Skip to main content

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}