Skip to main content

mcp_trace_validator/checks/draft/
transport.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! `2026-07-28` Streamable HTTP checks.
5//!
6//! None of these may lean on an `initialize` exchange: the revision removes the
7//! handshake, so a check gated on it is inert here and — worse than being
8//! absent — reports a vacuous *pass*. That is why
9//! `transport.protocol-version-header-present` exists rather than reusing the
10//! `2025-11-25` header check, which returns early unless the trace negotiated.
11//!
12//! Split by subject. This file holds what the area shares: the POST pairing,
13//! header safety, and the *mirror* vocabulary that names each body value a POST
14//! must carry in a header. [`headers`] holds the request-header clauses the
15//! client owns, [`stream`] the response-stream clauses, and [`validation`] the
16//! rejection clauses the server owns.
17
18use std::collections::BTreeMap;
19
20use serde_json::Value;
21
22use super::super::support::decode_base64;
23use crate::context::TraceContext;
24use mcp_conformance_core::trace::{Direction, EventBody, TransportKind};
25
26mod headers;
27mod stdio;
28mod stream;
29mod validation;
30
31#[cfg(test)]
32mod tests;
33
34pub(in crate::checks) use headers::{
35    header_value_encoding, protocol_version_header_matches_body, protocol_version_header_present,
36    request_metadata_headers, sentinel_marker_case, sentinel_pattern_encoded,
37    x_mcp_header_mirrored, x_mcp_header_name_valid,
38};
39pub(in crate::checks) use stdio::{
40    cancel_notification_references_request, no_messages_after_cancel_notification,
41};
42pub(in crate::checks) use stream::{
43    accel_buffering_header, client_no_responses, no_independent_server_requests,
44    no_messages_after_cancellation,
45};
46pub(in crate::checks) use validation::{
47    header_body_match_validated, header_mismatch_status, invalid_param_header_rejected,
48    unknown_method_404, unsupported_version_error, unsupported_version_status,
49    version_mismatch_rejected,
50};
51
52/// `Mcp-Name`'s source field, by method — the Standard Request Headers table.
53pub(super) const NAME_SOURCED: &[(&str, &str)] = &[
54    ("tools/call", "name"),
55    ("prompts/get", "name"),
56    ("resources/read", "uri"),
57];
58
59/// The `_meta` key carrying a request's protocol version.
60pub(super) const META_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion";
61
62/// The Base64 sentinel's opening marker.
63const SENTINEL_OPEN: &str = "=?base64?";
64/// The sentinel's closing marker.
65const SENTINEL_CLOSE: &str = "?=";
66
67/// A client POST: the HTTP headers it carried, and the message they framed.
68#[derive(Debug, Clone, Copy)]
69pub(super) struct Post<'a> {
70    /// The `seq` of the HTTP event carrying the headers.
71    pub seq: u64,
72    /// The `seq` of the message event the POST framed.
73    pub message_seq: u64,
74    /// The POST's headers. The trace reader lowercases the keys, so lookups
75    /// here are by lowercase name and on-the-wire casing cannot hide anything.
76    pub headers: &'a BTreeMap<String, String>,
77    /// The JSON-RPC message the POST carried.
78    pub payload: &'a Value,
79}
80
81impl Post<'_> {
82    /// The message's `method`, when it has one.
83    pub(super) fn method(&self) -> Option<&str> {
84        self.payload.get("method").and_then(Value::as_str)
85    }
86
87    /// Whether the POST carried a JSON-RPC *request* rather than a notification.
88    ///
89    /// The header clauses are scoped to requests deliberately: the revision
90    /// states that "header requirements for notification POSTs are not defined
91    /// by this revision" (`#sending-messages`), so judging one would invent a
92    /// rule the specification declines to make.
93    pub(super) fn is_request(&self) -> bool {
94        self.method().is_some() && self.payload.get("id").is_some_and(|id| !id.is_null())
95    }
96
97    /// The protocol version this request's `_meta` envelope states.
98    pub(super) fn body_protocol_version(&self) -> Option<&str> {
99        self.payload
100            .get("params")?
101            .get("_meta")?
102            .get(META_PROTOCOL_VERSION)?
103            .as_str()
104    }
105}
106
107/// Every client POST in the trace, in capture order.
108///
109/// The tap records an `http` event and then the message it carried, so the
110/// pairing is "the next client message after this client `http` event". A trace
111/// without HTTP framing (stdio) yields nothing, which is correct: these clauses
112/// bind the Streamable HTTP transport only.
113pub(super) fn posts<'a>(context: &'a TraceContext<'_>) -> Vec<Post<'a>> {
114    let mut out = Vec::new();
115    let events = context.events();
116    for (index, event) in events.iter().enumerate() {
117        if event.direction != Direction::ClientToServer
118            || event.transport != TransportKind::StreamableHttp
119        {
120            continue;
121        }
122        let EventBody::Http { headers, .. } = &event.body else {
123            continue;
124        };
125        let framed = events[index + 1..]
126            .iter()
127            .find(|later| later.direction == Direction::ClientToServer);
128        if let Some(framed) = framed
129            && let Some(payload) = framed.message_payload()
130        {
131            out.push(Post {
132                seq: event.seq,
133                message_seq: framed.seq,
134                headers,
135                payload,
136            });
137        }
138    }
139    out
140}
141
142/// The POSTs of the trace keyed by the `seq` of the message each framed — the
143/// index the server-side checks need to walk back from an answer to its request.
144pub(super) fn posts_by_message<'a>(context: &'a TraceContext<'_>) -> BTreeMap<u64, Post<'a>> {
145    posts(context)
146        .into_iter()
147        .map(|post| (post.message_seq, post))
148        .collect()
149}
150
151/// Whether `value` is safely representable as a plain ASCII header value.
152///
153/// RFC 9110 admits visible ASCII (`0x21`–`0x7E`), space and horizontal tab; the
154/// revision adds that a value with leading or trailing whitespace cannot be
155/// carried plainly either (`#value-encoding`).
156pub(super) fn header_safe(value: &str) -> bool {
157    value.trim() == value
158        && value
159            .bytes()
160            .all(|byte| byte == 0x09 || (0x20..0x7f).contains(&byte))
161}
162
163/// The Base64 payload `value` carries, when it uses the sentinel exactly.
164pub(super) fn sentinel_payload(value: &str) -> Option<&str> {
165    value
166        .strip_prefix(SENTINEL_OPEN)
167        .and_then(|rest| rest.strip_suffix(SENTINEL_CLOSE))
168}
169
170/// Whether `value` uses the sentinel markers in any casing but the required one.
171///
172/// The markers "are case-sensitive and MUST appear exactly as shown (lowercase)"
173/// (TRAN-089), so a value that only case-insensitively matches is a distinct,
174/// nameable defect rather than an arbitrary plain value.
175pub(super) fn is_miscased_sentinel(value: &str) -> bool {
176    let folded = value.to_ascii_lowercase();
177    sentinel_payload(value).is_none() && sentinel_payload(&folded).is_some()
178}
179
180/// How a mirrored header value relates to the body value it mirrors.
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub(super) enum Match {
183    /// The header carries the body value, plainly or Base64-encoded.
184    Carried,
185    /// The header repeats the body value verbatim even though it matches the
186    /// sentinel pattern, which TRAN-092 requires the client to encode.
187    UnencodedSentinel,
188    /// The header and the body disagree.
189    Mismatch,
190}
191
192/// Compares a header value against the body value it mirrors, decoding the
193/// sentinel first — the comparison the specification requires of servers
194/// (TRAN-091, TRAN-103) and therefore the one a trace must be judged by.
195pub(super) fn compare(header: &str, body: &str) -> Match {
196    if header == body {
197        return if sentinel_payload(body).is_some() {
198            Match::UnencodedSentinel
199        } else {
200            Match::Carried
201        };
202    }
203    if let Some(encoded) = sentinel_payload(header)
204        && decode_base64(encoded).as_deref() == Some(body)
205    {
206        return Match::Carried;
207    }
208    Match::Mismatch
209}
210
211/// One `x-mcp-header` annotation found in a tool's `inputSchema`.
212#[derive(Debug, Clone)]
213pub(super) struct Designation {
214    /// The chain of `properties` keys leading to the annotated property.
215    pub path: Vec<String>,
216    /// The `x-mcp-header` value, verbatim.
217    pub name: String,
218    /// The header it constructs, lowercased for lookup against a trace's headers.
219    pub header: String,
220    /// The annotated property's declared `type`, when it declares one.
221    pub declared_type: Option<String>,
222}
223
224/// Every tool definition the trace carried, as `(seq of the result, tool)`.
225pub(super) fn tool_definitions<'a>(
226    context: &'a TraceContext<'_>,
227) -> impl Iterator<Item = (u64, &'a Value)> + 'a {
228    context.messages().flat_map(|(event, _, _)| {
229        event
230            .message_payload()
231            .and_then(|payload| payload.get("result"))
232            .and_then(|result| result.get("tools"))
233            .and_then(Value::as_array)
234            .map(|tools| tools.iter().map(move |tool| (event.seq, tool)))
235            .into_iter()
236            .flatten()
237    })
238}
239
240/// The `x-mcp-header` annotations `schema` declares, in schema order.
241///
242/// Walks chains of `properties` keys only — the specification's *statically
243/// reachable* definition (`#schema-extension`). An annotation anywhere else is
244/// TRAN-082's business, which the registry excludes. Recursion is bounded by the
245/// JSON parser's own nesting limit, applied by the reader before any check runs.
246pub(super) fn designations(schema: &Value) -> Vec<Designation> {
247    let mut out = Vec::new();
248    collect_designations(schema, &mut Vec::new(), &mut out);
249    out
250}
251
252/// The recursive half of [`designations`].
253fn collect_designations(schema: &Value, path: &mut Vec<String>, out: &mut Vec<Designation>) {
254    let Some(properties) = schema.get("properties").and_then(Value::as_object) else {
255        return;
256    };
257    for (property, subschema) in properties {
258        path.push(property.clone());
259        if let Some(name) = subschema.get("x-mcp-header").and_then(Value::as_str) {
260            out.push(Designation {
261                path: path.clone(),
262                name: name.to_owned(),
263                header: format!("mcp-param-{}", name.to_ascii_lowercase()),
264                declared_type: subschema
265                    .get("type")
266                    .and_then(Value::as_str)
267                    .map(str::to_owned),
268            });
269        }
270        collect_designations(subschema, path, out);
271        path.pop();
272    }
273}
274
275/// The designations declared per tool name, across every tool list in the trace.
276pub(in crate::checks::draft) fn designations_by_tool(
277    context: &TraceContext<'_>,
278) -> BTreeMap<String, Vec<Designation>> {
279    let mut out = BTreeMap::new();
280    for (_, tool) in tool_definitions(context) {
281        let Some(name) = tool.get("name").and_then(Value::as_str) else {
282            continue;
283        };
284        let Some(schema) = tool.get("inputSchema") else {
285            continue;
286        };
287        let declared = designations(schema);
288        if !declared.is_empty() {
289            out.insert(name.to_owned(), declared);
290        }
291    }
292    out
293}
294
295/// The instance value at a designation's exact property path, when present.
296fn value_at<'a>(root: &'a Value, path: &[String]) -> Option<&'a Value> {
297    let mut cursor = root;
298    for step in path {
299        cursor = cursor.get(step)?;
300    }
301    Some(cursor)
302}
303
304/// A parameter value's header form, per the Value Encoding type conversions:
305/// strings as-is, integers as a decimal string, booleans lowercase.
306///
307/// Any other JSON type has no defined header form — the specification permits
308/// annotating primitives only — so it yields `None` and comparison abstains.
309fn header_text(value: &Value) -> Option<String> {
310    match value {
311        Value::String(text) => Some(text.clone()),
312        Value::Bool(flag) => Some(flag.to_string()),
313        Value::Number(number) if number.is_i64() || number.is_u64() => Some(number.to_string()),
314        _ => None,
315    }
316}
317
318/// One body value a POST is required to mirror into an HTTP header.
319#[derive(Debug, Clone)]
320pub(super) struct Mirror {
321    /// The header's lowercase name, for lookup against a trace's headers.
322    pub header: String,
323    /// The header as the specification spells it, for findings.
324    pub label: String,
325    /// The body path the value is sourced from, for findings.
326    pub source: String,
327    /// The body value's header form.
328    pub value: String,
329    /// Whether the Base64 sentinel may carry this header's value.
330    pub encodable: bool,
331}
332
333/// The mirrors `post` must satisfy, given the designations the trace declared.
334///
335/// Each is *sourced*: a mirror exists only where the body actually carries the
336/// value the header would come from, so a request missing `params.name` draws
337/// its own defect rather than a spurious "missing header" here.
338pub(super) fn mirrors(
339    post: &Post<'_>,
340    designated: &BTreeMap<String, Vec<Designation>>,
341) -> Vec<Mirror> {
342    let mut out = Vec::new();
343    let Some(method) = post.method() else {
344        return out;
345    };
346    out.push(Mirror {
347        header: "mcp-method".to_owned(),
348        label: "Mcp-Method".to_owned(),
349        source: "method".to_owned(),
350        value: method.to_owned(),
351        encodable: false,
352    });
353    let params = post.payload.get("params");
354    if let Some((_, field)) = NAME_SOURCED.iter().find(|(name, _)| *name == method)
355        && let Some(value) = params
356            .and_then(|params| params.get(*field))
357            .and_then(Value::as_str)
358    {
359        out.push(Mirror {
360            header: "mcp-name".to_owned(),
361            label: "Mcp-Name".to_owned(),
362            source: format!("params.{field}"),
363            value: value.to_owned(),
364            encodable: true,
365        });
366    }
367    let tool = params
368        .and_then(|params| params.get("name"))
369        .and_then(Value::as_str);
370    let declared = (method == "tools/call")
371        .then(|| tool.and_then(|tool| designated.get(tool)))
372        .flatten();
373    let arguments = params.and_then(|params| params.get("arguments"));
374    for designation in declared.into_iter().flatten() {
375        let Some(value) = arguments
376            .and_then(|arguments| value_at(arguments, &designation.path))
377            .and_then(header_text)
378        else {
379            continue;
380        };
381        out.push(Mirror {
382            header: designation.header.clone(),
383            label: format!("Mcp-Param-{}", designation.name),
384            source: format!("params.arguments.{}", designation.path.join(".")),
385            value,
386            encodable: true,
387        });
388    }
389    out
390}