1use serde_json::Value;
11
12use super::FindingSink;
13use super::support::{Declaration, has_rfc3986_scheme, is_base64, server_capability};
14use crate::context::TraceContext;
15
16pub(super) fn capability_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
20 let declared = match server_capability(context, &["prompts"]) {
23 Declaration::Declared => true,
24 Declaration::Withheld => false,
25 Declaration::Unknowable => return,
28 };
29 for exchange in context.exchanges() {
30 if matches!(exchange.method, "prompts/list" | "prompts/get") && exchange.result.is_some() {
31 sink.examined();
32 if !declared {
33 sink.push(
34 Some(exchange.response.seq),
35 format!(
36 "server answered {:?} without declaring the prompts capability",
37 exchange.method
38 ),
39 );
40 }
41 }
42 }
43}
44
45fn prompt_content_items<'a>(context: &TraceContext<'a>) -> Vec<(u64, &'a Value)> {
47 let mut items = Vec::new();
48 for exchange in context.exchanges_for("prompts/get") {
49 let messages = exchange
50 .result
51 .and_then(|result| result.get("messages"))
52 .and_then(Value::as_array);
53 for message in messages.into_iter().flatten() {
54 if let Some(content) = message.get("content") {
55 items.push((exchange.response.seq, content));
56 }
57 }
58 }
59 items
60}
61
62fn declared_required_arguments<'a>(
67 context: &TraceContext<'a>,
68) -> std::collections::BTreeMap<&'a str, Vec<&'a str>> {
69 let mut declared = std::collections::BTreeMap::new();
70 for exchange in context.exchanges_for("prompts/list") {
71 let prompts = exchange
72 .result
73 .and_then(|result| result.get("prompts"))
74 .and_then(Value::as_array);
75 for prompt in prompts.into_iter().flatten() {
76 let Some(name) = prompt.get("name").and_then(Value::as_str) else {
77 continue;
78 };
79 let required: Vec<&str> = prompt
80 .get("arguments")
81 .and_then(Value::as_array)
82 .into_iter()
83 .flatten()
84 .filter(|argument| argument.get("required").and_then(Value::as_bool) == Some(true))
85 .filter_map(|argument| argument.get("name").and_then(Value::as_str))
86 .collect();
87 if !required.is_empty() {
88 declared.insert(name, required);
90 }
91 }
92 }
93 declared
94}
95
96pub(super) fn arguments_validated(context: &TraceContext<'_>, sink: &mut FindingSink) {
115 let declared = declared_required_arguments(context);
116 if declared.is_empty() {
117 return;
118 }
119 for exchange in context.exchanges_for("prompts/get") {
120 if exchange.result.is_none() {
121 continue;
122 }
123 let Some(params) = exchange.params else {
124 continue;
125 };
126 let Some(name) = params.get("name").and_then(Value::as_str) else {
127 continue;
128 };
129 let Some(required) = declared.get(name) else {
130 continue;
131 };
132 sink.examined();
133 let supplied = params.get("arguments");
134 let missing: Vec<&str> = required
135 .iter()
136 .filter(|argument| {
137 supplied
138 .and_then(|arguments| arguments.get(*argument))
139 .is_none()
140 })
141 .copied()
142 .collect();
143 if missing.is_empty() {
144 continue;
145 }
146 sink.push(
147 Some(exchange.response.seq),
148 format!(
149 "server returned a result for prompts/get {name:?} although the request \
150 omitted the required argument(s) {missing:?} it declared in prompts/list"
151 ),
152 );
153 }
154}
155
156pub(super) fn image_content_encoding(context: &TraceContext<'_>, sink: &mut FindingSink) {
158 binary_content_encoding(context, sink, "image");
159}
160
161pub(super) fn audio_content_encoding(context: &TraceContext<'_>, sink: &mut FindingSink) {
163 binary_content_encoding(context, sink, "audio");
164}
165
166fn binary_content_encoding(context: &TraceContext<'_>, sink: &mut FindingSink, kind: &str) {
167 for (seq, content) in prompt_content_items(context) {
168 if content.get("type").and_then(Value::as_str) != Some(kind) {
169 continue;
170 }
171 sink.examined();
172 let data_valid = content
173 .get("data")
174 .and_then(Value::as_str)
175 .is_some_and(is_base64);
176 if !data_valid {
177 sink.push(
178 Some(seq),
179 format!("{kind} content data is not valid base64"),
180 );
181 }
182 let mime_present = content
183 .get("mimeType")
184 .and_then(Value::as_str)
185 .is_some_and(|mime| {
186 mime.split_once('/')
187 .is_some_and(|(t, s)| !t.is_empty() && !s.is_empty())
188 });
189 if !mime_present {
190 sink.push(
191 Some(seq),
192 format!("{kind} content lacks a valid mimeType (expected type/subtype)"),
193 );
194 }
195 }
196}
197
198pub(super) fn embedded_resource_shape(context: &TraceContext<'_>, sink: &mut FindingSink) {
201 for (seq, content) in prompt_content_items(context) {
202 if content.get("type").and_then(Value::as_str) != Some("resource") {
203 continue;
204 }
205 sink.examined();
206 let Some(resource) = content.get("resource") else {
207 sink.push(
208 Some(seq),
209 "embedded resource content lacks the resource member".to_owned(),
210 );
211 continue;
212 };
213 let uri_ok = resource
214 .get("uri")
215 .and_then(Value::as_str)
216 .is_some_and(has_rfc3986_scheme);
217 if !uri_ok {
218 sink.push(
219 Some(seq),
220 "embedded resource lacks a valid resource URI".to_owned(),
221 );
222 }
223 if resource.get("mimeType").and_then(Value::as_str).is_none() {
224 sink.push(Some(seq), "embedded resource lacks a mimeType".to_owned());
225 }
226 let text = resource.get("text").and_then(Value::as_str);
227 let blob = resource.get("blob").and_then(Value::as_str);
228 match (text, blob) {
229 (Some(_), None) => {}
230 (None, Some(blob)) if is_base64(blob) => {}
231 (None, Some(_)) => sink.push(
232 Some(seq),
233 "embedded resource blob is not valid base64".to_owned(),
234 ),
235 (Some(_), Some(_)) => sink.push(
236 Some(seq),
237 "embedded resource carries both text and blob; expected exactly one".to_owned(),
238 ),
239 (None, None) => sink.push(
240 Some(seq),
241 "embedded resource carries neither text nor blob data".to_owned(),
242 ),
243 }
244 }
245}
246
247#[cfg(test)]
248#[allow(clippy::unwrap_used)]
249mod tests {
250 use crate::checks;
251 use crate::context::TraceContext;
252 use crate::reader::{Limits, parse_trace};
253
254 fn findings_for(check: &str, trace: &str) -> Vec<String> {
255 let events = parse_trace(trace, &Limits::default()).unwrap();
256 let context = TraceContext::new(&events);
257 checks::find(check)
258 .unwrap()
259 .run(&context)
260 .findings
261 .into_iter()
262 .map(|finding| finding.detail)
263 .collect()
264 }
265
266 const HANDSHAKE: &str = 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"}}}}
267{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"prompts":{}},"serverInfo":{"name":"s","version":"0"}}}}
268{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#;
269
270 fn get_prompt_with_content(content: &str) -> String {
271 let request = r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"prompts/get","params":{"name":"p"}}}"#;
272 let result = format!(
273 r#"{{"seq":4,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{{"jsonrpc":"2.0","id":2,"result":{{"messages":[{{"role":"user","content":{content}}}]}}}}}}"#
274 );
275 format!("{HANDSHAKE}\n{request}\n{result}")
276 }
277
278 fn listed_then_called(arguments: &str, response: &str) -> String {
281 let list = r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"prompts/list"}}
282{"seq":4,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"result":{"prompts":[{"name":"review","arguments":[{"name":"diff","required":true},{"name":"tone","required":false}]}]}}}"#;
283 let get = format!(
284 r#"{{"seq":5,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{{"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{{"name":"review","arguments":{arguments}}}}}}}"#
285 );
286 format!("{HANDSHAKE}\n{list}\n{get}\n{response}")
287 }
288
289 const RESULT: &str = r#"{"seq":6,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"result":{"messages":[]}}}"#;
290 const ERROR: &str = r#"{"seq":6,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"error":{"code":-32602,"message":"missing required argument: diff"}}}"#;
291
292 #[test]
293 fn serving_a_result_despite_a_missing_required_argument_is_a_finding() {
294 let trace = listed_then_called(r#"{"tone":"terse"}"#, RESULT);
295 let findings = findings_for("prompts.arguments-validated", &trace);
296 assert_eq!(findings.len(), 1, "{findings:?}");
297 assert!(findings[0].contains("\"diff\""), "{findings:?}");
298 assert!(findings[0].contains("review"), "{findings:?}");
299 }
300
301 #[test]
302 fn supplying_the_required_argument_is_clean() {
303 let trace = listed_then_called(r#"{"diff":"--- a\n+++ b"}"#, RESULT);
304 assert!(findings_for("prompts.arguments-validated", &trace).is_empty());
305 }
306
307 #[test]
308 fn an_omitted_optional_argument_is_not_a_finding() {
309 let trace = listed_then_called(r#"{"diff":"d"}"#, RESULT);
311 assert!(findings_for("prompts.arguments-validated", &trace).is_empty());
312 }
313
314 #[test]
315 fn rejecting_the_call_satisfies_the_clause() {
316 let trace = listed_then_called(r#"{"tone":"terse"}"#, ERROR);
319 assert!(findings_for("prompts.arguments-validated", &trace).is_empty());
320 }
321
322 #[test]
323 fn a_prompt_never_listed_is_not_judged() {
324 let get = r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"prompts/get","params":{"name":"unlisted"}}}"#;
327 let result = r#"{"seq":4,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"result":{"messages":[]}}}"#;
328 let trace = format!("{HANDSHAKE}\n{get}\n{result}");
329 assert!(findings_for("prompts.arguments-validated", &trace).is_empty());
330 }
331
332 #[test]
333 fn a_missing_arguments_object_entirely_is_still_a_finding() {
334 let get = r#"{"seq":5,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"review"}}}"#;
337 let list = r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"prompts/list"}}
338{"seq":4,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"result":{"prompts":[{"name":"review","arguments":[{"name":"diff","required":true}]}]}}}"#;
339 let trace = format!("{HANDSHAKE}\n{list}\n{get}\n{RESULT}");
340 assert_eq!(findings_for("prompts.arguments-validated", &trace).len(), 1);
341 }
342
343 #[test]
344 fn image_and_audio_checks_are_type_scoped() {
345 let trace = get_prompt_with_content(
347 r#"{"type":"audio","data":"not base64!","mimeType":"audio/wav"}"#,
348 );
349 assert!(findings_for("prompts.image-content-encoding", &trace).is_empty());
350 let findings = findings_for("prompts.audio-content-encoding", &trace);
351 assert_eq!(findings.len(), 1, "{findings:?}");
352 assert!(findings[0].contains("audio content data"), "{findings:?}");
353 }
354
355 #[test]
356 fn mime_type_must_be_type_slash_subtype() {
357 for (mime, expect_finding) in [
358 (r#""image/png""#, false),
359 (r#""image/""#, true),
360 (r#""png""#, true),
361 ("42", true),
362 ] {
363 let trace = get_prompt_with_content(&format!(
364 r#"{{"type":"image","data":"QUJDRA==","mimeType":{mime}}}"#
365 ));
366 let findings = findings_for("prompts.image-content-encoding", &trace);
367 assert_eq!(!findings.is_empty(), expect_finding, "{mime}: {findings:?}");
368 }
369 }
370
371 #[test]
372 fn embedded_resource_shape_flags_each_defect_once() {
373 let trace = get_prompt_with_content(
374 r#"{"type":"resource","resource":{"uri":"no scheme","text":"x","blob":"QUJDRA=="}}"#,
375 );
376 let findings = findings_for("prompts.embedded-resource-shape", &trace);
377 assert_eq!(findings.len(), 3, "{findings:?}");
379 }
380
381 #[test]
382 fn well_formed_embedded_resource_passes() {
383 let trace = get_prompt_with_content(
384 r#"{"type":"resource","resource":{"uri":"file:///a.txt","mimeType":"text/plain","text":"hello"}}"#,
385 );
386 assert!(findings_for("prompts.embedded-resource-shape", &trace).is_empty());
387 }
388
389 #[test]
390 fn blob_only_embedded_resources_hinge_on_base64_validity() {
391 let valid = get_prompt_with_content(
393 r#"{"type":"resource","resource":{"uri":"file:///a.png","mimeType":"image/png","blob":"QUJDRA=="}}"#,
394 );
395 assert!(findings_for("prompts.embedded-resource-shape", &valid).is_empty());
396
397 let invalid = get_prompt_with_content(
399 r#"{"type":"resource","resource":{"uri":"file:///a.png","mimeType":"image/png","blob":"not base64!"}}"#,
400 );
401 let findings = findings_for("prompts.embedded-resource-shape", &invalid);
402 assert_eq!(findings.len(), 1, "{findings:?}");
403 assert!(findings[0].contains("not valid base64"), "{findings:?}");
404 }
405}