Skip to main content

prism_mcp_rs/protocol/
version.rs

1//! Protocol revision negotiation and MCP 2026-07-28 wire envelopes.
2//!
3//! MCP 2025-11-25 uses a connection-scoped `initialize` handshake. MCP
4//! 2026-07-28 is stateless: every request declares its version, client
5//! identity, and capabilities in `_meta`. Keeping this behavior in one module
6//! prevents revision checks from leaking into application handlers.
7
8use 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
18/// Final stateless MCP protocol revision supported by Prism v3.
19pub const MODERN_PROTOCOL_VERSION: &str = "2026-07-28";
20/// Stateful MCP protocol revision retained for production interoperability.
21pub const LEGACY_PROTOCOL_VERSION: &str = "2025-11-25";
22/// Protocol revisions supported by a dual-stack Prism server, in preference order.
23pub const SUPPORTED_PROTOCOL_VERSIONS: [&str; 2] =
24    [MODERN_PROTOCOL_VERSION, LEGACY_PROTOCOL_VERSION];
25
26/// Reserved request `_meta` key carrying the protocol revision.
27pub const PROTOCOL_VERSION_META_KEY: &str = "io.modelcontextprotocol/protocolVersion";
28/// Reserved request `_meta` key carrying client identity.
29pub const CLIENT_INFO_META_KEY: &str = "io.modelcontextprotocol/clientInfo";
30/// Reserved request `_meta` key carrying per-request client capabilities.
31pub const CLIENT_CAPABILITIES_META_KEY: &str = "io.modelcontextprotocol/clientCapabilities";
32/// Reserved result `_meta` key carrying server identity.
33pub const SERVER_INFO_META_KEY: &str = "io.modelcontextprotocol/serverInfo";
34/// Standard HTTP protocol revision header.
35pub const MCP_PROTOCOL_VERSION_HEADER: &str = "MCP-Protocol-Version";
36/// Standard HTTP method routing header.
37pub const MCP_METHOD_HEADER: &str = "Mcp-Method";
38/// Standard HTTP resource/name routing header.
39pub const MCP_NAME_HEADER: &str = "Mcp-Name";
40
41/// MCP-defined header/body mismatch error code.
42pub const HEADER_MISMATCH: i32 = -32020;
43/// MCP-defined missing client capability error code.
44pub const MISSING_REQUIRED_CLIENT_CAPABILITY: i32 = -32021;
45/// MCP-defined unsupported revision error code.
46pub const UNSUPPORTED_PROTOCOL_VERSION: i32 = -32022;
47
48/// Runtime protocol selection policy.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
50#[serde(rename_all = "snake_case")]
51pub enum ProtocolMode {
52    /// Prefer MCP 2026-07-28 and fall back only when discovery is explicitly
53    /// rejected as an unknown method.
54    #[default]
55    Auto,
56    /// Require MCP 2026-07-28. Never downgrade.
57    ModernOnly,
58    /// Use the MCP 2025-11-25 initialize lifecycle byte-for-byte.
59    LegacyOnly,
60}
61
62/// The lifecycle family selected for an active client.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum ProtocolEra {
66    Modern,
67    Legacy,
68}
69
70/// Stable record of the protocol selected for a connection.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct NegotiatedProtocol {
73    pub version: String,
74    pub era: ProtocolEra,
75}
76
77/// Revision-neutral result returned when a client establishes protocol behavior.
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct ConnectResult {
80    pub protocol: NegotiatedProtocol,
81    pub capabilities: ServerCapabilities,
82    /// Server identity is optional in MCP 2026-07-28 and required by the
83    /// legacy initialize result, so v3 exposes that distinction directly.
84    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/// Required metadata envelope for a 2026-07-28 request.
105#[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/// Parameters for `server/discover`.
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub struct DiscoverParams {
137    #[serde(rename = "_meta")]
138    pub meta: RequestMetaObject,
139}
140
141/// Cache sharing policy introduced by MCP 2026-07-28.
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "lowercase")]
144pub enum CacheScope {
145    Public,
146    Private,
147}
148
149/// The discriminant present on all modern successful results.
150#[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/// Result of the stateless `server/discover` request.
163#[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/// A modern server response that asks the client to provide in-band input and retry.
190#[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    /// Build a validated input-required result. At least one input request or
208    /// request-state token is required by the protocol.
209    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/// A version-neutral operation result for MRTR-aware applications.
228#[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/// Parsed modern request information. It is request-scoped by design.
250#[derive(Debug, Clone, PartialEq)]
251pub struct ModernRequestContext {
252    pub version: String,
253    pub client_info: Option<Implementation>,
254    pub client_capabilities: ClientCapabilities,
255}
256
257/// Add or replace the required modern `_meta` fields without disturbing
258/// application-defined metadata.
259pub 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
290/// Parse and validate the required modern request envelope.
291pub 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
340/// Methods removed from the stateless 2026 core.
341pub 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
354/// Convert a handler-produced object into a standards-compliant modern result.
355pub 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
384/// List/discovery operations that require explicit cache policy in 2026-07-28.
385pub 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
397/// Extract the modern protocol version from a request body for HTTP routing.
398pub 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
409/// Derive the `Mcp-Name` header value from the corresponding request field.
410pub 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
451/// Extract and validate `x-mcp-header` annotations from a tool input schema.
452pub 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        // Crossing any other subschema keyword makes an annotation
504        // statically unreachable for x-mcp-header extraction.
505        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
560/// Encode a body string as an MCP HTTP routing-header value.
561pub 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
577/// Decode and validate an MCP HTTP routing-header value.
578pub 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
634/// Build the custom HTTP headers required for one tool call.
635pub 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
647/// Validate received `Mcp-Param-*` values against a tool call body.
648pub 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
679/// Validate standard HTTP routing headers against a modern request body.
680pub 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        // Legacy requests retain their existing transport behavior.
688        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
714/// Map SDK errors to their JSON-RPC code and structured MCP error data.
715pub 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
741/// Test whether an error is the one safe automatic-downgrade signal.
742pub 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}