mcp_trace_validator/checks/draft/
transport.rs1use std::collections::BTreeMap;
19
20use serde_json::Value;
21
22use super::super::support::decode_base64;
23use crate::context::TraceContext;
24use mcp_conformance_core::trace::{Direction, EventBody, TransportKind};
25
26mod headers;
27mod stdio;
28mod stream;
29mod validation;
30
31#[cfg(test)]
32mod tests;
33
34pub(in crate::checks) use headers::{
35 header_value_encoding, protocol_version_header_matches_body, protocol_version_header_present,
36 request_metadata_headers, sentinel_marker_case, sentinel_pattern_encoded,
37 x_mcp_header_mirrored, x_mcp_header_name_valid,
38};
39pub(in crate::checks) use stdio::{
40 cancel_notification_references_request, no_messages_after_cancel_notification,
41};
42pub(in crate::checks) use stream::{
43 accel_buffering_header, client_no_responses, no_independent_server_requests,
44 no_messages_after_cancellation,
45};
46pub(in crate::checks) use validation::{
47 header_body_match_validated, header_mismatch_status, invalid_param_header_rejected,
48 unknown_method_404, unsupported_version_error, unsupported_version_status,
49 version_mismatch_rejected,
50};
51
52pub(super) const NAME_SOURCED: &[(&str, &str)] = &[
54 ("tools/call", "name"),
55 ("prompts/get", "name"),
56 ("resources/read", "uri"),
57];
58
59pub(super) const META_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion";
61
62const SENTINEL_OPEN: &str = "=?base64?";
64const SENTINEL_CLOSE: &str = "?=";
66
67#[derive(Debug, Clone, Copy)]
69pub(super) struct Post<'a> {
70 pub seq: u64,
72 pub message_seq: u64,
74 pub headers: &'a BTreeMap<String, String>,
77 pub payload: &'a Value,
79}
80
81impl Post<'_> {
82 pub(super) fn method(&self) -> Option<&str> {
84 self.payload.get("method").and_then(Value::as_str)
85 }
86
87 pub(super) fn is_request(&self) -> bool {
94 self.method().is_some() && self.payload.get("id").is_some_and(|id| !id.is_null())
95 }
96
97 pub(super) fn body_protocol_version(&self) -> Option<&str> {
99 self.payload
100 .get("params")?
101 .get("_meta")?
102 .get(META_PROTOCOL_VERSION)?
103 .as_str()
104 }
105}
106
107pub(super) fn posts<'a>(context: &'a TraceContext<'_>) -> Vec<Post<'a>> {
114 let mut out = Vec::new();
115 let events = context.events();
116 for (index, event) in events.iter().enumerate() {
117 if event.direction != Direction::ClientToServer
118 || event.transport != TransportKind::StreamableHttp
119 {
120 continue;
121 }
122 let EventBody::Http { headers, .. } = &event.body else {
123 continue;
124 };
125 let framed = events[index + 1..]
126 .iter()
127 .find(|later| later.direction == Direction::ClientToServer);
128 if let Some(framed) = framed
129 && let Some(payload) = framed.message_payload()
130 {
131 out.push(Post {
132 seq: event.seq,
133 message_seq: framed.seq,
134 headers,
135 payload,
136 });
137 }
138 }
139 out
140}
141
142pub(super) fn posts_by_message<'a>(context: &'a TraceContext<'_>) -> BTreeMap<u64, Post<'a>> {
145 posts(context)
146 .into_iter()
147 .map(|post| (post.message_seq, post))
148 .collect()
149}
150
151pub(super) fn header_safe(value: &str) -> bool {
157 value.trim() == value
158 && value
159 .bytes()
160 .all(|byte| byte == 0x09 || (0x20..0x7f).contains(&byte))
161}
162
163pub(super) fn sentinel_payload(value: &str) -> Option<&str> {
165 value
166 .strip_prefix(SENTINEL_OPEN)
167 .and_then(|rest| rest.strip_suffix(SENTINEL_CLOSE))
168}
169
170pub(super) fn is_miscased_sentinel(value: &str) -> bool {
176 let folded = value.to_ascii_lowercase();
177 sentinel_payload(value).is_none() && sentinel_payload(&folded).is_some()
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub(super) enum Match {
183 Carried,
185 UnencodedSentinel,
188 Mismatch,
190}
191
192pub(super) fn compare(header: &str, body: &str) -> Match {
196 if header == body {
197 return if sentinel_payload(body).is_some() {
198 Match::UnencodedSentinel
199 } else {
200 Match::Carried
201 };
202 }
203 if let Some(encoded) = sentinel_payload(header)
204 && decode_base64(encoded).as_deref() == Some(body)
205 {
206 return Match::Carried;
207 }
208 Match::Mismatch
209}
210
211#[derive(Debug, Clone)]
213pub(super) struct Designation {
214 pub path: Vec<String>,
216 pub name: String,
218 pub header: String,
220 pub declared_type: Option<String>,
222}
223
224pub(super) fn tool_definitions<'a>(
226 context: &'a TraceContext<'_>,
227) -> impl Iterator<Item = (u64, &'a Value)> + 'a {
228 context.messages().flat_map(|(event, _, _)| {
229 event
230 .message_payload()
231 .and_then(|payload| payload.get("result"))
232 .and_then(|result| result.get("tools"))
233 .and_then(Value::as_array)
234 .map(|tools| tools.iter().map(move |tool| (event.seq, tool)))
235 .into_iter()
236 .flatten()
237 })
238}
239
240pub(super) fn designations(schema: &Value) -> Vec<Designation> {
247 let mut out = Vec::new();
248 collect_designations(schema, &mut Vec::new(), &mut out);
249 out
250}
251
252fn collect_designations(schema: &Value, path: &mut Vec<String>, out: &mut Vec<Designation>) {
254 let Some(properties) = schema.get("properties").and_then(Value::as_object) else {
255 return;
256 };
257 for (property, subschema) in properties {
258 path.push(property.clone());
259 if let Some(name) = subschema.get("x-mcp-header").and_then(Value::as_str) {
260 out.push(Designation {
261 path: path.clone(),
262 name: name.to_owned(),
263 header: format!("mcp-param-{}", name.to_ascii_lowercase()),
264 declared_type: subschema
265 .get("type")
266 .and_then(Value::as_str)
267 .map(str::to_owned),
268 });
269 }
270 collect_designations(subschema, path, out);
271 path.pop();
272 }
273}
274
275pub(in crate::checks::draft) fn designations_by_tool(
277 context: &TraceContext<'_>,
278) -> BTreeMap<String, Vec<Designation>> {
279 let mut out = BTreeMap::new();
280 for (_, tool) in tool_definitions(context) {
281 let Some(name) = tool.get("name").and_then(Value::as_str) else {
282 continue;
283 };
284 let Some(schema) = tool.get("inputSchema") else {
285 continue;
286 };
287 let declared = designations(schema);
288 if !declared.is_empty() {
289 out.insert(name.to_owned(), declared);
290 }
291 }
292 out
293}
294
295fn value_at<'a>(root: &'a Value, path: &[String]) -> Option<&'a Value> {
297 let mut cursor = root;
298 for step in path {
299 cursor = cursor.get(step)?;
300 }
301 Some(cursor)
302}
303
304fn header_text(value: &Value) -> Option<String> {
310 match value {
311 Value::String(text) => Some(text.clone()),
312 Value::Bool(flag) => Some(flag.to_string()),
313 Value::Number(number) if number.is_i64() || number.is_u64() => Some(number.to_string()),
314 _ => None,
315 }
316}
317
318#[derive(Debug, Clone)]
320pub(super) struct Mirror {
321 pub header: String,
323 pub label: String,
325 pub source: String,
327 pub value: String,
329 pub encodable: bool,
331}
332
333pub(super) fn mirrors(
339 post: &Post<'_>,
340 designated: &BTreeMap<String, Vec<Designation>>,
341) -> Vec<Mirror> {
342 let mut out = Vec::new();
343 let Some(method) = post.method() else {
344 return out;
345 };
346 out.push(Mirror {
347 header: "mcp-method".to_owned(),
348 label: "Mcp-Method".to_owned(),
349 source: "method".to_owned(),
350 value: method.to_owned(),
351 encodable: false,
352 });
353 let params = post.payload.get("params");
354 if let Some((_, field)) = NAME_SOURCED.iter().find(|(name, _)| *name == method)
355 && let Some(value) = params
356 .and_then(|params| params.get(*field))
357 .and_then(Value::as_str)
358 {
359 out.push(Mirror {
360 header: "mcp-name".to_owned(),
361 label: "Mcp-Name".to_owned(),
362 source: format!("params.{field}"),
363 value: value.to_owned(),
364 encodable: true,
365 });
366 }
367 let tool = params
368 .and_then(|params| params.get("name"))
369 .and_then(Value::as_str);
370 let declared = (method == "tools/call")
371 .then(|| tool.and_then(|tool| designated.get(tool)))
372 .flatten();
373 let arguments = params.and_then(|params| params.get("arguments"));
374 for designation in declared.into_iter().flatten() {
375 let Some(value) = arguments
376 .and_then(|arguments| value_at(arguments, &designation.path))
377 .and_then(header_text)
378 else {
379 continue;
380 };
381 out.push(Mirror {
382 header: designation.header.clone(),
383 label: format!("Mcp-Param-{}", designation.name),
384 source: format!("params.arguments.{}", designation.path.join(".")),
385 value,
386 encodable: true,
387 });
388 }
389 out
390}