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