1use serde_json::Value;
13
14use super::FindingSink;
15use super::support::{Declaration, server_capability};
16use crate::context::TraceContext;
17
18fn 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
32fn 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
46pub(super) fn capability_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
50 let declared = match server_capability(context, &["tools"]) {
54 Declaration::Declared => true,
55 Declaration::Withheld => false,
56 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
76pub(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
97pub(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; };
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
114pub(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
138pub(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
166pub(super) fn embedded_resource_capability(context: &TraceContext<'_>, sink: &mut FindingSink) {
169 let declared = match server_capability(context, &["resources"]) {
172 Declaration::Declared => true,
173 Declaration::Withheld => false,
174 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
197pub(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
219pub(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; }
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
254fn 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
263fn 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 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 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 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}