1use serde_json::Value;
13
14use super::FindingSink;
15use super::support::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 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
66pub(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
85pub(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
100pub(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
122pub(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
150pub(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
171pub(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
192pub(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; }
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
226fn 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
235fn 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 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 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 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}