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
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
27pub(super) fn stdio_server_output_valid(context: &TraceContext<'_>, sink: &mut FindingSink) {
29 stdio_messages_valid(context, sink, Direction::ServerToClient, "stdout");
30}
31
32pub(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
57pub(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
80fn 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
95pub(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
113fn 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
122pub(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
151pub(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; }
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
172pub(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 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
198fn 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
206pub(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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}