mcp_trace_validator/checks/draft/features.rs
1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! The three feature-page clauses this revision added that no `2025-11-25`
5//! check covers: deterministic tool ordering, the safe integer range for a
6//! header-mirrored argument, and the empty `contents` array.
7//!
8//! Everything else on the tools, resources and prompts pages either reuses a
9//! shipped check — each read to the bottom first, since a check that consults
10//! the removed handshake is inert here — or carries an exclusion.
11
12use std::collections::BTreeSet;
13
14use mcp_conformance_core::trace::Direction;
15use serde_json::Value;
16
17use super::super::FindingSink;
18use super::transport::designations_by_tool;
19use crate::context::TraceContext;
20
21#[cfg(test)]
22mod tests;
23
24/// The largest integer IEEE 754 double-precision represents exactly.
25const SAFE_INTEGER: i64 = 9_007_199_254_740_991;
26
27/// `TOOL-022`: `tools/list` returns tools in a deterministic order.
28///
29/// The clause qualifies itself — "the same ordering across requests when the
30/// underlying set of tools has not changed" — and that qualifier is exactly what
31/// makes it checkable: two results whose tool *sets* are equal must list them in
32/// the same order. Where the sets differ the list did change, and the clause
33/// says nothing, so nothing is reported.
34pub(in crate::checks) fn deterministic_order(context: &TraceContext<'_>, sink: &mut FindingSink) {
35 let mut seen: Option<(u64, Vec<String>)> = None;
36 for exchange in context.exchanges_for("tools/list") {
37 let Some(names) = exchange
38 .result
39 .and_then(|result| result.get("tools"))
40 .and_then(Value::as_array)
41 .map(|tools| {
42 tools
43 .iter()
44 .filter_map(|tool| tool.get("name").and_then(Value::as_str))
45 .map(str::to_owned)
46 .collect::<Vec<_>>()
47 })
48 else {
49 continue;
50 };
51 if let Some((first_seq, first)) = &seen {
52 let same_set: BTreeSet<&String> = first.iter().collect();
53 let this_set: BTreeSet<&String> = names.iter().collect();
54 if same_set != this_set {
55 continue; // The set changed, so the clause says nothing here.
56 }
57 // The subject is a re-listing of an unchanged set: one `tools/list`
58 // can neither agree nor disagree with itself.
59 sink.examined();
60 if *first != names {
61 sink.push(
62 Some(exchange.response.seq),
63 format!(
64 "`tools/list` returned the same tools in a different order than the \
65 result at seq {first_seq}, though the set did not change"
66 ),
67 );
68 }
69 } else {
70 seen = Some((exchange.response.seq, names));
71 }
72 }
73}
74
75/// `TOOL-034`: a header-mirrored integer stays inside the IEEE 754 safe range.
76///
77/// Scoped to arguments at an `x-mcp-header`-annotated path, because that is what
78/// the clause is about: the value has to survive a round trip through a header
79/// and back through a double. An integer elsewhere in the arguments is the
80/// tool's own business.
81pub(in crate::checks) fn x_mcp_header_integer_range(
82 context: &TraceContext<'_>,
83 sink: &mut FindingSink,
84) {
85 let designations = designations_by_tool(context);
86 // Driven from the requests themselves rather than from answered exchanges:
87 // the clause binds the value the *client* sent, and a call the server never
88 // answered carries exactly the same out-of-range argument.
89 for (event, _, _) in context.messages() {
90 if event.direction != Direction::ClientToServer {
91 continue;
92 }
93 let Some(payload) = event.message_payload() else {
94 continue;
95 };
96 if payload.get("method").and_then(Value::as_str) != Some("tools/call") {
97 continue;
98 }
99 let Some(params) = payload.get("params") else {
100 continue;
101 };
102 let Some(name) = params.get("name").and_then(Value::as_str) else {
103 continue;
104 };
105 let Some(paths) = designations.get(name) else {
106 continue;
107 };
108 for designation in paths {
109 let mut value = params.get("arguments");
110 for segment in &designation.path {
111 value = value.and_then(|current| current.get(segment));
112 }
113 let Some(integer) = value.and_then(Value::as_i64) else {
114 continue;
115 };
116 sink.examined();
117 if !(-SAFE_INTEGER..=SAFE_INTEGER).contains(&integer) {
118 sink.push(
119 Some(event.seq),
120 format!(
121 "the argument mirrored into `{}` is {integer}, outside the \
122 IEEE 754 safe integer range",
123 designation.name
124 ),
125 );
126 }
127 }
128 }
129}
130
131/// `RES-022`: `resources/read` never answers with an empty `contents` array.
132///
133/// The specification forbids the shape outright and says why in the same breath:
134/// "An empty array is ambiguous — it could mean the resource exists but has no
135/// content, or that it doesn't exist at all." Because the objection is the
136/// ambiguity, the shape alone is the violation, and no reading of the server's
137/// intent is needed to report it.
138pub(in crate::checks) fn read_contents_non_empty(
139 context: &TraceContext<'_>,
140 sink: &mut FindingSink,
141) {
142 for exchange in context.exchanges_for("resources/read") {
143 let Some(contents) = exchange
144 .result
145 .and_then(|result| result.get("contents"))
146 .and_then(Value::as_array)
147 else {
148 continue;
149 };
150 sink.examined();
151 if contents.is_empty() {
152 sink.push(
153 Some(exchange.response.seq),
154 "`resources/read` answered with an empty `contents` array, which is ambiguous \
155 between an empty resource and a missing one"
156 .to_owned(),
157 );
158 }
159 }
160}