1use 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
21pub(super) fn stdio_server_output_valid(context: &TraceContext<'_>, sink: &mut FindingSink) {
23 stdio_messages_valid(context, sink, Direction::ServerToClient, "stdout");
24}
25
26pub(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
50fn 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
65pub(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 && !session_id.bytes().all(|byte| (0x21..=0x7E).contains(&byte))
70 {
71 sink.push(
72 Some(seq),
73 format!(
74 "session ID {session_id:?} contains characters outside visible ASCII (0x21-0x7E)"
75 ),
76 );
77 }
78 }
79}
80
81fn 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
90pub(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
118pub(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; }
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
138pub(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 && sent != negotiated
149 {
150 sink.push(
151 Some(seq),
152 format!(
153 "client sent MCP-Protocol-Version {sent:?}, but {negotiated:?} was negotiated at seq {negotiated_seq}"
154 ),
155 );
156 }
157 }
158}
159
160fn 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
168pub(super) fn client_accept_header(context: &TraceContext<'_>, sink: &mut FindingSink) {
178 for (seq, headers) in http_headers(context, Direction::ClientToServer) {
179 match headers.get("accept") {
180 None => sink.push(
181 Some(seq),
182 "client HTTP request has no Accept header; every request to the MCP \
183 endpoint must list text/event-stream (and POST requests application/json)"
184 .to_owned(),
185 ),
186 Some(accept) if !accept.to_ascii_lowercase().contains("text/event-stream") => sink
187 .push(
188 Some(seq),
189 format!("client Accept header {accept:?} does not list text/event-stream"),
190 ),
191 Some(_) => {}
192 }
193 }
194}
195
196pub(super) fn success_content_type(context: &TraceContext<'_>, sink: &mut FindingSink) {
204 for event in context.events() {
205 if event.direction != Direction::ServerToClient {
206 continue;
207 }
208 let EventBody::Http {
209 status: Some(200),
210 headers,
211 } = &event.body
212 else {
213 continue;
214 };
215 match headers.get("content-type") {
216 None => sink.push(
217 Some(event.seq),
218 "HTTP 200 response carries no Content-Type header; the MCP endpoint \
219 must answer with application/json or text/event-stream"
220 .to_owned(),
221 ),
222 Some(content_type) => {
223 let lowered = content_type.to_ascii_lowercase();
224 if !lowered.contains("application/json") && !lowered.contains("text/event-stream") {
225 sink.push(
226 Some(event.seq),
227 format!(
228 "HTTP 200 Content-Type {content_type:?} is neither application/json \
229 nor text/event-stream"
230 ),
231 );
232 }
233 }
234 }
235 }
236}
237
238#[cfg(test)]
239#[allow(clippy::unwrap_used)]
240mod tests {
241 use crate::checks;
242 use crate::context::TraceContext;
243 use crate::reader::{Limits, parse_trace};
244
245 use mcp_conformance_core::trace::TraceEvent;
246
247 fn findings_for(check: &str, trace: &str) -> Vec<String> {
248 let events: Vec<TraceEvent> = parse_trace(trace, &Limits::default()).unwrap();
249 let context = TraceContext::new(&events);
250 checks::find(check)
251 .unwrap()
252 .run(&context)
253 .into_iter()
254 .map(|finding| finding.detail)
255 .collect()
256 }
257
258 fn http_session(response_headers: &str, followup_request_headers: &str) -> String {
260 [
261 r#"{"seq":0,"direction":"client-to-server","transport":"streamable-http","kind":"http","headers":{"accept":"application/json, text/event-stream"}}"#.to_owned(),
262 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(),
263 format!(r#"{{"seq":2,"direction":"server-to-client","transport":"streamable-http","kind":"http","status":200,"headers":{response_headers}}}"#),
264 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(),
265 format!(r#"{{"seq":4,"direction":"client-to-server","transport":"streamable-http","kind":"http","headers":{followup_request_headers}}}"#),
266 r#"{"seq":5,"direction":"client-to-server","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#.to_owned(),
267 ]
268 .join("\n")
269 }
270
271 #[test]
272 fn session_id_checks_judge_assignment_and_echo() {
273 let good = http_session(
275 r#"{"mcp-session-id":"abc123"}"#,
276 r#"{"mcp-session-id":"abc123","mcp-protocol-version":"2025-11-25"}"#,
277 );
278 assert!(findings_for("transport.session-id-visible-ascii", &good).is_empty());
279 assert!(findings_for("transport.session-id-echoed", &good).is_empty());
280
281 let wrong = http_session(
283 r#"{"mcp-session-id":"abc123"}"#,
284 r#"{"mcp-session-id":"zzz","mcp-protocol-version":"2025-11-25"}"#,
285 );
286 let findings = findings_for("transport.session-id-echoed", &wrong);
287 assert_eq!(findings.len(), 1, "{findings:?}");
288 assert!(findings[0].contains("\"zzz\""), "{findings:?}");
289
290 let none = http_session("{}", "{}");
292 assert!(findings_for("transport.session-id-echoed", &none).is_empty());
293 }
294
295 #[test]
296 fn capitalized_header_names_are_still_judged() {
297 let bad = http_session(r#"{"Mcp-Session-Id":"has space"}"#, "{}");
301 assert_eq!(
302 findings_for("transport.session-id-visible-ascii", &bad).len(),
303 1,
304 "capitalized header name evaded the visible-ASCII check"
305 );
306 let wrong_version = http_session(
309 r#"{"mcp-session-id":"abc"}"#,
310 r#"{"mcp-session-id":"abc","Mcp-Protocol-Version":"2024-11-05"}"#,
311 );
312 let findings = findings_for("transport.protocol-version-negotiated", &wrong_version);
313 assert_eq!(findings.len(), 1, "{findings:?}");
314 assert!(findings[0].contains("2024-11-05"), "{findings:?}");
315 }
316
317 #[test]
318 fn session_id_ascii_boundaries_are_exact() {
319 let bad = http_session(r#"{"mcp-session-id":"has space"}"#, "{}");
321 assert_eq!(
322 findings_for("transport.session-id-visible-ascii", &bad).len(),
323 1
324 );
325 let edges = http_session(r#"{"mcp-session-id":"!~"}"#, r#"{"mcp-session-id":"!~"}"#);
326 assert!(findings_for("transport.session-id-visible-ascii", &edges).is_empty());
327 }
328
329 #[test]
330 fn protocol_version_checks_scope_to_requests_after_negotiation() {
331 let missing = http_session("{}", r#"{"mcp-session-id":"x"}"#);
334 let findings = findings_for("transport.protocol-version-header", &missing);
335 assert_eq!(findings.len(), 1, "{findings:?}");
336
337 let mismatched = http_session("{}", r#"{"mcp-protocol-version":"2024-11-05"}"#);
338 let findings = findings_for("transport.protocol-version-negotiated", &mismatched);
339 assert_eq!(findings.len(), 1, "{findings:?}");
340 assert!(findings[0].contains("2024-11-05"), "{findings:?}");
341 assert!(findings_for("transport.protocol-version-header", &mismatched).is_empty());
343 }
344
345 #[test]
346 fn stdio_validity_checks_are_direction_scoped() {
347 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"}}}}
348{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"hello":"world"}}
349{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":[1,2,3]}"#;
350 let server = findings_for("transport.stdio-server-output-valid", trace);
351 assert_eq!(server.len(), 1, "{server:?}");
352 assert!(server[0].contains("stdout"), "{server:?}");
353 let client = findings_for("transport.stdio-client-input-valid", trace);
354 assert_eq!(client.len(), 1, "{client:?}");
355 assert!(client[0].contains("not a JSON object"), "{client:?}");
356 }
357
358 #[test]
359 fn client_accept_header_requires_event_stream_on_every_request() {
360 let missing = http_session("{}", r#"{"mcp-protocol-version":"2025-11-25"}"#);
362 let findings = findings_for("transport.client-accept-header", &missing);
363 assert_eq!(findings.len(), 1, "{findings:?}");
364 assert!(findings[0].contains("no Accept header"), "{findings:?}");
365
366 let json_only = http_session("{}", r#"{"accept":"application/json"}"#);
368 let findings = findings_for("transport.client-accept-header", &json_only);
369 assert_eq!(findings.len(), 1, "{findings:?}");
370 assert!(findings[0].contains("text/event-stream"), "{findings:?}");
371
372 let fine = http_session(
374 "{}",
375 r#"{"accept":"TEXT/EVENT-STREAM;q=0.9, application/json"}"#,
376 );
377 assert!(findings_for("transport.client-accept-header", &fine).is_empty());
378 }
379
380 #[test]
381 fn success_content_type_judges_only_200_responses() {
382 let html = http_session(r#"{"content-type":"text/html"}"#, "{}");
384 let findings = findings_for("transport.success-content-type", &html);
385 assert_eq!(findings.len(), 1, "{findings:?}");
386 assert!(findings[0].contains("text/html"), "{findings:?}");
387
388 let none = http_session("{}", "{}");
390 let findings = findings_for("transport.success-content-type", &none);
391 assert_eq!(findings.len(), 1, "{findings:?}");
392 assert!(findings[0].contains("no Content-Type"), "{findings:?}");
393
394 for ok in [
396 r#"{"content-type":"text/event-stream"}"#,
397 r#"{"content-type":"application/json; charset=utf-8"}"#,
398 ] {
399 assert!(
400 findings_for("transport.success-content-type", &http_session(ok, "{}")).is_empty(),
401 "{ok}"
402 );
403 }
404 let accepted = r#"{"seq":0,"direction":"server-to-client","transport":"streamable-http","kind":"http","status":202}"#;
405 assert!(findings_for("transport.success-content-type", accepted).is_empty());
406 }
407}