Skip to main content

mcp_trace_validator/checks/
transport.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! Checks for the `2025-11-25` transport requirements (`TRAN-*`).
5//!
6//! stdio checks judge message validity per direction; Streamable HTTP checks judge
7//! the security-relevant headers recorded on [`EventBody::Http`] events — session IDs
8//! and the `MCP-Protocol-Version` header. HTTP request events are client-to-server
9//! `Http` observations; response events are server-to-client ones.
10//!
11//! [`EventBody::Http`]: mcp_conformance_core::trace::EventBody
12
13use std::collections::BTreeMap;
14
15use mcp_conformance_core::message::MessageKind;
16use mcp_conformance_core::trace::{Direction, EventBody, TransportKind};
17
18use super::FindingSink;
19use crate::context::TraceContext;
20
21mod accept;
22mod post;
23
24pub(super) use accept::{client_get_accept_header, client_post_accept_header};
25pub(super) use post::client_messages_use_post;
26
27/// `TRAN-004`: nothing on the server's stdout that is not a valid MCP message.
28pub(super) fn stdio_server_output_valid(context: &TraceContext<'_>, sink: &mut FindingSink) {
29    stdio_messages_valid(context, sink, Direction::ServerToClient, "stdout");
30}
31
32/// `TRAN-005`: nothing on the server's stdin that is not a valid MCP message.
33pub(super) fn stdio_client_input_valid(context: &TraceContext<'_>, sink: &mut FindingSink) {
34    stdio_messages_valid(context, sink, Direction::ClientToServer, "stdin");
35}
36
37fn stdio_messages_valid(
38    context: &TraceContext<'_>,
39    sink: &mut FindingSink,
40    direction: Direction,
41    stream: &str,
42) {
43    for (event, kind, _) in context.messages() {
44        if event.transport != TransportKind::Stdio || event.direction != direction {
45            continue;
46        }
47        sink.examined();
48        if let MessageKind::Invalid { reason } = kind {
49            sink.push(
50                Some(event.seq),
51                format!("message on {stream} is not a valid MCP message: {reason}"),
52            );
53        }
54    }
55}
56
57/// `TRAN-026`: every JSON-RPC message a client POSTs is a single request,
58/// notification, or response — a JSON object. A batch array (removed from the
59/// protocol in `2025-06-18`) or scalar body is representable in a trace as a
60/// non-object payload and is a violation, not a capture artifact.
61pub(super) fn http_post_single_message(context: &TraceContext<'_>, sink: &mut FindingSink) {
62    for (event, kind, _) in context.messages() {
63        if event.transport != TransportKind::StreamableHttp
64            || event.direction != Direction::ClientToServer
65        {
66            continue;
67        }
68        sink.examined();
69        if let MessageKind::Invalid { reason } = kind {
70            sink.push(
71                Some(event.seq),
72                format!(
73                    "client HTTP POST body is not a single JSON-RPC request, notification, or response: {reason}"
74                ),
75            );
76        }
77    }
78}
79
80/// The headers of every HTTP event in the given direction, in trace order.
81fn http_headers<'a>(
82    context: &TraceContext<'a>,
83    direction: Direction,
84) -> impl Iterator<Item = (u64, &'a BTreeMap<String, String>)> {
85    context
86        .events()
87        .iter()
88        .filter(move |event| event.direction == direction)
89        .filter_map(|event| match &event.body {
90            EventBody::Http { headers, .. } => Some((event.seq, headers)),
91            _ => None,
92        })
93}
94
95/// `TRAN-011`: session IDs must contain only visible ASCII (0x21–0x7E).
96pub(super) fn session_id_visible_ascii(context: &TraceContext<'_>, sink: &mut FindingSink) {
97    for (seq, headers) in http_headers(context, Direction::ServerToClient) {
98        let Some(session_id) = headers.get("mcp-session-id") else {
99            continue;
100        };
101        sink.examined();
102        if !session_id.bytes().all(|byte| (0x21..=0x7E).contains(&byte)) {
103            sink.push(
104                    Some(seq),
105                    format!(
106                    "session ID {session_id:?} contains characters outside visible ASCII (0x21-0x7E)"
107                ),
108            );
109        }
110    }
111}
112
113/// The first server-assigned session ID in the trace, with the seq it appeared at.
114fn assigned_session_id<'a>(context: &TraceContext<'a>) -> Option<(u64, &'a str)> {
115    http_headers(context, Direction::ServerToClient).find_map(|(seq, headers)| {
116        headers
117            .get("mcp-session-id")
118            .map(|session_id| (seq, session_id.as_str()))
119    })
120}
121
122/// `TRAN-013`: once the server returns an `MCP-Session-Id`, every subsequent client
123/// HTTP request must carry it.
124pub(super) fn session_id_echoed(context: &TraceContext<'_>, sink: &mut FindingSink) {
125    let Some((assigned_seq, session_id)) = assigned_session_id(context) else {
126        return;
127    };
128    for (seq, headers) in http_headers(context, Direction::ClientToServer) {
129        if seq < assigned_seq {
130            continue;
131        }
132        sink.examined();
133        match headers.get("mcp-session-id") {
134            None => sink.push(
135                Some(seq),
136                format!(
137                    "client HTTP request lacks the MCP-Session-Id header; the server assigned {session_id:?} at seq {assigned_seq}"
138                ),
139            ),
140            Some(echoed) if echoed != session_id => sink.push(
141                Some(seq),
142                format!(
143                    "client echoed session ID {echoed:?}, but the server assigned {session_id:?}"
144                ),
145            ),
146            Some(_) => {}
147        }
148    }
149}
150
151/// `TRAN-017`: after initialization, every client HTTP request must carry
152/// `MCP-Protocol-Version`.
153pub(super) fn protocol_version_header(context: &TraceContext<'_>, sink: &mut FindingSink) {
154    let Some((negotiated_seq, _)) = negotiated_version(context) else {
155        return;
156    };
157    for (seq, headers) in http_headers(context, Direction::ClientToServer) {
158        if seq <= negotiated_seq {
159            continue; // Initialization traffic itself precedes "subsequent requests".
160        }
161        sink.examined();
162        if !headers.contains_key("mcp-protocol-version") {
163            sink.push(
164                Some(seq),
165                "client HTTP request after initialization lacks the MCP-Protocol-Version header"
166                    .to_owned(),
167            );
168        }
169    }
170}
171
172/// `TRAN-018`: the protocol version the client sends should be the negotiated one.
173pub(super) fn protocol_version_negotiated(context: &TraceContext<'_>, sink: &mut FindingSink) {
174    let Some((negotiated_seq, negotiated)) = negotiated_version(context) else {
175        return;
176    };
177    for (seq, headers) in http_headers(context, Direction::ClientToServer) {
178        if seq <= negotiated_seq {
179            continue;
180        }
181        // The subject is a request that *sent* the header; one that omitted it
182        // is the neighbouring clause's finding, not this one's.
183        let Some(sent) = headers.get("mcp-protocol-version") else {
184            continue;
185        };
186        sink.examined();
187        if sent != negotiated {
188            sink.push(
189                    Some(seq),
190                    format!(
191                    "client sent MCP-Protocol-Version {sent:?}, but {negotiated:?} was negotiated at seq {negotiated_seq}"
192                ),
193            );
194        }
195    }
196}
197
198/// The protocol version the server's `initialize` result stated, when present and
199/// well-typed, with the seq it was negotiated at.
200fn negotiated_version<'a>(context: &TraceContext<'a>) -> Option<(u64, &'a str)> {
201    let (seq, result) = context.initialize().result?;
202    let version = result.get("protocolVersion")?.as_str()?;
203    Some((seq, version))
204}
205
206/// `TRAN-029`/`TRAN-040`: an HTTP 200 from the MCP endpoint must declare
207/// `Content-Type: application/json` or `Content-Type: text/event-stream`.
208///
209/// Both clauses demand one of the same two content types for their success
210/// case (POST answering a request; GET opening a stream), so the check is
211/// sound without knowing the request method. Non-200 paths (202 accepted,
212/// error statuses, 405) carry no such obligation and are not examined.
213pub(super) fn success_content_type(context: &TraceContext<'_>, sink: &mut FindingSink) {
214    for event in context.events() {
215        if event.direction != Direction::ServerToClient {
216            continue;
217        }
218        let EventBody::Http {
219            status: Some(200),
220            headers,
221            ..
222        } = &event.body
223        else {
224            continue;
225        };
226        sink.examined();
227        match headers.get("content-type") {
228            None => sink.push(
229                Some(event.seq),
230                "HTTP 200 response carries no Content-Type header; the MCP endpoint \
231                 must answer with application/json or text/event-stream"
232                    .to_owned(),
233            ),
234            Some(content_type) => {
235                let lowered = content_type.to_ascii_lowercase();
236                if !lowered.contains("application/json") && !lowered.contains("text/event-stream") {
237                    sink.push(
238                        Some(event.seq),
239                        format!(
240                            "HTTP 200 Content-Type {content_type:?} is neither application/json \
241                             nor text/event-stream"
242                        ),
243                    );
244                }
245            }
246        }
247    }
248}
249
250#[cfg(test)]
251#[allow(clippy::unwrap_used)]
252mod tests {
253    use crate::checks;
254    use crate::context::TraceContext;
255    use crate::reader::{Limits, parse_trace};
256
257    use mcp_conformance_core::trace::TraceEvent;
258
259    fn findings_for(check: &str, trace: &str) -> Vec<String> {
260        let events: Vec<TraceEvent> = parse_trace(trace, &Limits::default()).unwrap();
261        let context = TraceContext::new(&events);
262        checks::find(check)
263            .unwrap()
264            .run(&context)
265            .findings
266            .into_iter()
267            .map(|finding| finding.detail)
268            .collect()
269    }
270
271    /// An HTTP session: initialize exchange with headers, then one more request.
272    fn http_session(response_headers: &str, followup_request_headers: &str) -> String {
273        [
274            r#"{"seq":0,"direction":"client-to-server","transport":"streamable-http","kind":"http","headers":{"accept":"application/json, text/event-stream"}}"#.to_owned(),
275            r#"{"seq":1,"direction":"client-to-server","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}}"#.to_owned(),
276            format!(r#"{{"seq":2,"direction":"server-to-client","transport":"streamable-http","kind":"http","status":200,"headers":{response_headers}}}"#),
277            r#"{"seq":3,"direction":"server-to-client","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"s","version":"0"}}}}"#.to_owned(),
278            format!(r#"{{"seq":4,"direction":"client-to-server","transport":"streamable-http","kind":"http","headers":{followup_request_headers}}}"#),
279            r#"{"seq":5,"direction":"client-to-server","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#.to_owned(),
280        ]
281        .join("\n")
282    }
283
284    #[test]
285    fn session_id_checks_judge_assignment_and_echo() {
286        // Correct echo: no findings from either check.
287        let good = http_session(
288            r#"{"mcp-session-id":"abc123"}"#,
289            r#"{"mcp-session-id":"abc123","mcp-protocol-version":"2025-11-25"}"#,
290        );
291        assert!(findings_for("transport.session-id-visible-ascii", &good).is_empty());
292        assert!(findings_for("transport.session-id-echoed", &good).is_empty());
293
294        // Wrong echo value.
295        let wrong = http_session(
296            r#"{"mcp-session-id":"abc123"}"#,
297            r#"{"mcp-session-id":"zzz","mcp-protocol-version":"2025-11-25"}"#,
298        );
299        let findings = findings_for("transport.session-id-echoed", &wrong);
300        assert_eq!(findings.len(), 1, "{findings:?}");
301        assert!(findings[0].contains("\"zzz\""), "{findings:?}");
302
303        // No session assigned: the echo check abstains entirely.
304        let none = http_session("{}", "{}");
305        assert!(findings_for("transport.session-id-echoed", &none).is_empty());
306    }
307
308    #[test]
309    fn capitalized_header_names_are_still_judged() {
310        // On-the-wire casing must not let a bad header slip past: a session ID
311        // with an illegal space, recorded under the capitalized `Mcp-Session-Id`,
312        // is still caught because trace deserialization lowercases header keys.
313        let bad = http_session(r#"{"Mcp-Session-Id":"has space"}"#, "{}");
314        assert_eq!(
315            findings_for("transport.session-id-visible-ascii", &bad).len(),
316            1,
317            "capitalized header name evaded the visible-ASCII check"
318        );
319        // And a wrong protocol version under `Mcp-Protocol-Version` is flagged
320        // by TRAN-018 rather than silently passing.
321        let wrong_version = http_session(
322            r#"{"mcp-session-id":"abc"}"#,
323            r#"{"mcp-session-id":"abc","Mcp-Protocol-Version":"2024-11-05"}"#,
324        );
325        let findings = findings_for("transport.protocol-version-negotiated", &wrong_version);
326        assert_eq!(findings.len(), 1, "{findings:?}");
327        assert!(findings[0].contains("2024-11-05"), "{findings:?}");
328    }
329
330    #[test]
331    fn session_id_ascii_boundaries_are_exact() {
332        // 0x20 (space) and non-ASCII are out; 0x21 and 0x7E are in.
333        let bad = http_session(r#"{"mcp-session-id":"has space"}"#, "{}");
334        assert_eq!(
335            findings_for("transport.session-id-visible-ascii", &bad).len(),
336            1
337        );
338        let edges = http_session(r#"{"mcp-session-id":"!~"}"#, r#"{"mcp-session-id":"!~"}"#);
339        assert!(findings_for("transport.session-id-visible-ascii", &edges).is_empty());
340    }
341
342    #[test]
343    fn protocol_version_checks_scope_to_requests_after_negotiation() {
344        // The pre-initialize request (seq 0) must not be flagged; the follow-up
345        // without the header must be.
346        let missing = http_session("{}", r#"{"mcp-session-id":"x"}"#);
347        let findings = findings_for("transport.protocol-version-header", &missing);
348        assert_eq!(findings.len(), 1, "{findings:?}");
349
350        let mismatched = http_session("{}", r#"{"mcp-protocol-version":"2024-11-05"}"#);
351        let findings = findings_for("transport.protocol-version-negotiated", &mismatched);
352        assert_eq!(findings.len(), 1, "{findings:?}");
353        assert!(findings[0].contains("2024-11-05"), "{findings:?}");
354        // The mismatched header satisfies presence.
355        assert!(findings_for("transport.protocol-version-header", &mismatched).is_empty());
356    }
357
358    #[test]
359    fn http_post_single_message_flags_batch_and_scalar_bodies() {
360        // A batch array POSTed by the client violates TRAN-026; a server-sent
361        // array on the same transport is not this clause's subject, and stdio
362        // traffic belongs to the stdio checks.
363        let trace = r#"{"seq":0,"direction":"client-to-server","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}}
364{"seq":1,"direction":"client-to-server","transport":"streamable-http","kind":"message","payload":[{"jsonrpc":"2.0","id":2,"method":"ping"},{"jsonrpc":"2.0","id":3,"method":"ping"}]}
365{"seq":2,"direction":"server-to-client","transport":"streamable-http","kind":"message","payload":[1,2,3]}
366{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":[4,5,6]}"#;
367        let findings = findings_for("transport.http-post-single-message", trace);
368        assert_eq!(findings.len(), 1, "{findings:?}");
369        assert!(
370            findings[0].contains("not a single JSON-RPC request")
371                && findings[0].contains("not a JSON object"),
372            "{findings:?}"
373        );
374
375        // A scalar body is equally not a single JSON-RPC message.
376        let scalar = r#"{"seq":0,"direction":"client-to-server","transport":"streamable-http","kind":"message","payload":"jsonrpc"}"#;
377        assert_eq!(
378            findings_for("transport.http-post-single-message", scalar).len(),
379            1
380        );
381
382        // Well-formed single messages produce no findings.
383        let clean = http_session("{}", "{}");
384        assert!(findings_for("transport.http-post-single-message", &clean).is_empty());
385    }
386
387    #[test]
388    fn stdio_validity_checks_are_direction_scoped() {
389        let trace = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}}
390{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"hello":"world"}}
391{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":[1,2,3]}"#;
392        let server = findings_for("transport.stdio-server-output-valid", trace);
393        assert_eq!(server.len(), 1, "{server:?}");
394        assert!(server[0].contains("stdout"), "{server:?}");
395        let client = findings_for("transport.stdio-client-input-valid", trace);
396        assert_eq!(client.len(), 1, "{client:?}");
397        assert!(client[0].contains("not a JSON object"), "{client:?}");
398    }
399
400    #[test]
401    fn success_content_type_judges_only_200_responses() {
402        // 200 with a content type outside the two allowed.
403        let html = http_session(r#"{"content-type":"text/html"}"#, "{}");
404        let findings = findings_for("transport.success-content-type", &html);
405        assert_eq!(findings.len(), 1, "{findings:?}");
406        assert!(findings[0].contains("text/html"), "{findings:?}");
407
408        // 200 with no content type at all.
409        let none = http_session("{}", "{}");
410        let findings = findings_for("transport.success-content-type", &none);
411        assert_eq!(findings.len(), 1, "{findings:?}");
412        assert!(findings[0].contains("no Content-Type"), "{findings:?}");
413
414        // SSE and JSON answers both pass; a 202 is not examined.
415        for ok in [
416            r#"{"content-type":"text/event-stream"}"#,
417            r#"{"content-type":"application/json; charset=utf-8"}"#,
418        ] {
419            assert!(
420                findings_for("transport.success-content-type", &http_session(ok, "{}")).is_empty(),
421                "{ok}"
422            );
423        }
424        let accepted = r#"{"seq":0,"direction":"server-to-client","transport":"streamable-http","kind":"http","status":202}"#;
425        assert!(findings_for("transport.success-content-type", accepted).is_empty());
426    }
427}