Skip to main content

fastmcp_protocol/
protocol_version.rs

1//! Final MCP protocol-version header validation.
2//!
3//! This module models the `2026-07-28` HTTP rule only: every request carries
4//! an `MCP-Protocol-Version` header whose exact value matches the body's
5//! `io.modelcontextprotocol/protocolVersion` value. Transport code owns HTTP
6//! header parsing and supplies the already-decoded field value here.
7
8use serde_json::{Map, Value, json};
9
10use crate::ClientCapabilities;
11
12/// The final protocol version implemented by this narrow modern surface.
13pub const FINAL_PROTOCOL_VERSION: &str = "2026-07-28";
14
15/// The exact protocol versions admitted by this final-only surface.
16pub const SUPPORTED_FINAL_PROTOCOL_VERSIONS: &[&str] = &[FINAL_PROTOCOL_VERSION];
17
18/// The HTTP header that mirrors the body's protocol-version metadata.
19pub const MCP_PROTOCOL_VERSION_HEADER: &str = "MCP-Protocol-Version";
20
21/// The HTTP header that mirrors the JSON-RPC request method.
22pub const MCP_METHOD_HEADER: &str = "Mcp-Method";
23
24/// The HTTP header that mirrors a method-specific tool, resource, or prompt name.
25pub const MCP_NAME_HEADER: &str = "Mcp-Name";
26
27/// The final MCP JSON-RPC code for malformed, missing, or mismatched headers.
28pub const HEADER_MISMATCH_ERROR_CODE: i32 = -32020;
29
30/// The final MCP JSON-RPC code for a missing required client capability.
31pub const MISSING_REQUIRED_CLIENT_CAPABILITY_ERROR_CODE: i32 = -32021;
32
33/// The final MCP JSON-RPC code for a version the server does not support.
34pub const UNSUPPORTED_PROTOCOL_VERSION_ERROR_CODE: i32 = -32022;
35
36/// The maximum encoded size accepted for typed required-capabilities error data.
37pub const MAX_REQUIRED_CAPABILITIES_ERROR_DATA_BYTES: usize = 64 * 1024;
38
39/// A validated final protocol version.
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub struct FinalProtocolVersion;
42
43impl FinalProtocolVersion {
44    /// Returns the exact wire value for this final version.
45    #[must_use]
46    pub const fn as_str(self) -> &'static str {
47        FINAL_PROTOCOL_VERSION
48    }
49}
50
51/// The request metadata whose HTTP and JSON body values mirror each other.
52#[derive(Clone, Copy, Debug, Eq, PartialEq)]
53pub struct RequestVersionMetadata<'a> {
54    /// The already-decoded `MCP-Protocol-Version` HTTP header value.
55    pub header_version: Option<&'a str>,
56    /// The `io.modelcontextprotocol/protocolVersion` body metadata value.
57    pub body_version: Option<&'a str>,
58}
59
60/// A request admitted through the final protocol-version boundary.
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub struct FinalRequestAdmission {
63    version: FinalProtocolVersion,
64}
65
66impl FinalRequestAdmission {
67    /// Returns the version whose header/body mirror was admitted.
68    #[must_use]
69    pub const fn protocol_version(self) -> FinalProtocolVersion {
70        self.version
71    }
72}
73
74/// HTTP metadata mirrored from the final JSON-RPC request body.
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76pub struct FinalHttpRequestMetadata<'a> {
77    /// The final protocol-version header/body mirror.
78    pub version: RequestVersionMetadata<'a>,
79    /// The `Mcp-Method` header value.
80    pub header_method: Option<&'a str>,
81    /// The JSON-RPC request method.
82    pub body_method: Option<&'a str>,
83    /// The conditional `Mcp-Name` header value.
84    pub header_name: Option<&'a str>,
85    /// The conditional name or URI value from the request body.
86    pub body_name: Option<&'a str>,
87}
88
89/// The exact header/body condition that failed final request admission.
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91pub enum HeaderMismatchReason {
92    /// The required protocol-version header was absent.
93    MissingHeader,
94    /// The required body protocol-version field was absent.
95    MissingBodyVersion,
96    /// The header value was empty.
97    EmptyHeader,
98    /// The body value was empty.
99    EmptyBodyVersion,
100    /// The supplied header and body values were different.
101    HeaderBodyVersionMismatch,
102    /// The required `Mcp-Method` header was absent.
103    MissingMethodHeader,
104    /// The JSON-RPC request method was absent.
105    MissingBodyMethod,
106    /// The `Mcp-Method` header was empty.
107    EmptyMethodHeader,
108    /// The JSON-RPC request method was empty.
109    EmptyBodyMethod,
110    /// The supplied `Mcp-Method` header and JSON-RPC method differed.
111    HeaderBodyMethodMismatch,
112    /// A method that requires `Mcp-Name` omitted that header.
113    MissingNameHeader,
114    /// A method that requires `Mcp-Name` omitted the matching body value.
115    MissingBodyName,
116    /// The required `Mcp-Name` header was empty.
117    EmptyNameHeader,
118    /// The matching body name or URI was empty.
119    EmptyBodyName,
120    /// The supplied `Mcp-Name` header and matching body value differed.
121    HeaderBodyNameMismatch,
122}
123
124/// Typed local detail for a final `HeaderMismatchError`.
125///
126/// The detail is for local routing and diagnostics only. Canonical peer-facing
127/// header-mismatch emission has no required error-data shape.
128#[derive(Clone, Copy, Debug, Eq, PartialEq)]
129pub struct HeaderMismatchError {
130    reason: HeaderMismatchReason,
131}
132
133impl HeaderMismatchError {
134    /// Returns the local typed reason without constructing peer error data.
135    #[must_use]
136    pub const fn reason(self) -> HeaderMismatchReason {
137        self.reason
138    }
139
140    /// Returns the final MCP JSON-RPC header-mismatch code.
141    #[must_use]
142    pub const fn jsonrpc_error_code(self) -> i32 {
143        HEADER_MISMATCH_ERROR_CODE
144    }
145
146    /// Returns the final HTTP status for a header mismatch.
147    #[must_use]
148    pub const fn http_status(self) -> u16 {
149        400
150    }
151
152    /// Returns no canonical peer error data for a header mismatch.
153    #[must_use]
154    pub fn canonical_error_data(self) -> Option<Value> {
155        None
156    }
157}
158
159/// Typed data for a final unsupported-protocol-version response.
160#[derive(Clone, Debug, Eq, PartialEq)]
161pub struct UnsupportedProtocolVersionError {
162    requested: String,
163}
164
165impl UnsupportedProtocolVersionError {
166    /// Returns the exact matching header/body value the server rejected.
167    #[must_use]
168    pub fn requested(&self) -> &str {
169        &self.requested
170    }
171
172    /// Returns the exact final versions that this surface supports.
173    #[must_use]
174    pub const fn supported_versions(&self) -> &'static [&'static str] {
175        SUPPORTED_FINAL_PROTOCOL_VERSIONS
176    }
177
178    /// Returns the final MCP JSON-RPC unsupported-version code.
179    #[must_use]
180    pub const fn jsonrpc_error_code(&self) -> i32 {
181        UNSUPPORTED_PROTOCOL_VERSION_ERROR_CODE
182    }
183
184    /// Returns the final HTTP status for an unsupported version.
185    #[must_use]
186    pub const fn http_status(&self) -> u16 {
187        400
188    }
189
190    /// Returns the exact final peer error-data object.
191    #[must_use]
192    pub fn canonical_error_data(&self) -> Value {
193        json!({
194            "supported": self.supported_versions(),
195            "requested": self.requested(),
196        })
197    }
198}
199
200/// Why a caller could not construct required-capabilities error data.
201#[derive(Clone, Copy, Debug, Eq, PartialEq)]
202pub enum RequiredCapabilitiesError {
203    /// The protocol requires an object, not another JSON value kind.
204    NotAnObject,
205    /// The exact JSON encoding exceeded the bounded peer-data allowance.
206    TooLarge,
207    /// The JSON object could not be encoded for the peer-facing error shape.
208    Encoding,
209}
210
211/// Typed final error data for a missing required client capability.
212#[derive(Clone, Debug, PartialEq)]
213pub struct MissingRequiredClientCapabilityError {
214    required_capabilities: Map<String, Value>,
215}
216
217impl MissingRequiredClientCapabilityError {
218    /// Constructs the error from the exact typed client-capabilities object.
219    ///
220    /// This is the safe local constructor for a capability-gated final request:
221    /// it serializes the standard typed shape without accepting a separately
222    /// assembled peer-data object.
223    pub fn from_client_capabilities(
224        required_capabilities: &ClientCapabilities,
225    ) -> Result<Self, RequiredCapabilitiesError> {
226        let required_capabilities = serde_json::to_value(required_capabilities)
227            .map_err(|_| RequiredCapabilitiesError::Encoding)?;
228        Self::new(required_capabilities)
229    }
230
231    /// Constructs the error from the exact required-capabilities object.
232    ///
233    /// Flattened diagnostic paths deliberately do not enter peer-facing data;
234    /// the exact object is retained for the final error shape instead.
235    pub fn new(required_capabilities: Value) -> Result<Self, RequiredCapabilitiesError> {
236        let Value::Object(required_capabilities) = required_capabilities else {
237            return Err(RequiredCapabilitiesError::NotAnObject);
238        };
239        let encoded_len = serde_json::to_vec(&required_capabilities)
240            .map_err(|_| RequiredCapabilitiesError::Encoding)?
241            .len();
242        if encoded_len > MAX_REQUIRED_CAPABILITIES_ERROR_DATA_BYTES {
243            return Err(RequiredCapabilitiesError::TooLarge);
244        }
245        Ok(Self {
246            required_capabilities,
247        })
248    }
249
250    /// Returns the exact required-capabilities object.
251    #[must_use]
252    pub const fn required_capabilities(&self) -> &Map<String, Value> {
253        &self.required_capabilities
254    }
255
256    /// Returns the final MCP JSON-RPC missing-capability code.
257    #[must_use]
258    pub const fn jsonrpc_error_code(&self) -> i32 {
259        MISSING_REQUIRED_CLIENT_CAPABILITY_ERROR_CODE
260    }
261
262    /// Returns the final HTTP status for a missing client capability.
263    #[must_use]
264    pub const fn http_status(&self) -> u16 {
265        400
266    }
267
268    /// Returns the exact final peer error-data object.
269    #[must_use]
270    pub fn canonical_error_data(&self) -> Value {
271        json!({"requiredCapabilities": self.required_capabilities})
272    }
273}
274
275/// Typed failure from final request version admission.
276#[derive(Clone, Debug, Eq, PartialEq)]
277pub enum RequestAdmissionError {
278    /// Required metadata was missing, empty, malformed, or failed the mirror check.
279    HeaderMismatch(HeaderMismatchError),
280    /// The mirror was valid but selected a version this surface does not support.
281    UnsupportedProtocolVersion(UnsupportedProtocolVersionError),
282}
283
284impl RequestAdmissionError {
285    /// Returns the HTTP status required by this final request-admission failure.
286    #[must_use]
287    pub const fn http_status(&self) -> u16 {
288        match self {
289            Self::HeaderMismatch(error) => error.http_status(),
290            Self::UnsupportedProtocolVersion(error) => error.http_status(),
291        }
292    }
293
294    /// Returns the final MCP JSON-RPC error code for this failure.
295    #[must_use]
296    pub const fn jsonrpc_error_code(&self) -> i32 {
297        match self {
298            Self::HeaderMismatch(error) => error.jsonrpc_error_code(),
299            Self::UnsupportedProtocolVersion(error) => error.jsonrpc_error_code(),
300        }
301    }
302}
303
304/// Why final protocol-version validation rejected a request.
305#[derive(Clone, Debug, Eq, PartialEq)]
306pub enum ProtocolVersionError {
307    /// The required header or body field was absent, empty, malformed, or differed.
308    HeaderMismatch,
309    /// Header and body agreed on a well-formed value that this final surface does not support.
310    UnsupportedProtocolVersion { requested: String },
311}
312
313impl ProtocolVersionError {
314    /// Returns the HTTP status required for either final version failure.
315    #[must_use]
316    pub const fn http_status(&self) -> u16 {
317        400
318    }
319
320    /// Returns the final MCP JSON-RPC error code.
321    #[must_use]
322    pub const fn jsonrpc_error_code(&self) -> i32 {
323        match self {
324            Self::HeaderMismatch => HEADER_MISMATCH_ERROR_CODE,
325            Self::UnsupportedProtocolVersion { .. } => UNSUPPORTED_PROTOCOL_VERSION_ERROR_CODE,
326        }
327    }
328}
329
330/// Validates the final HTTP header/body protocol-version mirror.
331///
332/// This deliberately performs no legacy fallback. A missing or empty header,
333/// a missing or empty body value, and different values all use the final
334/// header-mismatch classification. Matching non-final values are classified as
335/// unsupported and retain the requested value for the caller's typed error
336/// data.
337pub fn validate_final_protocol_version(
338    header_version: Option<&str>,
339    body_version: Option<&str>,
340) -> Result<FinalProtocolVersion, ProtocolVersionError> {
341    admit_final_request(RequestVersionMetadata {
342        header_version,
343        body_version,
344    })
345    .map(|admission| admission.protocol_version())
346    .map_err(|error| match error {
347        RequestAdmissionError::HeaderMismatch(_) => ProtocolVersionError::HeaderMismatch,
348        RequestAdmissionError::UnsupportedProtocolVersion(error) => {
349            ProtocolVersionError::UnsupportedProtocolVersion {
350                requested: error.requested,
351            }
352        }
353    })
354}
355
356/// Admits final request metadata using the required error precedence.
357///
358/// Header/body validity is evaluated before version support. This prevents a
359/// mismatched or malformed mirror from being misclassified as an unsupported
360/// version and ensures callers never select policy from an untrusted header.
361pub fn admit_final_request(
362    metadata: RequestVersionMetadata<'_>,
363) -> Result<FinalRequestAdmission, RequestAdmissionError> {
364    let header_version = metadata
365        .header_version
366        .ok_or(RequestAdmissionError::HeaderMismatch(HeaderMismatchError {
367            reason: HeaderMismatchReason::MissingHeader,
368        }))?;
369    let body_version = metadata
370        .body_version
371        .ok_or(RequestAdmissionError::HeaderMismatch(HeaderMismatchError {
372            reason: HeaderMismatchReason::MissingBodyVersion,
373        }))?;
374
375    if header_version.is_empty() {
376        return Err(RequestAdmissionError::HeaderMismatch(HeaderMismatchError {
377            reason: HeaderMismatchReason::EmptyHeader,
378        }));
379    }
380    if body_version.is_empty() {
381        return Err(RequestAdmissionError::HeaderMismatch(HeaderMismatchError {
382            reason: HeaderMismatchReason::EmptyBodyVersion,
383        }));
384    }
385    if header_version != body_version {
386        return Err(RequestAdmissionError::HeaderMismatch(HeaderMismatchError {
387            reason: HeaderMismatchReason::HeaderBodyVersionMismatch,
388        }));
389    }
390    if header_version != FINAL_PROTOCOL_VERSION {
391        return Err(RequestAdmissionError::UnsupportedProtocolVersion(
392            UnsupportedProtocolVersionError {
393                requested: header_version.to_owned(),
394            },
395        ));
396    }
397
398    Ok(FinalRequestAdmission {
399        version: FinalProtocolVersion,
400    })
401}
402
403/// Admits the standard final HTTP header mirrors for a request.
404///
405/// Protocol-version admission occurs first. Once that mirror selects the
406/// final protocol, `Mcp-Method` must exactly match the JSON-RPC method. The
407/// `Mcp-Name` mirror is then required for name- or identifier-addressed
408/// methods, including official Tasks operations; no extra header is inferred
409/// for other methods.
410pub fn admit_final_http_request(
411    metadata: FinalHttpRequestMetadata<'_>,
412) -> Result<FinalRequestAdmission, RequestAdmissionError> {
413    let admission = admit_final_request(metadata.version)?;
414    let method = exact_nonempty_mirror(
415        metadata.header_method,
416        metadata.body_method,
417        HeaderMismatchReason::MissingMethodHeader,
418        HeaderMismatchReason::MissingBodyMethod,
419        HeaderMismatchReason::EmptyMethodHeader,
420        HeaderMismatchReason::EmptyBodyMethod,
421        HeaderMismatchReason::HeaderBodyMethodMismatch,
422    )?;
423    if requires_mcp_name(method) {
424        let _ = exact_nonempty_mirror(
425            metadata.header_name,
426            metadata.body_name,
427            HeaderMismatchReason::MissingNameHeader,
428            HeaderMismatchReason::MissingBodyName,
429            HeaderMismatchReason::EmptyNameHeader,
430            HeaderMismatchReason::EmptyBodyName,
431            HeaderMismatchReason::HeaderBodyNameMismatch,
432        )?;
433    }
434    Ok(admission)
435}
436
437fn exact_nonempty_mirror<'a>(
438    header: Option<&'a str>,
439    body: Option<&'a str>,
440    missing_header: HeaderMismatchReason,
441    missing_body: HeaderMismatchReason,
442    empty_header: HeaderMismatchReason,
443    empty_body: HeaderMismatchReason,
444    mismatch: HeaderMismatchReason,
445) -> Result<&'a str, RequestAdmissionError> {
446    let header = header.ok_or(RequestAdmissionError::HeaderMismatch(HeaderMismatchError {
447        reason: missing_header,
448    }))?;
449    let body = body.ok_or(RequestAdmissionError::HeaderMismatch(HeaderMismatchError {
450        reason: missing_body,
451    }))?;
452    if header.is_empty() {
453        return Err(RequestAdmissionError::HeaderMismatch(HeaderMismatchError {
454            reason: empty_header,
455        }));
456    }
457    if body.is_empty() {
458        return Err(RequestAdmissionError::HeaderMismatch(HeaderMismatchError {
459            reason: empty_body,
460        }));
461    }
462    if header != body {
463        return Err(RequestAdmissionError::HeaderMismatch(HeaderMismatchError {
464            reason: mismatch,
465        }));
466    }
467    Ok(header)
468}
469
470fn requires_mcp_name(method: &str) -> bool {
471    matches!(
472        method,
473        "tools/call"
474            | "resources/read"
475            | "prompts/get"
476            | "tasks/get"
477            | "tasks/update"
478            | "tasks/cancel"
479    )
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    #[test]
487    fn prt_03_a_positive() {
488        let admission = admit_final_http_request(FinalHttpRequestMetadata {
489            version: RequestVersionMetadata {
490                header_version: Some(FINAL_PROTOCOL_VERSION),
491                body_version: Some(FINAL_PROTOCOL_VERSION),
492            },
493            header_method: Some("tools/call"),
494            body_method: Some("tools/call"),
495            header_name: Some("weather"),
496            body_name: Some("weather"),
497        })
498        .expect("matching final standard headers and body values must be admitted");
499
500        assert_eq!(
501            admission.protocol_version().as_str(),
502            FINAL_PROTOCOL_VERSION
503        );
504        assert_eq!(MCP_PROTOCOL_VERSION_HEADER, "MCP-Protocol-Version");
505        assert_eq!(MCP_METHOD_HEADER, "Mcp-Method");
506        assert_eq!(MCP_NAME_HEADER, "Mcp-Name");
507    }
508
509    #[test]
510    fn prt_03_a_planted_negative() {
511        let body_name = Some("weather");
512        let error = admit_final_http_request(FinalHttpRequestMetadata {
513            version: RequestVersionMetadata {
514                header_version: Some(FINAL_PROTOCOL_VERSION),
515                body_version: Some(FINAL_PROTOCOL_VERSION),
516            },
517            header_method: Some("tools/call"),
518            body_method: Some("tools/call"),
519            header_name: Some("other-weather"),
520            body_name,
521        })
522        .expect_err("changing only the name header must reject the request");
523
524        assert_eq!(
525            error,
526            RequestAdmissionError::HeaderMismatch(HeaderMismatchError {
527                reason: HeaderMismatchReason::HeaderBodyNameMismatch,
528            })
529        );
530        assert_eq!(error.http_status(), 400);
531        assert_eq!(error.jsonrpc_error_code(), HEADER_MISMATCH_ERROR_CODE);
532        assert_eq!(body_name, Some("weather"));
533    }
534
535    #[test]
536    fn official_tasks_methods_require_the_same_mcp_name_mirror() {
537        for method in ["tasks/get", "tasks/update", "tasks/cancel"] {
538            let admitted = admit_final_http_request(FinalHttpRequestMetadata {
539                version: RequestVersionMetadata {
540                    header_version: Some(FINAL_PROTOCOL_VERSION),
541                    body_version: Some(FINAL_PROTOCOL_VERSION),
542                },
543                header_method: Some(method),
544                body_method: Some(method),
545                header_name: Some("task-42"),
546                body_name: Some("task-42"),
547            });
548            assert!(
549                admitted.is_ok(),
550                "{method} accepts an exact task identifier mirror"
551            );
552        }
553
554        let body_name = Some("task-42");
555        let error = admit_final_http_request(FinalHttpRequestMetadata {
556            version: RequestVersionMetadata {
557                header_version: Some(FINAL_PROTOCOL_VERSION),
558                body_version: Some(FINAL_PROTOCOL_VERSION),
559            },
560            header_method: Some("tasks/get"),
561            body_method: Some("tasks/get"),
562            header_name: Some("other-task"),
563            body_name,
564        })
565        .expect_err("changing only a Tasks name mirror rejects final admission");
566        assert_eq!(
567            error,
568            RequestAdmissionError::HeaderMismatch(HeaderMismatchError {
569                reason: HeaderMismatchReason::HeaderBodyNameMismatch,
570            })
571        );
572        assert_eq!(body_name, Some("task-42"));
573    }
574
575    #[test]
576    fn matching_unsupported_version_reports_the_requested_value() {
577        let error = validate_final_protocol_version(Some("2025-11-25"), Some("2025-11-25"))
578            .expect_err("matching unsupported versions must not be accepted");
579
580        assert_eq!(
581            error,
582            ProtocolVersionError::UnsupportedProtocolVersion {
583                requested: "2025-11-25".to_owned(),
584            }
585        );
586        assert_eq!(error.http_status(), 400);
587        assert_eq!(
588            error.jsonrpc_error_code(),
589            UNSUPPORTED_PROTOCOL_VERSION_ERROR_CODE
590        );
591    }
592
593    #[test]
594    fn missing_or_empty_version_is_a_header_mismatch() {
595        for (header, body) in [
596            (None, Some(FINAL_PROTOCOL_VERSION)),
597            (Some(FINAL_PROTOCOL_VERSION), None),
598            (Some(""), Some(FINAL_PROTOCOL_VERSION)),
599            (Some(FINAL_PROTOCOL_VERSION), Some("")),
600        ] {
601            assert_eq!(
602                validate_final_protocol_version(header, body),
603                Err(ProtocolVersionError::HeaderMismatch)
604            );
605        }
606    }
607
608    #[test]
609    fn prt_03_b_positive() {
610        let admission = admit_final_request(RequestVersionMetadata {
611            header_version: Some(FINAL_PROTOCOL_VERSION),
612            body_version: Some(FINAL_PROTOCOL_VERSION),
613        })
614        .expect("matching supported header and body versions must admit the request");
615
616        assert_eq!(
617            admission.protocol_version().as_str(),
618            FINAL_PROTOCOL_VERSION
619        );
620        assert_eq!(SUPPORTED_FINAL_PROTOCOL_VERSIONS, [FINAL_PROTOCOL_VERSION]);
621    }
622
623    #[test]
624    fn prt_03_b_planted_negative() {
625        let body_version = Some(FINAL_PROTOCOL_VERSION);
626        let changed_header_version = Some("2025-11-25");
627
628        let error = admit_final_request(RequestVersionMetadata {
629            header_version: changed_header_version,
630            body_version,
631        })
632        .expect_err("changing only the header must retain header-mismatch precedence");
633
634        assert_eq!(
635            error,
636            RequestAdmissionError::HeaderMismatch(HeaderMismatchError {
637                reason: HeaderMismatchReason::HeaderBodyVersionMismatch,
638            })
639        );
640        assert_eq!(error.jsonrpc_error_code(), HEADER_MISMATCH_ERROR_CODE);
641        assert_eq!(error.http_status(), 400);
642        assert_eq!(body_version, Some(FINAL_PROTOCOL_VERSION));
643    }
644
645    #[test]
646    fn matching_unsupported_version_is_classified_after_the_mirror_check() {
647        let error = admit_final_request(RequestVersionMetadata {
648            header_version: Some("2025-11-25"),
649            body_version: Some("2025-11-25"),
650        })
651        .expect_err("matching unsupported version must reject after mirror validation");
652
653        let RequestAdmissionError::UnsupportedProtocolVersion(error) = error else {
654            panic!("matching values must not use the header-mismatch error");
655        };
656        assert_eq!(error.requested(), "2025-11-25");
657        assert_eq!(error.supported_versions(), [FINAL_PROTOCOL_VERSION]);
658        assert_eq!(
659            error.jsonrpc_error_code(),
660            UNSUPPORTED_PROTOCOL_VERSION_ERROR_CODE
661        );
662        assert_eq!(error.http_status(), 400);
663    }
664
665    #[test]
666    fn typed_errors_preserve_only_their_final_peer_data_shapes() {
667        let mismatch = HeaderMismatchError {
668            reason: HeaderMismatchReason::MissingHeader,
669        };
670        assert_eq!(mismatch.canonical_error_data(), None);
671
672        let unsupported = UnsupportedProtocolVersionError {
673            requested: "2025-11-25".to_owned(),
674        };
675        assert_eq!(
676            unsupported.canonical_error_data(),
677            json!({"supported": [FINAL_PROTOCOL_VERSION], "requested": "2025-11-25"})
678        );
679
680        let missing = MissingRequiredClientCapabilityError::new(json!({
681            "roots": {"listChanged": true},
682            "sampling": {"context": {}}
683        }))
684        .expect("bounded capability object is valid typed peer data");
685        assert_eq!(missing.http_status(), 400);
686        assert_eq!(
687            missing.jsonrpc_error_code(),
688            MISSING_REQUIRED_CLIENT_CAPABILITY_ERROR_CODE
689        );
690        assert_eq!(
691            missing.canonical_error_data(),
692            json!({
693                "requiredCapabilities": {
694                    "roots": {"listChanged": true},
695                    "sampling": {"context": {}}
696                }
697            })
698        );
699
700        let typed_missing =
701            MissingRequiredClientCapabilityError::from_client_capabilities(&ClientCapabilities {
702                roots: Some(crate::RootsCapability { list_changed: true }),
703                ..ClientCapabilities::default()
704            })
705            .expect("typed capabilities serialize as a bounded required-capabilities object");
706        assert_eq!(
707            typed_missing.canonical_error_data(),
708            json!({"requiredCapabilities": {"roots": {"listChanged": true}}})
709        );
710    }
711}