Skip to main content

mcp_trace_validator/checks/draft/
envelope.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! `2026-07-28` message-envelope checks: `resultType`, in-flight request IDs,
5//! and the error-code partition.
6//!
7//! Every check here reads message payloads only, so none depends on the
8//! stateless session model — which is why this is the area that could land
9//! first. Each is *falsifiable*: it reports what the wire shows, never what an
10//! implementation intended.
11
12use serde_json::Value;
13
14use super::super::FindingSink;
15use crate::context::TraceContext;
16
17#[cfg(test)]
18mod tests;
19
20/// JSON-RPC's implementation-defined server-error range, which `2026-07-28`
21/// partitions (`basic/index#error-codes`).
22const LEGACY_RANGE: core::ops::RangeInclusive<i64> = -32019..=-32000;
23/// The sub-range reserved to the MCP specification itself.
24const RESERVED_RANGE: core::ops::RangeInclusive<i64> = -32099..=-32020;
25/// The whole JSON-RPC reserved range, inside which application-defined codes
26/// do not belong.
27const JSONRPC_RESERVED: core::ops::RangeInclusive<i64> = -32768..=-32000;
28
29/// Codes this revision defines in the reserved sub-range. A code inside
30/// `RESERVED_RANGE` but absent here is one the specification does not define,
31/// which the clause forbids emitting.
32const DEFINED_RESERVED: &[i64] = &[-32020, -32021, -32022];
33
34/// Codes earlier revisions defined that this one withdraws. They stay reserved
35/// and are never reused, so emitting one at `2026-07-28` is a violation even
36/// though it was correct at `2025-11-25`.
37const WITHDRAWN: &[(i64, &str)] = &[
38    (
39        -32002,
40        "resource not found (2025-11-25 and earlier; replaced by -32602)",
41    ),
42    (-32042, "URL elicitation required (2025-11-25 only)"),
43];
44
45/// The standard JSON-RPC codes, which remain valid.
46const STANDARD: &[i64] = &[-32700, -32600, -32601, -32602, -32603];
47
48/// Every `(seq, code)` an error response in the trace carried, as an integer.
49///
50/// Non-integer codes are `base.error-code-integer`'s business (BASE-054); they
51/// are skipped here rather than double-reported.
52fn error_codes<'a>(context: &'a TraceContext<'_>) -> impl Iterator<Item = (u64, i64)> + 'a {
53    context.messages().filter_map(|(event, _, _)| {
54        let code = event
55            .message_payload()?
56            .get("error")?
57            .get("code")?
58            .as_i64()?;
59        Some((event.seq, code))
60    })
61}
62
63/// `BASE-048`: every result carries `resultType` (SEP-2322).
64///
65/// Absence is judged only on results, never on errors or notifications. The
66/// backward-compatibility rule — that a client reads an absent field as
67/// `"complete"` — binds the *client's interpretation* and is excluded under
68/// BASE-051; it does not licence a `2026-07-28` server to omit the field.
69pub(in crate::checks) fn result_type_present(context: &TraceContext<'_>, sink: &mut FindingSink) {
70    for (event, _, _) in context.messages() {
71        let Some(payload) = event.message_payload() else {
72            continue;
73        };
74        let Some(result) = payload.get("result") else {
75            continue;
76        };
77        sink.examined();
78        if result.get("resultType").is_none() {
79            sink.push(
80                Some(event.seq),
81                "result has no `resultType`; 2026-07-28 requires it on every result".to_owned(),
82            );
83        } else if !result.get("resultType").is_some_and(Value::is_string) {
84            sink.push(
85                Some(event.seq),
86                "`resultType` is present but not a string".to_owned(),
87            );
88        }
89    }
90}
91
92/// `BASE-045`: a request ID must not collide with one still outstanding.
93///
94/// Deliberately *not* `base.request-id-unique`, which implements the stricter
95/// `2025-11-25` rule (never reuse an ID within a session). At `2026-07-28`
96/// reuse after a response is legal, so this tracks in-flight IDs and clears
97/// each when its response arrives. Pointing BASE-045 at the older check would
98/// report conforming traces as violations.
99pub(in crate::checks) fn request_id_unique_in_flight(
100    context: &TraceContext<'_>,
101    sink: &mut FindingSink,
102) {
103    // Keyed by (direction-of-sender, canonical id) so a client and a server may
104    // each have their own request 1 outstanding, exactly as JSON-RPC allows.
105    let mut outstanding: std::collections::HashSet<(bool, String)> =
106        std::collections::HashSet::new();
107    for (event, _, _) in context.messages() {
108        let Some(payload) = event.message_payload() else {
109            continue;
110        };
111        let Some(id) = payload.get("id") else {
112            continue;
113        };
114        if id.is_null() {
115            continue;
116        }
117        let key = (
118            matches!(
119                event.direction,
120                mcp_conformance_core::trace::Direction::ClientToServer
121            ),
122            id.to_string(),
123        );
124        if payload.get("method").is_some() {
125            // The subject is a request bearing an id; a response only clears
126            // one, and a session with no identified requests tests nothing.
127            sink.examined();
128            if !outstanding.insert(key) {
129                sink.push(
130                    Some(event.seq),
131                    format!(
132                        "request id {id} is already outstanding for this sender; \
133                         2026-07-28 forbids reusing an id before its response"
134                    ),
135                );
136            }
137        } else {
138            // A response clears the peer's outstanding id.
139            outstanding.remove(&(!key.0, key.1));
140        }
141    }
142}
143
144/// `BASE-055`: the `-32000`..`-32019` legacy sub-range is closed to new use.
145pub(in crate::checks) fn error_code_legacy_subrange(
146    context: &TraceContext<'_>,
147    sink: &mut FindingSink,
148) {
149    for (seq, code) in error_codes(context) {
150        sink.examined();
151        if LEGACY_RANGE.contains(&code) {
152            sink.push(
153                Some(seq),
154                format!(
155                    "error code {code} is in the legacy sub-range (-32000..-32019), \
156                     which 2026-07-28 implementations are not to use"
157                ),
158            );
159        }
160    }
161}
162
163/// `BASE-057`: the `-32020`..`-32099` sub-range is the specification's own, and
164/// only codes it defines may be emitted there.
165pub(in crate::checks) fn error_code_reserved_subrange(
166    context: &TraceContext<'_>,
167    sink: &mut FindingSink,
168) {
169    for (seq, code) in error_codes(context) {
170        sink.examined();
171        if RESERVED_RANGE.contains(&code) && !DEFINED_RESERVED.contains(&code) {
172            sink.push(
173                Some(seq),
174                format!(
175                    "error code {code} is in the MCP-reserved sub-range \
176                     (-32020..-32099) but is not defined by this specification"
177                ),
178            );
179        }
180    }
181}
182
183/// `BASE-058`: codes withdrawn by this revision stay reserved and unusable.
184pub(in crate::checks) fn error_code_withdrawn(context: &TraceContext<'_>, sink: &mut FindingSink) {
185    for (seq, code) in error_codes(context) {
186        sink.examined();
187        if let Some((_, meaning)) = WITHDRAWN.iter().find(|(withdrawn, _)| *withdrawn == code) {
188            sink.push(
189                Some(seq),
190                format!("error code {code} — {meaning} — must not be emitted at 2026-07-28"),
191            );
192        }
193    }
194}
195
196/// `BASE-060`: application-defined codes belong outside the JSON-RPC reserved
197/// range.
198///
199/// Reports only codes the specification leaves undefined: a standard JSON-RPC
200/// code, a defined MCP code, and the two ranges the sibling checks own are all
201/// accounted for elsewhere, so this fires on the remainder — an
202/// application-defined code that has been placed inside `-32768`..`-32000`.
203pub(in crate::checks) fn error_code_application_range(
204    context: &TraceContext<'_>,
205    sink: &mut FindingSink,
206) {
207    for (seq, code) in error_codes(context) {
208        sink.examined();
209        let accounted_for = STANDARD.contains(&code)
210            || DEFINED_RESERVED.contains(&code)
211            || LEGACY_RANGE.contains(&code)
212            || RESERVED_RANGE.contains(&code);
213        if JSONRPC_RESERVED.contains(&code) && !accounted_for {
214            sink.push(
215                Some(seq),
216                format!(
217                    "error code {code} is application-defined but sits inside the \
218                     JSON-RPC reserved range (-32768..-32000)"
219                ),
220            );
221        }
222    }
223}