mcp_trace_validator/checks/draft/transport/
validation.rs1use std::collections::BTreeSet;
13
14use serde_json::Value;
15
16use super::super::super::FindingSink;
17use super::super::http_status_for;
18use super::{
19 META_PROTOCOL_VERSION, Match, Post, compare, designations_by_tool, header_safe, mirrors, posts,
20 posts_by_message, sentinel_payload,
21};
22use crate::context::TraceContext;
23
24#[cfg(test)]
25mod tests;
26
27const HEADER_MISMATCH: i64 = -32020;
29const UNSUPPORTED_VERSION: i64 = -32022;
31const METHOD_NOT_FOUND: i64 = -32601;
33
34fn answer_code(response: &mcp_conformance_core::trace::TraceEvent) -> Option<i64> {
36 response
37 .message_payload()?
38 .get("error")?
39 .get("code")?
40 .as_i64()
41}
42
43fn answer_label(code: Option<i64>) -> String {
45 code.map_or_else(|| "a result".to_owned(), |code| format!("error {code}"))
46}
47
48pub(in crate::checks) fn version_mismatch_rejected(
50 context: &TraceContext<'_>,
51 sink: &mut FindingSink,
52) {
53 rejected_for(context, sink, version_mismatch_fault);
54}
55
56pub(in crate::checks) fn invalid_param_header_rejected(
58 context: &TraceContext<'_>,
59 sink: &mut FindingSink,
60) {
61 let recognized = recognized_param_headers(context);
62 rejected_for(context, sink, |post| invalid_param_fault(post, &recognized));
63}
64
65pub(in crate::checks) fn header_mismatch_status(
71 context: &TraceContext<'_>,
72 sink: &mut FindingSink,
73) {
74 for (event, _, _) in context.messages() {
75 if answer_code(event) != Some(HEADER_MISMATCH) {
76 continue;
77 }
78 let Some((status_seq, status)) = http_status_for(context, event.seq) else {
81 continue;
82 };
83 sink.examined();
84 if status != 400 {
85 sink.push(
86 Some(status_seq),
87 format!(
88 "HeaderMismatch ({HEADER_MISMATCH}) was returned with HTTP {status}, not 400"
89 ),
90 );
91 }
92 }
93}
94
95fn rejected_for(
98 context: &TraceContext<'_>,
99 sink: &mut FindingSink,
100 fault: impl Fn(&Post<'_>) -> Option<String>,
101) {
102 let by_message = posts_by_message(context);
103 for exchange in context.exchanges() {
104 let Some(post) = by_message.get(&exchange.request.seq) else {
105 continue;
106 };
107 let Some(reason) = fault(post) else {
108 continue;
109 };
110 sink.examined();
113 let code = answer_code(exchange.response);
114 if code != Some(HEADER_MISMATCH) {
115 sink.push(
116 Some(exchange.response.seq),
117 format!(
118 "the POST at seq {} {reason}; the server answered with {} instead of \
119 rejecting it with {HEADER_MISMATCH} (HeaderMismatch)",
120 post.seq,
121 answer_label(code)
122 ),
123 );
124 }
125 }
126}
127
128fn recognized_param_headers(context: &TraceContext<'_>) -> BTreeSet<String> {
131 designations_by_tool(context)
132 .values()
133 .flatten()
134 .map(|designation| designation.header.clone())
135 .collect()
136}
137
138fn version_mismatch_fault(post: &Post<'_>) -> Option<String> {
140 let sent = post.headers.get("mcp-protocol-version")?;
141 let body = post.body_protocol_version()?;
142 (sent != body).then(|| {
143 format!("carried `MCP-Protocol-Version: {sent}` against a body `_meta` version of {body:?}")
144 })
145}
146
147fn invalid_param_fault(post: &Post<'_>, recognized: &BTreeSet<String>) -> Option<String> {
150 post.headers
151 .iter()
152 .find(|(name, value)| {
153 recognized.contains(*name) && sentinel_payload(value).is_none() && !header_safe(value)
154 })
155 .map(|(name, value)| {
156 format!("carried `{name}: {value:?}`, whose characters are not valid unencoded")
157 })
158}
159
160pub(in crate::checks) fn header_body_match_validated(
167 context: &TraceContext<'_>,
168 sink: &mut FindingSink,
169) {
170 let designated = designations_by_tool(context);
171 let by_message = posts_by_message(context);
172 for exchange in context.exchanges() {
173 let Some(post) = by_message.get(&exchange.request.seq) else {
174 continue;
175 };
176 if exchange.result.is_none() {
177 continue; }
179 for mirror in mirrors(post, &designated) {
180 let Some(sent) = post.headers.get(&mirror.header) else {
181 continue;
182 };
183 sink.examined();
184 if compare(sent, &mirror.value) == Match::Mismatch {
185 sink.push(
186 Some(exchange.response.seq),
187 format!(
188 "the POST at seq {} carried `{}: {sent}` against `{}` = {:?}; the \
189 server answered it with a result instead of rejecting the mismatch",
190 post.seq, mirror.label, mirror.source, mirror.value
191 ),
192 );
193 }
194 }
195 }
196}
197
198pub(in crate::checks) fn unsupported_version_error(
201 context: &TraceContext<'_>,
202 sink: &mut FindingSink,
203) {
204 unsupported_version_shape(context, sink);
205 unsupported_version_answer(context, sink);
206}
207
208fn unsupported_version_shape(context: &TraceContext<'_>, sink: &mut FindingSink) {
210 for (event, _, _) in context.messages() {
211 if answer_code(event) != Some(UNSUPPORTED_VERSION) {
212 continue;
213 }
214 sink.examined();
215 let lists_versions = event
216 .message_payload()
217 .and_then(|payload| payload.get("error"))
218 .and_then(|error| error.get("data"))
219 .and_then(|data| data.get("supported"))
220 .and_then(Value::as_array)
221 .is_some_and(|supported| {
222 !supported.is_empty() && supported.iter().all(Value::is_string)
223 });
224 if !lists_versions {
225 sink.push(
226 Some(event.seq),
227 format!(
228 "error {UNSUPPORTED_VERSION} does not carry `data.supported` listing the \
229 protocol versions the server does implement"
230 ),
231 );
232 }
233 }
234}
235
236pub(in crate::checks) fn unsupported_version_status(
244 context: &TraceContext<'_>,
245 sink: &mut FindingSink,
246) {
247 let handshakes = legacy_handshake_ids(context);
248 for (event, _, _) in context.messages() {
249 if answer_code(event) != Some(UNSUPPORTED_VERSION) {
250 continue;
251 }
252 if event
262 .message_payload()
263 .and_then(|payload| payload.get("id"))
264 .is_some_and(|id| handshakes.contains(&id.to_string()))
265 {
266 continue;
267 }
268 let Some((status_seq, status)) = http_status_for(context, event.seq) else {
269 continue;
270 };
271 sink.examined();
272 if status != 400 {
273 sink.push(
274 Some(status_seq),
275 format!(
276 "UnsupportedProtocolVersionError ({UNSUPPORTED_VERSION}) was returned \
277 with HTTP {status}, not 400"
278 ),
279 );
280 }
281 }
282}
283
284fn unsupported_version_answer(context: &TraceContext<'_>, sink: &mut FindingSink) {
299 let Some(supported) = declared_versions(context) else {
300 return;
301 };
302 for exchange in context.exchanges() {
303 let Some(requested) = exchange
304 .params
305 .and_then(|params| params.get("_meta")?.get(META_PROTOCOL_VERSION)?.as_str())
306 else {
307 continue;
308 };
309 if supported.contains(requested) {
310 continue;
311 }
312 sink.examined();
315 let code = answer_code(exchange.response);
316 if code != Some(UNSUPPORTED_VERSION) {
317 sink.push(
318 Some(exchange.response.seq),
319 format!(
320 "the request at seq {} declared protocol version {requested:?}, which the \
321 server's own `supportedVersions` omits; it answered with {} instead of \
322 {UNSUPPORTED_VERSION}",
323 exchange.request.seq,
324 answer_label(code)
325 ),
326 );
327 }
328 }
329}
330
331fn legacy_handshake_ids(context: &TraceContext<'_>) -> BTreeSet<String> {
333 context
334 .messages()
335 .filter_map(|(event, _, _)| {
336 let payload = event.message_payload()?;
337 if payload.get("method")?.as_str()? != "initialize" {
338 return None;
339 }
340 Some(payload.get("id")?.to_string())
341 })
342 .collect()
343}
344
345fn declared_versions(context: &TraceContext<'_>) -> Option<BTreeSet<String>> {
347 context
348 .exchanges_for("server/discover")
349 .find_map(|exchange| {
350 let versions: BTreeSet<String> = exchange
351 .result?
352 .get("supportedVersions")?
353 .as_array()?
354 .iter()
355 .filter_map(|version| version.as_str().map(str::to_owned))
356 .collect();
357 (!versions.is_empty()).then_some(versions)
358 })
359}
360
361pub(in crate::checks) fn unknown_method_404(context: &TraceContext<'_>, sink: &mut FindingSink) {
363 if posts(context).is_empty() {
366 return;
367 }
368 for (event, _, _) in context.messages() {
369 if answer_code(event) != Some(METHOD_NOT_FOUND) {
370 continue;
371 }
372 let Some((status_seq, status)) = http_status_for(context, event.seq) else {
373 continue;
374 };
375 sink.examined();
376 if status != 404 {
377 sink.push(
378 Some(status_seq),
379 format!(
380 "`Method not found` ({METHOD_NOT_FOUND}) was returned with HTTP {status}, \
381 not 404"
382 ),
383 );
384 }
385 }
386}