Skip to main content

mcp_trace_validator/checks/
tools.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! Checks for the `2025-11-25` tools requirements (`TOOL-*`).
5//!
6//! List-shaped evidence comes from `tools/list` results; call-shaped evidence from
7//! `tools/call` exchanges. Checks abstain (no finding) when the trace lacks the
8//! evidence a judgment needs — a missing `initialize` result, an error response, a
9//! tool object without a `name` — because those gaps are other requirements'
10//! findings, not these.
11
12use serde_json::Value;
13
14use super::FindingSink;
15use super::support::{Declaration, server_capability};
16use crate::context::TraceContext;
17
18/// Every tool object across all `tools/list` results, with the result event's `seq`.
19fn listed_tools<'a>(context: &TraceContext<'a>) -> impl Iterator<Item = (u64, &'a Value)> {
20    context.exchanges_for("tools/list").flat_map(|exchange| {
21        let seq = exchange.response.seq;
22        exchange
23            .result
24            .and_then(|result| result.get("tools"))
25            .and_then(Value::as_array)
26            .into_iter()
27            .flatten()
28            .map(move |tool| (seq, tool))
29    })
30}
31
32/// Successful `tools/call` results, with the called tool's name when stated.
33fn call_results<'a>(
34    context: &TraceContext<'a>,
35) -> impl Iterator<Item = (u64, Option<&'a str>, &'a Value)> {
36    context.exchanges_for("tools/call").filter_map(|exchange| {
37        let result = exchange.result?;
38        let name = exchange
39            .params
40            .and_then(|params| params.get("name"))
41            .and_then(Value::as_str);
42        Some((exchange.response.seq, name, result))
43    })
44}
45
46/// `TOOL-001`: "Servers that support tools MUST declare the `tools` capability:" —
47/// successfully serving tools traffic, or emitting the tools list-changed
48/// notification, is the observable form of supporting tools.
49pub(super) fn capability_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
50    // The subject is an answered `tools/*` exchange — the observable form of
51    // supporting tools — whether or not the declaration is there. A session
52    // that never exercised tools has nothing to judge and says so.
53    let declared = match server_capability(context, &["tools"]) {
54        Declaration::Declared => true,
55        Declaration::Withheld => false,
56        // Nothing in this trace could have declared anything, so it shows
57        // neither compliance nor violation: abstain before counting a subject.
58        Declaration::Unknowable => return,
59    };
60    for exchange in context.exchanges() {
61        if exchange.method.starts_with("tools/") && exchange.result.is_some() {
62            sink.examined();
63            if !declared {
64                sink.push(
65                    Some(exchange.response.seq),
66                    format!(
67                        "server answered {:?} without declaring the tools capability",
68                        exchange.method
69                    ),
70                );
71            }
72        }
73    }
74}
75
76/// `TOOL-003`: a listed tool's `inputSchema` must be a JSON Schema *object* — never
77/// `null`, an array, or any other scalar. Presence is not judged here (the spec's
78/// shape lists the member; this clause constrains its type).
79pub(super) fn input_schema_object(context: &TraceContext<'_>, sink: &mut FindingSink) {
80    for (seq, tool) in listed_tools(context) {
81        let Some(schema) = tool.get("inputSchema") else {
82            continue;
83        };
84        sink.examined();
85        if !schema.is_object() {
86            sink.push(
87                Some(seq),
88                format!(
89                    "tool {} has an inputSchema that is not a JSON Schema object: {schema}",
90                    tool_label(tool)
91                ),
92            );
93        }
94    }
95}
96
97/// `TOOL-005`: tool names should be 1–128 characters long, inclusive.
98pub(super) fn name_length(context: &TraceContext<'_>, sink: &mut FindingSink) {
99    for (seq, tool) in listed_tools(context) {
100        let Some(name) = tool.get("name").and_then(Value::as_str) else {
101            continue; // A tool object without a string name is BASE's finding.
102        };
103        sink.examined();
104        let length = name.chars().count();
105        if !(1..=128).contains(&length) {
106            sink.push(
107                Some(seq),
108                format!("tool name {name:?} is {length} characters long, expected 1 to 128"),
109            );
110        }
111    }
112}
113
114/// `TOOL-006` / `TOOL-007`: tool names should use only ASCII letters, digits,
115/// underscore, hyphen, and dot — which also rules out spaces, commas, and other
116/// special characters.
117pub(super) fn name_charset(context: &TraceContext<'_>, sink: &mut FindingSink) {
118    for (seq, tool) in listed_tools(context) {
119        let Some(name) = tool.get("name").and_then(Value::as_str) else {
120            continue;
121        };
122        sink.examined();
123        let offenders: String = name
124            .chars()
125            .filter(|c| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')))
126            .collect();
127        if !offenders.is_empty() {
128            sink.push(
129                Some(seq),
130                format!(
131                    "tool name {name:?} contains characters outside A-Z, a-z, 0-9, underscore, hyphen, and dot: {offenders:?}"
132                ),
133            );
134        }
135    }
136}
137
138/// `TOOL-008`: tool names should be unique within a server. Judged within each
139/// `tools/list` result: re-listing the same page is not a duplication, so cross-result
140/// repeats are out of scope (and pagination cursor flows are PAGE-002's business).
141pub(super) fn name_unique(context: &TraceContext<'_>, sink: &mut FindingSink) {
142    for exchange in context.exchanges_for("tools/list") {
143        let Some(tools) = exchange
144            .result
145            .and_then(|result| result.get("tools"))
146            .and_then(Value::as_array)
147        else {
148            continue;
149        };
150        let mut seen = std::collections::BTreeSet::new();
151        for tool in tools {
152            let Some(name) = tool.get("name").and_then(Value::as_str) else {
153                continue;
154            };
155            sink.examined();
156            if !seen.insert(name) {
157                sink.push(
158                    Some(exchange.response.seq),
159                    format!("tool name {name:?} appears more than once in this tools/list result"),
160                );
161            }
162        }
163    }
164}
165
166/// `TOOL-009`: servers returning embedded resources in tool results should declare
167/// the `resources` capability.
168pub(super) fn embedded_resource_capability(context: &TraceContext<'_>, sink: &mut FindingSink) {
169    // The subject is a result that actually embedded a resource; a session
170    // whose tools never returned one leaves this clause untested.
171    let declared = match server_capability(context, &["resources"]) {
172        Declaration::Declared => true,
173        Declaration::Withheld => false,
174        // Nothing in this trace could have declared anything, so it shows
175        // neither compliance nor violation: abstain before counting a subject.
176        Declaration::Unknowable => return,
177    };
178    for (seq, name, result) in call_results(context) {
179        let embedded = content_items(result)
180            .any(|item| item.get("type").and_then(Value::as_str) == Some("resource"));
181        if !embedded {
182            continue;
183        }
184        sink.examined();
185        if !declared {
186            sink.push(
187                Some(seq),
188                format!(
189                    "tool {} returned an embedded resource, but the server did not declare the resources capability",
190                    name.map_or_else(|| "(unnamed)".to_owned(), |name| format!("{name:?}"))
191                ),
192            );
193        }
194    }
195}
196
197/// `TOOL-010`: a result carrying `structuredContent` should also carry the serialized
198/// JSON in a `TextContent` block, for backwards compatibility.
199pub(super) fn structured_content_text(context: &TraceContext<'_>, sink: &mut FindingSink) {
200    for (seq, name, result) in call_results(context) {
201        if result.get("structuredContent").is_none() {
202            continue;
203        }
204        sink.examined();
205        let has_text = content_items(result)
206            .any(|item| item.get("type").and_then(Value::as_str) == Some("text"));
207        if !has_text {
208            sink.push(
209                Some(seq),
210                format!(
211                    "tool {} returned structuredContent without a TextContent fallback block",
212                    name.map_or_else(|| "(unnamed)".to_owned(), |name| format!("{name:?}"))
213                ),
214            );
215        }
216    }
217}
218
219/// `TOOL-011`: when a tool declared an `outputSchema` in `tools/list`, its successful,
220/// non-`isError` call results must provide `structuredContent`. Conformance of that
221/// content *to* the schema needs a JSON Schema engine and is exercised through the
222/// official-suite agreement check (roadmap M2); presence is what a trace judges.
223pub(super) fn output_schema_structured_result(context: &TraceContext<'_>, sink: &mut FindingSink) {
224    let with_output_schema: std::collections::BTreeSet<&str> = listed_tools(context)
225        .filter(|(_, tool)| tool.get("outputSchema").is_some_and(Value::is_object))
226        .filter_map(|(_, tool)| tool.get("name").and_then(Value::as_str))
227        .collect();
228    if with_output_schema.is_empty() {
229        return;
230    }
231    for (seq, name, result) in call_results(context) {
232        let Some(name) = name else { continue };
233        if !with_output_schema.contains(name) {
234            continue;
235        }
236        if result.get("isError").and_then(Value::as_bool) == Some(true) {
237            continue; // Execution errors legitimately carry no structured result.
238        }
239        sink.examined();
240        if !result
241            .get("structuredContent")
242            .is_some_and(Value::is_object)
243        {
244            sink.push(
245                Some(seq),
246                format!(
247                    "tool {name:?} declares an outputSchema but this result carries no structuredContent object"
248                ),
249            );
250        }
251    }
252}
253
254/// The `content` array items of a tool result, if any.
255fn content_items(result: &Value) -> impl Iterator<Item = &Value> {
256    result
257        .get("content")
258        .and_then(Value::as_array)
259        .into_iter()
260        .flatten()
261}
262
263/// A short identifier for a tool object in findings: its name when present.
264fn tool_label(tool: &Value) -> String {
265    tool.get("name")
266        .and_then(Value::as_str)
267        .map_or_else(|| "(unnamed)".to_owned(), |name| format!("{name:?}"))
268}
269
270#[cfg(test)]
271#[allow(clippy::unwrap_used)]
272mod tests {
273    use crate::checks;
274    use crate::context::TraceContext;
275    use crate::reader::{Limits, parse_trace};
276
277    fn findings_for(check: &str, trace: &str) -> Vec<String> {
278        let events = parse_trace(trace, &Limits::default()).unwrap();
279        let context = TraceContext::new(&events);
280        checks::find(check)
281            .unwrap()
282            .run(&context)
283            .findings
284            .into_iter()
285            .map(|finding| finding.detail)
286            .collect()
287    }
288
289    fn session(server_capabilities: &str, body: &[&str]) -> String {
290        let mut lines = vec![
291            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"}}}}"#.to_owned(),
292            format!(
293                r#"{{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{{"jsonrpc":"2.0","id":1,"result":{{"protocolVersion":"2025-11-25","capabilities":{server_capabilities},"serverInfo":{{"name":"s","version":"0"}}}}}}}}"#
294            ),
295            r#"{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#.to_owned(),
296        ];
297        for (offset, payload) in body.iter().enumerate() {
298            let seq = 3 + offset as u64;
299            let direction = if offset % 2 == 0 {
300                "client-to-server"
301            } else {
302                "server-to-client"
303            };
304            lines.push(format!(
305                r#"{{"seq":{seq},"direction":"{direction}","transport":"stdio","kind":"message","payload":{payload}}}"#
306            ));
307        }
308        lines.join("\n")
309    }
310
311    #[test]
312    fn name_length_boundaries_are_inclusive() {
313        let ok_128 = "a".repeat(128);
314        let bad_129 = "a".repeat(129);
315        let trace = session(
316            r#"{"tools":{}}"#,
317            &[
318                r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#,
319                &format!(
320                    r#"{{"jsonrpc":"2.0","id":2,"result":{{"tools":[{{"name":"{ok_128}","inputSchema":{{"type":"object"}}}},{{"name":"{bad_129}","inputSchema":{{"type":"object"}}}},{{"name":"","inputSchema":{{"type":"object"}}}}]}}}}"#
321                ),
322            ],
323        );
324        let findings = findings_for("tools.name-length", &trace);
325        assert_eq!(findings.len(), 2, "{findings:?}");
326        assert!(findings[0].contains("129 characters"), "{findings:?}");
327        assert!(findings[1].contains("0 characters"), "{findings:?}");
328    }
329
330    #[test]
331    fn charset_findings_name_the_offending_characters() {
332        let trace = session(
333            r#"{"tools":{}}"#,
334            &[
335                r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#,
336                r#"{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"weather lookup,v2!","inputSchema":{"type":"object"}},{"name":"admin.tools.list-v2_X","inputSchema":{"type":"object"}}]}}"#,
337            ],
338        );
339        let findings = findings_for("tools.name-charset", &trace);
340        assert_eq!(findings.len(), 1, "{findings:?}");
341        assert!(findings[0].contains(r#"" ,!""#), "{findings:?}");
342    }
343
344    #[test]
345    fn capability_check_abstains_without_an_initialize_result() {
346        // Truncated trace: tools traffic but no initialize result at all — the
347        // declaration surface is missing, so the check must abstain, not flag.
348        let trace = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"tools/list"}}
349{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}}"#;
350        assert!(findings_for("tools.capability-declared", trace).is_empty());
351    }
352
353    #[test]
354    fn null_and_false_capability_values_are_not_declarations() {
355        // `{"tools": null}` and `{"tools": false}` resolve the path but declare
356        // nothing — the ADR-0006 truthiness rule, pinned here at the check layer.
357        for capabilities in [r#"{"tools":null}"#, r#"{"tools":false}"#] {
358            let trace = session(
359                capabilities,
360                &[
361                    r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#,
362                    r#"{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}"#,
363                ],
364            );
365            let findings = findings_for("tools.capability-declared", &trace);
366            assert_eq!(findings.len(), 1, "{capabilities}: {findings:?}");
367        }
368    }
369
370    #[test]
371    fn capability_check_ignores_error_answers() {
372        // A server *rejecting* tools traffic is not evidence it supports tools.
373        let trace = session(
374            "{}",
375            &[
376                r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#,
377                r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32601,"message":"Method not found"}}"#,
378            ],
379        );
380        assert!(findings_for("tools.capability-declared", &trace).is_empty());
381    }
382
383    #[test]
384    fn output_schema_check_skips_execution_errors_and_unknown_tools() {
385        let trace = session(
386            r#"{"tools":{}}"#,
387            &[
388                r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#,
389                r#"{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"w","inputSchema":{"type":"object"},"outputSchema":{"type":"object"}}]}}"#,
390                r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"w","arguments":{}}}"#,
391                r#"{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"boom"}],"isError":true}}"#,
392                r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"other","arguments":{}}}"#,
393                r#"{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"ok"}]}}"#,
394            ],
395        );
396        assert!(
397            findings_for("tools.output-schema-structured-result", &trace).is_empty(),
398            "execution errors and tools without schemas are not findings"
399        );
400    }
401}