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
50pub(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
72fn 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
87pub(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
103fn 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
112pub(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
140pub(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; }
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
160pub(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
182fn 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
190pub(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
218pub(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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}