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/// The headers of every HTTP event in the given direction, in trace order.
51fn http_headers<'a>(
52    context: &TraceContext<'a>,
53    direction: Direction,
54) -> impl Iterator<Item = (u64, &'a BTreeMap<String, String>)> {
55    context
56        .events()
57        .iter()
58        .filter(move |event| event.direction == direction)
59        .filter_map(|event| match &event.body {
60            EventBody::Http { headers, .. } => Some((event.seq, headers)),
61            _ => None,
62        })
63}
64
65/// `TRAN-011`: session IDs must contain only visible ASCII (0x21–0x7E).
66pub(super) fn session_id_visible_ascii(context: &TraceContext<'_>, sink: &mut FindingSink) {
67    for (seq, headers) in http_headers(context, Direction::ServerToClient) {
68        if let Some(session_id) = headers.get("mcp-session-id") {
69            if !session_id.bytes().all(|byte| (0x21..=0x7E).contains(&byte)) {
70                sink.push(
71                    Some(seq),
72                    format!(
73                        "session ID {session_id:?} contains characters outside visible ASCII (0x21-0x7E)"
74                    ),
75                );
76            }
77        }
78    }
79}
80
81/// The first server-assigned session ID in the trace, with the seq it appeared at.
82fn assigned_session_id<'a>(context: &TraceContext<'a>) -> Option<(u64, &'a str)> {
83    http_headers(context, Direction::ServerToClient).find_map(|(seq, headers)| {
84        headers
85            .get("mcp-session-id")
86            .map(|session_id| (seq, session_id.as_str()))
87    })
88}
89
90/// `TRAN-013`: once the server returns an `MCP-Session-Id`, every subsequent client
91/// HTTP request must carry it.
92pub(super) fn session_id_echoed(context: &TraceContext<'_>, sink: &mut FindingSink) {
93    let Some((assigned_seq, session_id)) = assigned_session_id(context) else {
94        return;
95    };
96    for (seq, headers) in http_headers(context, Direction::ClientToServer) {
97        if seq < assigned_seq {
98            continue;
99        }
100        match headers.get("mcp-session-id") {
101            None => sink.push(
102                Some(seq),
103                format!(
104                    "client HTTP request lacks the MCP-Session-Id header; the server assigned {session_id:?} at seq {assigned_seq}"
105                ),
106            ),
107            Some(echoed) if echoed != session_id => sink.push(
108                Some(seq),
109                format!(
110                    "client echoed session ID {echoed:?}, but the server assigned {session_id:?}"
111                ),
112            ),
113            Some(_) => {}
114        }
115    }
116}
117
118/// `TRAN-017`: after initialization, every client HTTP request must carry
119/// `MCP-Protocol-Version`.
120pub(super) fn protocol_version_header(context: &TraceContext<'_>, sink: &mut FindingSink) {
121    let Some((negotiated_seq, _)) = negotiated_version(context) else {
122        return;
123    };
124    for (seq, headers) in http_headers(context, Direction::ClientToServer) {
125        if seq <= negotiated_seq {
126            continue; // Initialization traffic itself precedes "subsequent requests".
127        }
128        if !headers.contains_key("mcp-protocol-version") {
129            sink.push(
130                Some(seq),
131                "client HTTP request after initialization lacks the MCP-Protocol-Version header"
132                    .to_owned(),
133            );
134        }
135    }
136}
137
138/// `TRAN-018`: the protocol version the client sends should be the negotiated one.
139pub(super) fn protocol_version_negotiated(context: &TraceContext<'_>, sink: &mut FindingSink) {
140    let Some((negotiated_seq, negotiated)) = negotiated_version(context) else {
141        return;
142    };
143    for (seq, headers) in http_headers(context, Direction::ClientToServer) {
144        if seq <= negotiated_seq {
145            continue;
146        }
147        if let Some(sent) = headers.get("mcp-protocol-version") {
148            if sent != negotiated {
149                sink.push(
150                    Some(seq),
151                    format!(
152                        "client sent MCP-Protocol-Version {sent:?}, but {negotiated:?} was negotiated at seq {negotiated_seq}"
153                    ),
154                );
155            }
156        }
157    }
158}
159
160/// The protocol version the server's `initialize` result stated, when present and
161/// well-typed, with the seq it was negotiated at.
162fn negotiated_version<'a>(context: &TraceContext<'a>) -> Option<(u64, &'a str)> {
163    let (seq, result) = context.initialize().result?;
164    let version = result.get("protocolVersion")?.as_str()?;
165    Some((seq, version))
166}
167
168#[cfg(test)]
169#[allow(clippy::unwrap_used)]
170mod tests {
171    use crate::checks;
172    use crate::context::TraceContext;
173    use crate::reader::{Limits, parse_trace};
174
175    use mcp_conformance_core::trace::TraceEvent;
176
177    fn findings_for(check: &str, trace: &str) -> Vec<String> {
178        let events: Vec<TraceEvent> = parse_trace(trace, &Limits::default()).unwrap();
179        let context = TraceContext::new(&events);
180        checks::find(check)
181            .unwrap()
182            .run(&context)
183            .into_iter()
184            .map(|finding| finding.detail)
185            .collect()
186    }
187
188    /// An HTTP session: initialize exchange with headers, then one more request.
189    fn http_session(response_headers: &str, followup_request_headers: &str) -> String {
190        [
191            r#"{"seq":0,"direction":"client-to-server","transport":"streamable-http","kind":"http","headers":{"accept":"application/json, text/event-stream"}}"#.to_owned(),
192            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(),
193            format!(r#"{{"seq":2,"direction":"server-to-client","transport":"streamable-http","kind":"http","status":200,"headers":{response_headers}}}"#),
194            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(),
195            format!(r#"{{"seq":4,"direction":"client-to-server","transport":"streamable-http","kind":"http","headers":{followup_request_headers}}}"#),
196            r#"{"seq":5,"direction":"client-to-server","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#.to_owned(),
197        ]
198        .join("\n")
199    }
200
201    #[test]
202    fn session_id_checks_judge_assignment_and_echo() {
203        // Correct echo: no findings from either check.
204        let good = http_session(
205            r#"{"mcp-session-id":"abc123"}"#,
206            r#"{"mcp-session-id":"abc123","mcp-protocol-version":"2025-11-25"}"#,
207        );
208        assert!(findings_for("transport.session-id-visible-ascii", &good).is_empty());
209        assert!(findings_for("transport.session-id-echoed", &good).is_empty());
210
211        // Wrong echo value.
212        let wrong = http_session(
213            r#"{"mcp-session-id":"abc123"}"#,
214            r#"{"mcp-session-id":"zzz","mcp-protocol-version":"2025-11-25"}"#,
215        );
216        let findings = findings_for("transport.session-id-echoed", &wrong);
217        assert_eq!(findings.len(), 1, "{findings:?}");
218        assert!(findings[0].contains("\"zzz\""), "{findings:?}");
219
220        // No session assigned: the echo check abstains entirely.
221        let none = http_session("{}", "{}");
222        assert!(findings_for("transport.session-id-echoed", &none).is_empty());
223    }
224
225    #[test]
226    fn session_id_ascii_boundaries_are_exact() {
227        // 0x20 (space) and non-ASCII are out; 0x21 and 0x7E are in.
228        let bad = http_session(r#"{"mcp-session-id":"has space"}"#, "{}");
229        assert_eq!(
230            findings_for("transport.session-id-visible-ascii", &bad).len(),
231            1
232        );
233        let edges = http_session(r#"{"mcp-session-id":"!~"}"#, r#"{"mcp-session-id":"!~"}"#);
234        assert!(findings_for("transport.session-id-visible-ascii", &edges).is_empty());
235    }
236
237    #[test]
238    fn protocol_version_checks_scope_to_requests_after_negotiation() {
239        // The pre-initialize request (seq 0) must not be flagged; the follow-up
240        // without the header must be.
241        let missing = http_session("{}", r#"{"mcp-session-id":"x"}"#);
242        let findings = findings_for("transport.protocol-version-header", &missing);
243        assert_eq!(findings.len(), 1, "{findings:?}");
244
245        let mismatched = http_session("{}", r#"{"mcp-protocol-version":"2024-11-05"}"#);
246        let findings = findings_for("transport.protocol-version-negotiated", &mismatched);
247        assert_eq!(findings.len(), 1, "{findings:?}");
248        assert!(findings[0].contains("2024-11-05"), "{findings:?}");
249        // The mismatched header satisfies presence.
250        assert!(findings_for("transport.protocol-version-header", &mismatched).is_empty());
251    }
252
253    #[test]
254    fn stdio_validity_checks_are_direction_scoped() {
255        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"}}}}
256{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"hello":"world"}}
257{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":[1,2,3]}"#;
258        let server = findings_for("transport.stdio-server-output-valid", trace);
259        assert_eq!(server.len(), 1, "{server:?}");
260        assert!(server[0].contains("stdout"), "{server:?}");
261        let client = findings_for("transport.stdio-client-input-valid", trace);
262        assert_eq!(client.len(), 1, "{client:?}");
263        assert!(client[0].contains("not a JSON object"), "{client:?}");
264    }
265}