1use std::collections::HashMap;
9
10use base64::Engine;
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value};
13
14use crate::core::error::{McpError, McpResult};
15
16use super::{ClientCapabilities, Implementation, JsonRpcRequest, ServerCapabilities};
17
18pub const MODERN_PROTOCOL_VERSION: &str = "2026-07-28";
20pub const LEGACY_PROTOCOL_VERSION: &str = "2025-11-25";
22pub const SUPPORTED_PROTOCOL_VERSIONS: [&str; 2] =
24 [MODERN_PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSION];
25
26pub const PROTOCOL_VERSION_META_KEY: &str = "io.modelcontextprotocol/protocolVersion";
28pub const CLIENT_INFO_META_KEY: &str = "io.modelcontextprotocol/clientInfo";
30pub const CLIENT_CAPABILITIES_META_KEY: &str = "io.modelcontextprotocol/clientCapabilities";
32pub const SERVER_INFO_META_KEY: &str = "io.modelcontextprotocol/serverInfo";
34pub const MCP_PROTOCOL_VERSION_HEADER: &str = "MCP-Protocol-Version";
36pub const MCP_METHOD_HEADER: &str = "Mcp-Method";
38pub const MCP_NAME_HEADER: &str = "Mcp-Name";
40
41pub const HEADER_MISMATCH: i32 = -32020;
43pub const MISSING_REQUIRED_CLIENT_CAPABILITY: i32 = -32021;
45pub const UNSUPPORTED_PROTOCOL_VERSION: i32 = -32022;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
50#[serde(rename_all = "snake_case")]
51pub enum ProtocolMode {
52 #[default]
55 Auto,
56 ModernOnly,
58 LegacyOnly,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum ProtocolEra {
66 Modern,
67 Legacy,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct NegotiatedProtocol {
73 pub version: String,
74 pub era: ProtocolEra,
75}
76
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct ConnectResult {
80 pub protocol: NegotiatedProtocol,
81 pub capabilities: ServerCapabilities,
82 pub server_info: Option<Implementation>,
85 pub instructions: Option<String>,
86}
87
88impl NegotiatedProtocol {
89 pub fn modern() -> Self {
90 Self {
91 version: MODERN_PROTOCOL_VERSION.to_string(),
92 era: ProtocolEra::Modern,
93 }
94 }
95
96 pub fn legacy() -> Self {
97 Self {
98 version: LEGACY_PROTOCOL_VERSION.to_string(),
99 era: ProtocolEra::Legacy,
100 }
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106pub struct RequestMetaObject {
107 #[serde(rename = "io.modelcontextprotocol/protocolVersion")]
108 pub protocol_version: String,
109 #[serde(
110 rename = "io.modelcontextprotocol/clientInfo",
111 skip_serializing_if = "Option::is_none"
112 )]
113 pub client_info: Option<Implementation>,
114 #[serde(rename = "io.modelcontextprotocol/clientCapabilities")]
115 pub client_capabilities: ClientCapabilities,
116 #[serde(rename = "progressToken", skip_serializing_if = "Option::is_none")]
117 pub progress_token: Option<Value>,
118 #[serde(flatten)]
119 pub additional: HashMap<String, Value>,
120}
121
122impl RequestMetaObject {
123 pub fn modern(client_info: Implementation, capabilities: ClientCapabilities) -> Self {
124 Self {
125 protocol_version: MODERN_PROTOCOL_VERSION.to_string(),
126 client_info: Some(client_info),
127 client_capabilities: capabilities,
128 progress_token: None,
129 additional: HashMap::new(),
130 }
131 }
132}
133
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub struct DiscoverParams {
137 #[serde(rename = "_meta")]
138 pub meta: RequestMetaObject,
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "lowercase")]
144pub enum CacheScope {
145 Public,
146 Private,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum ResultType {
153 Complete,
154 InputRequired,
155 Task,
156}
157
158fn default_complete() -> ResultType {
159 ResultType::Complete
160}
161
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
164pub struct DiscoverResult {
165 #[serde(rename = "resultType", default = "default_complete")]
166 pub result_type: ResultType,
167 #[serde(rename = "supportedVersions")]
168 pub supported_versions: Vec<String>,
169 pub capabilities: ServerCapabilities,
170 #[serde(skip_serializing_if = "Option::is_none")]
171 pub instructions: Option<String>,
172 #[serde(rename = "ttlMs")]
173 pub ttl_ms: u64,
174 #[serde(rename = "cacheScope")]
175 pub cache_scope: CacheScope,
176 #[serde(rename = "_meta", default, skip_serializing_if = "HashMap::is_empty")]
177 pub meta: HashMap<String, Value>,
178}
179
180impl DiscoverResult {
181 pub fn server_info(&self) -> Option<Implementation> {
182 self.meta
183 .get(SERVER_INFO_META_KEY)
184 .cloned()
185 .and_then(|value| serde_json::from_value(value).ok())
186 }
187}
188
189#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
191pub struct InputRequiredResult {
192 #[serde(rename = "resultType")]
193 pub result_type: ResultType,
194 #[serde(
195 rename = "inputRequests",
196 default,
197 skip_serializing_if = "HashMap::is_empty"
198 )]
199 pub input_requests: HashMap<String, Value>,
200 #[serde(rename = "requestState", skip_serializing_if = "Option::is_none")]
201 pub request_state: Option<String>,
202 #[serde(rename = "_meta", default, skip_serializing_if = "HashMap::is_empty")]
203 pub meta: HashMap<String, Value>,
204}
205
206impl InputRequiredResult {
207 pub fn new(
210 input_requests: HashMap<String, Value>,
211 request_state: Option<String>,
212 ) -> McpResult<Self> {
213 if input_requests.is_empty() && request_state.is_none() {
214 return Err(McpError::Validation(
215 "input_required needs inputRequests or requestState".to_string(),
216 ));
217 }
218 Ok(Self {
219 result_type: ResultType::InputRequired,
220 input_requests,
221 request_state,
222 meta: HashMap::new(),
223 })
224 }
225}
226
227#[derive(Debug, Clone, PartialEq)]
229pub enum OperationResult<T> {
230 Complete(T),
231 InputRequired(InputRequiredResult),
232}
233
234impl<T> OperationResult<T>
235where
236 T: serde::de::DeserializeOwned,
237{
238 pub fn from_value(value: Value) -> McpResult<Self> {
239 match value.get("resultType").and_then(Value::as_str) {
240 Some("input_required") => Ok(Self::InputRequired(serde_json::from_value(value)?)),
241 Some("complete") | None => Ok(Self::Complete(serde_json::from_value(value)?)),
242 Some(other) => Err(McpError::Protocol(format!(
243 "unsupported MCP resultType: {other}"
244 ))),
245 }
246 }
247}
248
249#[derive(Debug, Clone, PartialEq)]
251pub struct ModernRequestContext {
252 pub version: String,
253 pub client_info: Option<Implementation>,
254 pub client_capabilities: ClientCapabilities,
255}
256
257pub fn decorate_modern_request(
260 request: &mut JsonRpcRequest,
261 client_info: &Implementation,
262 capabilities: &ClientCapabilities,
263) -> McpResult<()> {
264 let params = request
265 .params
266 .get_or_insert_with(|| Value::Object(Map::new()))
267 .as_object_mut()
268 .ok_or_else(|| McpError::Validation("MCP request params must be an object".to_string()))?;
269 let meta = params
270 .entry("_meta")
271 .or_insert_with(|| Value::Object(Map::new()))
272 .as_object_mut()
273 .ok_or_else(|| McpError::Validation("MCP request _meta must be an object".to_string()))?;
274
275 meta.insert(
276 PROTOCOL_VERSION_META_KEY.to_string(),
277 Value::String(MODERN_PROTOCOL_VERSION.to_string()),
278 );
279 meta.insert(
280 CLIENT_INFO_META_KEY.to_string(),
281 serde_json::to_value(client_info)?,
282 );
283 meta.insert(
284 CLIENT_CAPABILITIES_META_KEY.to_string(),
285 serde_json::to_value(capabilities)?,
286 );
287 Ok(())
288}
289
290pub fn modern_request_context(request: &JsonRpcRequest) -> McpResult<Option<ModernRequestContext>> {
292 let Some(meta) = request
293 .params
294 .as_ref()
295 .and_then(Value::as_object)
296 .and_then(|params| params.get("_meta"))
297 .and_then(Value::as_object)
298 else {
299 return Ok(None);
300 };
301 let Some(version) = meta.get(PROTOCOL_VERSION_META_KEY).and_then(Value::as_str) else {
302 return Ok(None);
303 };
304
305 if version != MODERN_PROTOCOL_VERSION {
306 return Err(McpError::UnsupportedProtocolVersion {
307 requested: version.to_string(),
308 supported: SUPPORTED_PROTOCOL_VERSIONS
309 .iter()
310 .map(|value| (*value).to_string())
311 .collect(),
312 });
313 }
314
315 let capabilities = meta
316 .get(CLIENT_CAPABILITIES_META_KEY)
317 .cloned()
318 .ok_or_else(|| {
319 McpError::Validation(format!(
320 "missing required _meta key {CLIENT_CAPABILITIES_META_KEY}"
321 ))
322 })
323 .and_then(|value| {
324 serde_json::from_value(value).map_err(|error| McpError::Validation(error.to_string()))
325 })?;
326 let client_info = meta
327 .get(CLIENT_INFO_META_KEY)
328 .cloned()
329 .map(serde_json::from_value)
330 .transpose()
331 .map_err(|error| McpError::Validation(error.to_string()))?;
332
333 Ok(Some(ModernRequestContext {
334 version: version.to_string(),
335 client_info,
336 client_capabilities: capabilities,
337 }))
338}
339
340pub fn is_legacy_only_method(method: &str) -> bool {
342 matches!(
343 method,
344 "initialize"
345 | "notifications/initialized"
346 | "ping"
347 | "logging/setLevel"
348 | "resources/subscribe"
349 | "resources/unsubscribe"
350 | "notifications/roots/list_changed"
351 )
352}
353
354pub fn decorate_modern_result(
356 method: &str,
357 mut result: Value,
358 server_info: &Implementation,
359) -> McpResult<Value> {
360 let object = result.as_object_mut().ok_or_else(|| {
361 McpError::Protocol("MCP 2026 successful results must be JSON objects".to_string())
362 })?;
363 object
364 .entry("resultType")
365 .or_insert_with(|| Value::String("complete".to_string()));
366
367 if is_cacheable_method(method) {
368 object.entry("ttlMs").or_insert(Value::from(0_u64));
369 object
370 .entry("cacheScope")
371 .or_insert_with(|| Value::String("private".to_string()));
372 }
373
374 let meta = object
375 .entry("_meta")
376 .or_insert_with(|| Value::Object(Map::new()))
377 .as_object_mut()
378 .ok_or_else(|| McpError::Protocol("result _meta must be an object".to_string()))?;
379 meta.entry(SERVER_INFO_META_KEY)
380 .or_insert(serde_json::to_value(server_info)?);
381 Ok(result)
382}
383
384pub fn is_cacheable_method(method: &str) -> bool {
386 matches!(
387 method,
388 "server/discover"
389 | "tools/list"
390 | "prompts/list"
391 | "resources/list"
392 | "resources/templates/list"
393 | "resources/read"
394 )
395}
396
397pub fn request_protocol_version(request: &JsonRpcRequest) -> Option<&str> {
399 request
400 .params
401 .as_ref()?
402 .as_object()?
403 .get("_meta")?
404 .as_object()?
405 .get(PROTOCOL_VERSION_META_KEY)?
406 .as_str()
407}
408
409pub fn request_routing_name(request: &JsonRpcRequest) -> Option<&str> {
411 let params = request.params.as_ref()?.as_object()?;
412 let field = match request.method.as_str() {
413 "tools/call" | "prompts/get" => "name",
414 "resources/read" => "uri",
415 "tasks/get" | "tasks/update" | "tasks/cancel" => "taskId",
416 _ => return None,
417 };
418 params.get(field).and_then(Value::as_str)
419}
420
421#[derive(Debug, Clone, PartialEq, Eq)]
422pub struct ToolHeaderMapping {
423 pub header_name: String,
424 pub parameter_path: Vec<String>,
425}
426
427fn valid_header_token(value: &str) -> bool {
428 !value.is_empty()
429 && value.bytes().all(|byte| {
430 byte.is_ascii_alphanumeric()
431 || matches!(
432 byte,
433 b'!' | b'#'
434 | b'$'
435 | b'%'
436 | b'&'
437 | b'\''
438 | b'*'
439 | b'+'
440 | b'-'
441 | b'.'
442 | b'^'
443 | b'_'
444 | b'`'
445 | b'|'
446 | b'~'
447 )
448 })
449}
450
451pub fn tool_header_mappings(schema: &Value) -> McpResult<Vec<ToolHeaderMapping>> {
453 fn walk(
454 schema: &Value,
455 path: &mut Vec<String>,
456 properties_reachable: bool,
457 is_property: bool,
458 mappings: &mut Vec<ToolHeaderMapping>,
459 seen: &mut std::collections::HashSet<String>,
460 ) -> McpResult<()> {
461 let Some(object) = schema.as_object() else {
462 return Ok(());
463 };
464 if let Some(header) = object.get("x-mcp-header") {
465 if !properties_reachable || !is_property {
466 return Err(McpError::Validation(
467 "x-mcp-header is not reachable through properties only".to_string(),
468 ));
469 }
470 let suffix = header
471 .as_str()
472 .ok_or_else(|| McpError::Validation("x-mcp-header must be a string".to_string()))?;
473 if !valid_header_token(suffix) {
474 return Err(McpError::Validation(format!(
475 "invalid x-mcp-header token: {suffix}"
476 )));
477 }
478 let parameter_type = object.get("type").and_then(Value::as_str);
479 if !matches!(parameter_type, Some("string" | "integer" | "boolean")) {
480 return Err(McpError::Validation(format!(
481 "x-mcp-header {suffix} must annotate string, integer, or boolean"
482 )));
483 }
484 let normalized = suffix.to_ascii_lowercase();
485 if !seen.insert(normalized) {
486 return Err(McpError::Validation(format!(
487 "duplicate x-mcp-header name: {suffix}"
488 )));
489 }
490 mappings.push(ToolHeaderMapping {
491 header_name: format!("Mcp-Param-{suffix}"),
492 parameter_path: path.clone(),
493 });
494 }
495 if let Some(properties) = object.get("properties").and_then(Value::as_object) {
496 for (name, property) in properties {
497 path.push(name.clone());
498 walk(property, path, properties_reachable, true, mappings, seen)?;
499 path.pop();
500 }
501 }
502
503 for keyword in [
506 "items",
507 "contains",
508 "not",
509 "if",
510 "then",
511 "else",
512 "additionalProperties",
513 "unevaluatedProperties",
514 "propertyNames",
515 ] {
516 if let Some(subschema) = object.get(keyword).filter(|value| value.is_object()) {
517 walk(subschema, path, false, false, mappings, seen)?;
518 }
519 }
520 for keyword in ["allOf", "anyOf", "oneOf", "prefixItems"] {
521 if let Some(subschemas) = object.get(keyword).and_then(Value::as_array) {
522 for subschema in subschemas {
523 walk(subschema, path, false, false, mappings, seen)?;
524 }
525 }
526 }
527 for keyword in [
528 "$defs",
529 "definitions",
530 "patternProperties",
531 "dependentSchemas",
532 ] {
533 if let Some(subschemas) = object.get(keyword).and_then(Value::as_object) {
534 for subschema in subschemas.values() {
535 walk(subschema, path, false, false, mappings, seen)?;
536 }
537 }
538 }
539 Ok(())
540 }
541
542 let mut mappings = Vec::new();
543 let mut seen = std::collections::HashSet::new();
544 walk(
545 schema,
546 &mut Vec::new(),
547 true,
548 false,
549 &mut mappings,
550 &mut seen,
551 )?;
552 Ok(mappings)
553}
554
555fn parameter_at_path<'a>(arguments: &'a Value, path: &[String]) -> Option<&'a Value> {
556 path.iter()
557 .try_fold(arguments, |value, segment| value.get(segment))
558}
559
560pub fn encode_http_header_value(raw: &str) -> String {
562 let sentinel = raw.starts_with("=?base64?") && raw.ends_with("?=");
563 let unsafe_value = raw.starts_with([' ', '\t'])
564 || raw.ends_with([' ', '\t'])
565 || !raw.bytes().all(|byte| (0x20..=0x7e).contains(&byte))
566 || sentinel;
567 if unsafe_value {
568 format!(
569 "=?base64?{}?=",
570 base64::engine::general_purpose::STANDARD.encode(raw.as_bytes())
571 )
572 } else {
573 raw.to_string()
574 }
575}
576
577pub fn decode_http_header_value(value: &str) -> McpResult<String> {
579 if let Some(encoded) = value
580 .strip_prefix("=?base64?")
581 .and_then(|value| value.strip_suffix("?="))
582 {
583 let bytes = base64::engine::general_purpose::STANDARD
584 .decode(encoded)
585 .map_err(|_| McpError::HeaderMismatch("invalid base64 header value".to_string()))?;
586 return String::from_utf8(bytes)
587 .map_err(|_| McpError::HeaderMismatch("header value is not UTF-8".to_string()));
588 }
589 if value.starts_with([' ', '\t'])
590 || value.ends_with([' ', '\t'])
591 || !value
592 .bytes()
593 .all(|byte| byte == b'\t' || (0x20..=0x7e).contains(&byte))
594 {
595 return Err(McpError::HeaderMismatch(
596 "unsafe plain MCP header value".to_string(),
597 ));
598 }
599 Ok(value.to_string())
600}
601
602fn header_parameter_string(value: &Value) -> McpResult<Option<String>> {
603 let raw = match value {
604 Value::Null => return Ok(None),
605 Value::String(value) => value.clone(),
606 Value::Bool(value) => value.to_string(),
607 Value::Number(value) if value.is_i64() => {
608 let integer = value.as_i64().expect("checked integer");
609 if !(-9_007_199_254_740_991..=9_007_199_254_740_991).contains(&integer) {
610 return Err(McpError::Validation(
611 "x-mcp-header integer exceeds JavaScript safe range".to_string(),
612 ));
613 }
614 integer.to_string()
615 }
616 Value::Number(value) if value.is_u64() => {
617 let integer = value.as_u64().expect("checked integer");
618 if integer > 9_007_199_254_740_991 {
619 return Err(McpError::Validation(
620 "x-mcp-header integer exceeds JavaScript safe range".to_string(),
621 ));
622 }
623 integer.to_string()
624 }
625 _ => {
626 return Err(McpError::Validation(
627 "x-mcp-header value must be string, integer, boolean, or null".to_string(),
628 ))
629 }
630 };
631 Ok(Some(encode_http_header_value(&raw)))
632}
633
634pub fn tool_call_headers(schema: &Value, arguments: &Value) -> McpResult<HashMap<String, String>> {
636 let mut headers = HashMap::new();
637 for mapping in tool_header_mappings(schema)? {
638 if let Some(value) = parameter_at_path(arguments, &mapping.parameter_path) {
639 if let Some(value) = header_parameter_string(value)? {
640 headers.insert(mapping.header_name, value);
641 }
642 }
643 }
644 Ok(headers)
645}
646
647pub fn validate_tool_call_headers(
649 schema: &Value,
650 arguments: &Value,
651 received: &HashMap<String, String>,
652) -> McpResult<()> {
653 let expected = tool_call_headers(schema, arguments)?;
654 let received = received
655 .iter()
656 .map(|(name, value)| (name.to_ascii_lowercase(), value.as_str()))
657 .collect::<HashMap<_, _>>();
658 for mapping in tool_header_mappings(schema)? {
659 let name = mapping.header_name.to_ascii_lowercase();
660 let expected_value = expected.get(&mapping.header_name);
661 let received_value = received.get(&name).copied();
662 let matches = match (expected_value, received_value) {
663 (None, None) => true,
664 (Some(expected), Some(received)) => {
665 decode_http_header_value(expected)? == decode_http_header_value(received)?
666 }
667 _ => false,
668 };
669 if !matches {
670 return Err(McpError::HeaderMismatch(format!(
671 "{} does not match the tool arguments",
672 mapping.header_name
673 )));
674 }
675 }
676 Ok(())
677}
678
679pub fn validate_http_headers(
681 request: &JsonRpcRequest,
682 protocol_version: Option<&str>,
683 method: Option<&str>,
684 name: Option<&str>,
685) -> McpResult<()> {
686 let Some(body_version) = request_protocol_version(request) else {
687 return Ok(());
689 };
690 if protocol_version != Some(body_version) {
691 return Err(McpError::HeaderMismatch(format!(
692 "{MCP_PROTOCOL_VERSION_HEADER} does not match request _meta"
693 )));
694 }
695 if method != Some(request.method.as_str()) {
696 return Err(McpError::HeaderMismatch(format!(
697 "{MCP_METHOD_HEADER} does not match request method"
698 )));
699 }
700 let body_name = request_routing_name(request);
701 let name_matches = match (name, body_name) {
702 (None, None) => true,
703 (Some(header), Some(body)) => decode_http_header_value(header)? == body,
704 _ => false,
705 };
706 if !name_matches {
707 return Err(McpError::HeaderMismatch(format!(
708 "{MCP_NAME_HEADER} does not match the request target"
709 )));
710 }
711 Ok(())
712}
713
714pub fn json_rpc_error_details(error: &McpError) -> (i32, Option<Value>) {
716 match error {
717 McpError::MethodNotFound(_) => (-32601, None),
718 McpError::InvalidParams(_) | McpError::Validation(_) => (-32602, None),
719 McpError::HeaderMismatch(_) => (HEADER_MISMATCH, None),
720 McpError::MissingRequiredClientCapability(capability) => (
721 MISSING_REQUIRED_CLIENT_CAPABILITY,
722 Some(serde_json::json!({"requiredCapabilities": capability})),
723 ),
724 McpError::UnsupportedProtocolVersion {
725 requested,
726 supported,
727 } => (
728 UNSUPPORTED_PROTOCOL_VERSION,
729 Some(serde_json::json!({
730 "requested": requested,
731 "supported": supported,
732 })),
733 ),
734 McpError::ToolNotFound(_) | McpError::ResourceNotFound(_) | McpError::PromptNotFound(_) => {
735 (-32602, None)
736 }
737 _ => (-32603, None),
738 }
739}
740
741pub fn is_method_not_found(error: &McpError) -> bool {
743 match error {
744 McpError::MethodNotFound(_) => true,
745 McpError::Protocol(message) => message.contains("-32601"),
746 _ => false,
747 }
748}
749
750#[cfg(test)]
751mod tests {
752 use super::*;
753 use serde_json::json;
754
755 fn info() -> Implementation {
756 Implementation::new("prism-test", "3.0.0")
757 }
758
759 #[test]
760 fn modern_request_is_self_describing() {
761 let mut request =
762 JsonRpcRequest::new(1.into(), "tools/list".into(), Some(json!({}))).expect("request");
763 decorate_modern_request(&mut request, &info(), &ClientCapabilities::default())
764 .expect("decorate");
765 let context = modern_request_context(&request)
766 .expect("valid")
767 .expect("modern");
768 assert_eq!(context.version, MODERN_PROTOCOL_VERSION);
769 assert_eq!(context.client_info.expect("identity").name, "prism-test");
770 }
771
772 #[test]
773 fn modern_result_gets_identity_and_cache_policy() {
774 let result = decorate_modern_result("tools/list", json!({"tools": []}), &info())
775 .expect("decorate result");
776 assert_eq!(result["resultType"], "complete");
777 assert_eq!(result["ttlMs"], 0);
778 assert_eq!(result["cacheScope"], "private");
779 assert_eq!(result["_meta"][SERVER_INFO_META_KEY]["name"], "prism-test");
780 }
781
782 #[test]
783 fn input_required_requires_resume_material() {
784 assert!(InputRequiredResult::new(HashMap::new(), None).is_err());
785 assert!(InputRequiredResult::new(HashMap::new(), Some("opaque".into())).is_ok());
786 }
787
788 #[test]
789 fn custom_tool_headers_support_nested_primitive_parameters() {
790 let schema = json!({
791 "type": "object",
792 "properties": {
793 "region": {"type": "string", "x-mcp-header": "Region"},
794 "options": {
795 "type": "object",
796 "properties": {
797 "priority": {"type": "integer", "x-mcp-header": "Priority"},
798 "dryRun": {"type": "boolean", "x-mcp-header": "Dry-Run"}
799 }
800 }
801 }
802 });
803 let arguments = json!({
804 "region": "eu-north-1",
805 "options": {"priority": 7, "dryRun": true}
806 });
807
808 let headers = tool_call_headers(&schema, &arguments).expect("headers");
809 assert_eq!(headers.get("Mcp-Param-Region"), Some(&"eu-north-1".into()));
810 assert_eq!(headers.get("Mcp-Param-Priority"), Some(&"7".into()));
811 assert_eq!(headers.get("Mcp-Param-Dry-Run"), Some(&"true".into()));
812 validate_tool_call_headers(&schema, &arguments, &headers).expect("matching headers");
813 }
814
815 #[test]
816 fn unsafe_tool_header_values_are_base64_wrapped() {
817 let schema = json!({
818 "type": "object",
819 "properties": {
820 "token": {"type": "string", "x-mcp-header": "Token"}
821 }
822 });
823 let headers =
824 tool_call_headers(&schema, &json!({"token": " secret\n"})).expect("encoded header");
825 assert_eq!(
826 headers.get("Mcp-Param-Token"),
827 Some(&"=?base64?IHNlY3JldAo=?=".to_string())
828 );
829 }
830
831 #[test]
832 fn duplicate_custom_header_names_are_rejected_case_insensitively() {
833 let schema = json!({
834 "type": "object",
835 "properties": {
836 "first": {"type": "string", "x-mcp-header": "Region"},
837 "second": {"type": "string", "x-mcp-header": "region"}
838 }
839 });
840 assert!(tool_header_mappings(&schema).is_err());
841 }
842
843 #[test]
844 fn custom_headers_outside_properties_only_paths_are_rejected() {
845 let schema = json!({
846 "type": "object",
847 "properties": {
848 "items": {
849 "type": "array",
850 "items": {
851 "type": "object",
852 "properties": {
853 "region": {"type": "string", "x-mcp-header": "Region"}
854 }
855 }
856 }
857 }
858 });
859 assert!(tool_header_mappings(&schema).is_err());
860 }
861
862 #[test]
863 fn routing_names_use_and_validate_base64_sentinel_encoding() {
864 let mut request = JsonRpcRequest::new(
865 1.into(),
866 "resources/read".into(),
867 Some(json!({"uri": "file:///résumé.txt"})),
868 )
869 .expect("request");
870 decorate_modern_request(&mut request, &info(), &ClientCapabilities::default())
871 .expect("decorate");
872 let encoded = encode_http_header_value("file:///résumé.txt");
873 validate_http_headers(
874 &request,
875 Some(MODERN_PROTOCOL_VERSION),
876 Some("resources/read"),
877 Some(&encoded),
878 )
879 .expect("encoded name");
880 }
881
882 #[test]
883 fn unknown_custom_headers_are_ignored() {
884 let schema = json!({
885 "type": "object",
886 "properties": {
887 "region": {"type": "string", "x-mcp-header": "Region"}
888 }
889 });
890 let received = HashMap::from([
891 ("mcp-param-region".to_string(), "eu-north-1".to_string()),
892 ("mcp-param-proxy-only".to_string(), "route-a".to_string()),
893 ]);
894 validate_tool_call_headers(&schema, &json!({"region": "eu-north-1"}), &received)
895 .expect("unknown header ignored");
896 }
897}