Skip to main content

mcp_trace_validator/checks/draft/transport/
validation.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! The `2026-07-28` rejection clauses a *server* owns: what it must refuse, with
5//! which JSON-RPC code, and under which HTTP status.
6//!
7//! These judge answers rather than requests. A malformed POST is the client's
8//! defect and is reported by [`super::headers`]; what is reported here is the
9//! server having seen that POST and answered it anyway — which the recorded
10//! exchange shows directly.
11
12use std::collections::BTreeSet;
13
14use serde_json::Value;
15
16use super::super::super::FindingSink;
17use super::super::http_status_for;
18use super::{
19    META_PROTOCOL_VERSION, Match, Post, compare, designations_by_tool, header_safe, mirrors, posts,
20    posts_by_message, sentinel_payload,
21};
22use crate::context::TraceContext;
23
24#[cfg(test)]
25mod tests;
26
27/// `HeaderMismatch`.
28const HEADER_MISMATCH: i64 = -32020;
29/// `UnsupportedProtocolVersionError`.
30const UNSUPPORTED_VERSION: i64 = -32022;
31/// JSON-RPC `Method not found`.
32const METHOD_NOT_FOUND: i64 = -32601;
33
34/// The JSON-RPC error code an exchange's answer carried, if it was an error.
35fn answer_code(response: &mcp_conformance_core::trace::TraceEvent) -> Option<i64> {
36    response
37        .message_payload()?
38        .get("error")?
39        .get("code")?
40        .as_i64()
41}
42
43/// How a finding names the answer a request drew.
44fn answer_label(code: Option<i64>) -> String {
45    code.map_or_else(|| "a result".to_owned(), |code| format!("error {code}"))
46}
47
48/// `TRAN-073`: a protocol-version header/body mismatch is rejected.
49pub(in crate::checks) fn version_mismatch_rejected(
50    context: &TraceContext<'_>,
51    sink: &mut FindingSink,
52) {
53    rejected_for(context, sink, version_mismatch_fault);
54}
55
56/// `TRAN-096`: a recognized `Mcp-Param-*` carrying invalid characters is rejected.
57pub(in crate::checks) fn invalid_param_header_rejected(
58    context: &TraceContext<'_>,
59    sink: &mut FindingSink,
60) {
61    let recognized = recognized_param_headers(context);
62    rejected_for(context, sink, |post| invalid_param_fault(post, &recognized));
63}
64
65/// `TRAN-098`/`TRAN-102`: whatever draws `HeaderMismatch` draws HTTP `400`.
66///
67/// The two clauses state one rule in two sections — the response shape a header
68/// rejection takes — so they share a check; which *antecedent* obliged the
69/// rejection is TRAN-073's and TRAN-096's to report.
70pub(in crate::checks) fn header_mismatch_status(
71    context: &TraceContext<'_>,
72    sink: &mut FindingSink,
73) {
74    for (event, _, _) in context.messages() {
75        if answer_code(event) != Some(HEADER_MISMATCH) {
76            continue;
77        }
78        // Only judged where the recording carries HTTP framing; on stdio there
79        // is no status to hold the error against.
80        let Some((status_seq, status)) = http_status_for(context, event.seq) else {
81            continue;
82        };
83        sink.examined();
84        if status != 400 {
85            sink.push(
86                Some(status_seq),
87                format!(
88                    "HeaderMismatch ({HEADER_MISMATCH}) was returned with HTTP {status}, not 400"
89                ),
90            );
91        }
92    }
93}
94
95/// Reports every exchange whose POST carried `fault` yet drew something other
96/// than a `HeaderMismatch` rejection — the shared body of TRAN-073 and TRAN-096.
97fn rejected_for(
98    context: &TraceContext<'_>,
99    sink: &mut FindingSink,
100    fault: impl Fn(&Post<'_>) -> Option<String>,
101) {
102    let by_message = posts_by_message(context);
103    for exchange in context.exchanges() {
104        let Some(post) = by_message.get(&exchange.request.seq) else {
105            continue;
106        };
107        let Some(reason) = fault(post) else {
108            continue;
109        };
110        // The subject is a POST that actually carried the fault; a session whose
111        // POSTs were all well-formed never puts the rejection rule to the test.
112        sink.examined();
113        let code = answer_code(exchange.response);
114        if code != Some(HEADER_MISMATCH) {
115            sink.push(
116                Some(exchange.response.seq),
117                format!(
118                    "the POST at seq {} {reason}; the server answered with {} instead of \
119                     rejecting it with {HEADER_MISMATCH} (HeaderMismatch)",
120                    post.seq,
121                    answer_label(code)
122                ),
123            );
124        }
125    }
126}
127
128/// The `Mcp-Param-*` headers the trace shows a tool declaring — the ones a
129/// server demonstrably *recognizes*, which is what TRAN-096 is scoped to.
130fn recognized_param_headers(context: &TraceContext<'_>) -> BTreeSet<String> {
131    designations_by_tool(context)
132        .values()
133        .flatten()
134        .map(|designation| designation.header.clone())
135        .collect()
136}
137
138/// TRAN-073's antecedent: the protocol-version header disagrees with `_meta`.
139fn version_mismatch_fault(post: &Post<'_>) -> Option<String> {
140    let sent = post.headers.get("mcp-protocol-version")?;
141    let body = post.body_protocol_version()?;
142    (sent != body).then(|| {
143        format!("carried `MCP-Protocol-Version: {sent}` against a body `_meta` version of {body:?}")
144    })
145}
146
147/// TRAN-096's antecedent: a recognized custom header carries characters no
148/// header value may hold unencoded.
149fn invalid_param_fault(post: &Post<'_>, recognized: &BTreeSet<String>) -> Option<String> {
150    post.headers
151        .iter()
152        .find(|(name, value)| {
153            recognized.contains(*name) && sentinel_payload(value).is_none() && !header_safe(value)
154        })
155        .map(|(name, value)| {
156            format!("carried `{name}: {value:?}`, whose characters are not valid unencoded")
157        })
158}
159
160/// `TRAN-097`/`TRAN-100`: a header/body mismatch is validated and rejected.
161///
162/// The comparison the server is required to perform, performed on the trace and
163/// then held against the answer it gave: a POST whose mirrored header disagrees
164/// with the body field it mirrors — after decoding the sentinel, as TRAN-091 and
165/// TRAN-103 require — must not have drawn a result.
166pub(in crate::checks) fn header_body_match_validated(
167    context: &TraceContext<'_>,
168    sink: &mut FindingSink,
169) {
170    let designated = designations_by_tool(context);
171    let by_message = posts_by_message(context);
172    for exchange in context.exchanges() {
173        let Some(post) = by_message.get(&exchange.request.seq) else {
174            continue;
175        };
176        if exchange.result.is_none() {
177            continue; // rejected; whether it was rejected *correctly* is TRAN-098's
178        }
179        for mirror in mirrors(post, &designated) {
180            let Some(sent) = post.headers.get(&mirror.header) else {
181                continue;
182            };
183            sink.examined();
184            if compare(sent, &mirror.value) == Match::Mismatch {
185                sink.push(
186                    Some(exchange.response.seq),
187                    format!(
188                        "the POST at seq {} carried `{}: {sent}` against `{}` = {:?}; the \
189                         server answered it with a result instead of rejecting the mismatch",
190                        post.seq, mirror.label, mirror.source, mirror.value
191                    ),
192                );
193            }
194        }
195    }
196}
197
198/// `TRAN-074`: an unsupported protocol version draws
199/// `UnsupportedProtocolVersionError`, listing the versions the server supports.
200pub(in crate::checks) fn unsupported_version_error(
201    context: &TraceContext<'_>,
202    sink: &mut FindingSink,
203) {
204    unsupported_version_shape(context, sink);
205    unsupported_version_answer(context, sink);
206}
207
208/// The shape side: `-32022` lists the versions the server does implement.
209fn unsupported_version_shape(context: &TraceContext<'_>, sink: &mut FindingSink) {
210    for (event, _, _) in context.messages() {
211        if answer_code(event) != Some(UNSUPPORTED_VERSION) {
212            continue;
213        }
214        sink.examined();
215        let lists_versions = event
216            .message_payload()
217            .and_then(|payload| payload.get("error"))
218            .and_then(|error| error.get("data"))
219            .and_then(|data| data.get("supported"))
220            .and_then(Value::as_array)
221            .is_some_and(|supported| {
222                !supported.is_empty() && supported.iter().all(Value::is_string)
223            });
224        if !lists_versions {
225            sink.push(
226                Some(event.seq),
227                format!(
228                    "error {UNSUPPORTED_VERSION} does not carry `data.supported` listing the \
229                     protocol versions the server does implement"
230                ),
231            );
232        }
233    }
234}
235
236/// `TRAN-074`, the HTTP half: an `UnsupportedProtocolVersionError` rides a 400.
237///
238/// Split from [`unsupported_version_error`] because that rule is stated twice —
239/// on the transport page *with* the status (TRAN-074) and on `basic/versioning`
240/// *without* it (VERS-001). One check covering both would attribute a wrong
241/// HTTP status to VERS-001, whose quote says nothing about statuses, since the
242/// engine reports a check's finding against every requirement naming it.
243pub(in crate::checks) fn unsupported_version_status(
244    context: &TraceContext<'_>,
245    sink: &mut FindingSink,
246) {
247    let handshakes = legacy_handshake_ids(context);
248    for (event, _, _) in context.messages() {
249        if answer_code(event) != Some(UNSUPPORTED_VERSION) {
250            continue;
251        }
252        // The clause's antecedent is a version requested *in the header*
253        // (`#protocol-version-header`), and the removed handshake carries
254        // none — a `2025-11-25` client has no such header to send. So a
255        // `-32022` answering an `initialize` is outside this rule, the same
256        // carve-out `basic/versioning` makes for the error *code* there
257        // ("the exact code is implementation-defined"); it would be
258        // incoherent to leave the code open and mandate one code's status.
259        // What a modern-only server owes that client is VERS-008's, and it
260        // is judged separately.
261        if event
262            .message_payload()
263            .and_then(|payload| payload.get("id"))
264            .is_some_and(|id| handshakes.contains(&id.to_string()))
265        {
266            continue;
267        }
268        let Some((status_seq, status)) = http_status_for(context, event.seq) else {
269            continue;
270        };
271        sink.examined();
272        if status != 400 {
273            sink.push(
274                Some(status_seq),
275                format!(
276                    "UnsupportedProtocolVersionError ({UNSUPPORTED_VERSION}) was returned \
277                     with HTTP {status}, not 400"
278                ),
279            );
280        }
281    }
282}
283
284/// The obligation side: with the server's own supported list on the wire, a
285/// request carrying a version outside it must draw `-32022`.
286///
287/// The list comes from a `server/discover` result the trace itself carried, so
288/// this judges the server against what it said about itself — never against an
289/// assumption about which versions it ought to implement. Applied to the whole
290/// trace, not just what follows discovery: which versions a server implements is
291/// a property of the server, not of when the client asked.
292///
293/// The requested version is read from the request's own `_meta`, not from the
294/// POST that carried it. Both say the same thing — `Post::body_protocol_version`
295/// reads that same field — but going through the POST made the obligation
296/// invisible on stdio, where there is no POST and the clause still binds:
297/// `basic/versioning` states it for every transport.
298fn unsupported_version_answer(context: &TraceContext<'_>, sink: &mut FindingSink) {
299    let Some(supported) = declared_versions(context) else {
300        return;
301    };
302    for exchange in context.exchanges() {
303        let Some(requested) = exchange
304            .params
305            .and_then(|params| params.get("_meta")?.get(META_PROTOCOL_VERSION)?.as_str())
306        else {
307            continue;
308        };
309        if supported.contains(requested) {
310            continue;
311        }
312        // The subject is a request naming a version the server's own list omits;
313        // a session that only ever asked for supported ones is untested here.
314        sink.examined();
315        let code = answer_code(exchange.response);
316        if code != Some(UNSUPPORTED_VERSION) {
317            sink.push(
318                Some(exchange.response.seq),
319                format!(
320                    "the request at seq {} declared protocol version {requested:?}, which the \
321                     server's own `supportedVersions` omits; it answered with {} instead of \
322                     {UNSUPPORTED_VERSION}",
323                    exchange.request.seq,
324                    answer_label(code)
325                ),
326            );
327        }
328    }
329}
330
331/// The ids of `initialize` requests the client sent — the removed handshake.
332fn legacy_handshake_ids(context: &TraceContext<'_>) -> BTreeSet<String> {
333    context
334        .messages()
335        .filter_map(|(event, _, _)| {
336            let payload = event.message_payload()?;
337            if payload.get("method")?.as_str()? != "initialize" {
338                return None;
339            }
340            Some(payload.get("id")?.to_string())
341        })
342        .collect()
343}
344
345/// The protocol versions a `server/discover` result in the trace declared.
346fn declared_versions(context: &TraceContext<'_>) -> Option<BTreeSet<String>> {
347    context
348        .exchanges_for("server/discover")
349        .find_map(|exchange| {
350            let versions: BTreeSet<String> = exchange
351                .result?
352                .get("supportedVersions")?
353                .as_array()?
354                .iter()
355                .filter_map(|version| version.as_str().map(str::to_owned))
356                .collect();
357            (!versions.is_empty()).then_some(versions)
358        })
359}
360
361/// `TRAN-075`: an unimplemented method draws `404 Not Found` with `-32601`.
362pub(in crate::checks) fn unknown_method_404(context: &TraceContext<'_>, sink: &mut FindingSink) {
363    // A POST is the only way to reach the endpoint at this revision, so a trace
364    // without HTTP framing carries no status to judge and reports nothing.
365    if posts(context).is_empty() {
366        return;
367    }
368    for (event, _, _) in context.messages() {
369        if answer_code(event) != Some(METHOD_NOT_FOUND) {
370            continue;
371        }
372        let Some((status_seq, status)) = http_status_for(context, event.seq) else {
373            continue;
374        };
375        sink.examined();
376        if status != 404 {
377            sink.push(
378                Some(status_seq),
379                format!(
380                    "`Method not found` ({METHOD_NOT_FOUND}) was returned with HTTP {status}, \
381                     not 404"
382                ),
383            );
384        }
385    }
386}