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