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}