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            && !schema.is_object()
73        {
74            sink.push(
75                Some(seq),
76                format!(
77                    "tool {} has an inputSchema that is not a JSON Schema object: {schema}",
78                    tool_label(tool)
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                && !seen.insert(name)
138            {
139                sink.push(
140                    Some(exchange.response.seq),
141                    format!("tool name {name:?} appears more than once in this tools/list result"),
142                );
143            }
144        }
145    }
146}
147
148/// `TOOL-009`: servers returning embedded resources in tool results should declare
149/// the `resources` capability.
150pub(super) fn embedded_resource_capability(context: &TraceContext<'_>, sink: &mut FindingSink) {
151    if server_capability(context, &["resources"]) != Some(false) {
152        return;
153    }
154    for (seq, name, result) in call_results(context) {
155        let embedded = content_items(result)
156            .any(|item| item.get("type").and_then(Value::as_str) == Some("resource"));
157        if embedded {
158            sink.push(
159                Some(seq),
160                format!(
161                    "tool {} returned an embedded resource, but the server did not declare the resources capability",
162                    name.map_or_else(|| "(unnamed)".to_owned(), |name| format!("{name:?}"))
163                ),
164            );
165        }
166    }
167}
168
169/// `TOOL-010`: a result carrying `structuredContent` should also carry the serialized
170/// JSON in a `TextContent` block, for backwards compatibility.
171pub(super) fn structured_content_text(context: &TraceContext<'_>, sink: &mut FindingSink) {
172    for (seq, name, result) in call_results(context) {
173        if result.get("structuredContent").is_none() {
174            continue;
175        }
176        let has_text = content_items(result)
177            .any(|item| item.get("type").and_then(Value::as_str) == Some("text"));
178        if !has_text {
179            sink.push(
180                Some(seq),
181                format!(
182                    "tool {} returned structuredContent without a TextContent fallback block",
183                    name.map_or_else(|| "(unnamed)".to_owned(), |name| format!("{name:?}"))
184                ),
185            );
186        }
187    }
188}
189
190/// `TOOL-011`: when a tool declared an `outputSchema` in `tools/list`, its successful,
191/// non-`isError` call results must provide `structuredContent`. Conformance of that
192/// content *to* the schema needs a JSON Schema engine and is exercised through the
193/// official-suite agreement check (roadmap M2); presence is what a trace judges.
194pub(super) fn output_schema_structured_result(context: &TraceContext<'_>, sink: &mut FindingSink) {
195    let with_output_schema: std::collections::BTreeSet<&str> = listed_tools(context)
196        .filter(|(_, tool)| tool.get("outputSchema").is_some_and(Value::is_object))
197        .filter_map(|(_, tool)| tool.get("name").and_then(Value::as_str))
198        .collect();
199    if with_output_schema.is_empty() {
200        return;
201    }
202    for (seq, name, result) in call_results(context) {
203        let Some(name) = name else { continue };
204        if !with_output_schema.contains(name) {
205            continue;
206        }
207        if result.get("isError").and_then(Value::as_bool) == Some(true) {
208            continue; // Execution errors legitimately carry no structured result.
209        }
210        if !result
211            .get("structuredContent")
212            .is_some_and(Value::is_object)
213        {
214            sink.push(
215                Some(seq),
216                format!(
217                    "tool {name:?} declares an outputSchema but this result carries no structuredContent object"
218                ),
219            );
220        }
221    }
222}
223
224/// The `content` array items of a tool result, if any.
225fn content_items(result: &Value) -> impl Iterator<Item = &Value> {
226    result
227        .get("content")
228        .and_then(Value::as_array)
229        .into_iter()
230        .flatten()
231}
232
233/// A short identifier for a tool object in findings: its name when present.
234fn tool_label(tool: &Value) -> String {
235    tool.get("name")
236        .and_then(Value::as_str)
237        .map_or_else(|| "(unnamed)".to_owned(), |name| format!("{name:?}"))
238}
239
240#[cfg(test)]
241#[allow(clippy::unwrap_used)]
242mod tests {
243    use crate::checks;
244    use crate::context::TraceContext;
245    use crate::reader::{Limits, parse_trace};
246
247    fn findings_for(check: &str, trace: &str) -> Vec<String> {
248        let events = parse_trace(trace, &Limits::default()).unwrap();
249        let context = TraceContext::new(&events);
250        checks::find(check)
251            .unwrap()
252            .run(&context)
253            .into_iter()
254            .map(|finding| finding.detail)
255            .collect()
256    }
257
258    fn session(server_capabilities: &str, body: &[&str]) -> String {
259        let mut lines = vec![
260            format!(
261                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"}}}}}}}}"#
262            ),
263            format!(
264                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"}}}}}}}}"#
265            ),
266            r#"{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#.to_owned(),
267        ];
268        for (offset, payload) in body.iter().enumerate() {
269            let seq = 3 + offset as u64;
270            let direction = if offset % 2 == 0 {
271                "client-to-server"
272            } else {
273                "server-to-client"
274            };
275            lines.push(format!(
276                r#"{{"seq":{seq},"direction":"{direction}","transport":"stdio","kind":"message","payload":{payload}}}"#
277            ));
278        }
279        lines.join("\n")
280    }
281
282    #[test]
283    fn name_length_boundaries_are_inclusive() {
284        let ok_128 = "a".repeat(128);
285        let bad_129 = "a".repeat(129);
286        let trace = session(
287            r#"{"tools":{}}"#,
288            &[
289                r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#,
290                &format!(
291                    r#"{{"jsonrpc":"2.0","id":2,"result":{{"tools":[{{"name":"{ok_128}","inputSchema":{{"type":"object"}}}},{{"name":"{bad_129}","inputSchema":{{"type":"object"}}}},{{"name":"","inputSchema":{{"type":"object"}}}}]}}}}"#
292                ),
293            ],
294        );
295        let findings = findings_for("tools.name-length", &trace);
296        assert_eq!(findings.len(), 2, "{findings:?}");
297        assert!(findings[0].contains("129 characters"), "{findings:?}");
298        assert!(findings[1].contains("0 characters"), "{findings:?}");
299    }
300
301    #[test]
302    fn charset_findings_name_the_offending_characters() {
303        let trace = session(
304            r#"{"tools":{}}"#,
305            &[
306                r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#,
307                r#"{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"weather lookup,v2!","inputSchema":{"type":"object"}},{"name":"admin.tools.list-v2_X","inputSchema":{"type":"object"}}]}}"#,
308            ],
309        );
310        let findings = findings_for("tools.name-charset", &trace);
311        assert_eq!(findings.len(), 1, "{findings:?}");
312        assert!(findings[0].contains(r#"" ,!""#), "{findings:?}");
313    }
314
315    #[test]
316    fn capability_check_abstains_without_an_initialize_result() {
317        // Truncated trace: tools traffic but no initialize result at all — the
318        // declaration surface is missing, so the check must abstain, not flag.
319        let trace = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"tools/list"}}
320{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}}"#;
321        assert!(findings_for("tools.capability-declared", trace).is_empty());
322    }
323
324    #[test]
325    fn null_and_false_capability_values_are_not_declarations() {
326        // `{"tools": null}` and `{"tools": false}` resolve the path but declare
327        // nothing — the ADR-0006 truthiness rule, pinned here at the check layer.
328        for capabilities in [r#"{"tools":null}"#, r#"{"tools":false}"#] {
329            let trace = session(
330                capabilities,
331                &[
332                    r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#,
333                    r#"{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}"#,
334                ],
335            );
336            let findings = findings_for("tools.capability-declared", &trace);
337            assert_eq!(findings.len(), 1, "{capabilities}: {findings:?}");
338        }
339    }
340
341    #[test]
342    fn capability_check_ignores_error_answers() {
343        // A server *rejecting* tools traffic is not evidence it supports tools.
344        let trace = session(
345            "{}",
346            &[
347                r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#,
348                r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32601,"message":"Method not found"}}"#,
349            ],
350        );
351        assert!(findings_for("tools.capability-declared", &trace).is_empty());
352    }
353
354    #[test]
355    fn output_schema_check_skips_execution_errors_and_unknown_tools() {
356        let trace = session(
357            r#"{"tools":{}}"#,
358            &[
359                r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#,
360                r#"{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"w","inputSchema":{"type":"object"},"outputSchema":{"type":"object"}}]}}"#,
361                r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"w","arguments":{}}}"#,
362                r#"{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"boom"}],"isError":true}}"#,
363                r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"other","arguments":{}}}"#,
364                r#"{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"ok"}]}}"#,
365            ],
366        );
367        assert!(
368            findings_for("tools.output-schema-structured-result", &trace).is_empty(),
369            "execution errors and tools without schemas are not findings"
370        );
371    }
372}