mcp_trace_validator/checks/
prompts.rs1use serde_json::Value;
11
12use super::FindingSink;
13use super::support::{has_rfc3986_scheme, is_base64, server_capability};
14use crate::context::TraceContext;
15
16pub(super) fn capability_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
20 if server_capability(context, &["prompts"]) != Some(false) {
21 return;
22 }
23 for exchange in context.exchanges() {
24 if matches!(exchange.method, "prompts/list" | "prompts/get") && exchange.result.is_some() {
25 sink.push(
26 Some(exchange.response.seq),
27 format!(
28 "server answered {:?} without declaring the prompts capability",
29 exchange.method
30 ),
31 );
32 }
33 }
34}
35
36fn prompt_content_items<'a>(context: &TraceContext<'a>) -> Vec<(u64, &'a Value)> {
38 let mut items = Vec::new();
39 for exchange in context.exchanges_for("prompts/get") {
40 let messages = exchange
41 .result
42 .and_then(|result| result.get("messages"))
43 .and_then(Value::as_array);
44 for message in messages.into_iter().flatten() {
45 if let Some(content) = message.get("content") {
46 items.push((exchange.response.seq, content));
47 }
48 }
49 }
50 items
51}
52
53pub(super) fn image_content_encoding(context: &TraceContext<'_>, sink: &mut FindingSink) {
55 binary_content_encoding(context, sink, "image");
56}
57
58pub(super) fn audio_content_encoding(context: &TraceContext<'_>, sink: &mut FindingSink) {
60 binary_content_encoding(context, sink, "audio");
61}
62
63fn binary_content_encoding(context: &TraceContext<'_>, sink: &mut FindingSink, kind: &str) {
64 for (seq, content) in prompt_content_items(context) {
65 if content.get("type").and_then(Value::as_str) != Some(kind) {
66 continue;
67 }
68 let data_valid = content
69 .get("data")
70 .and_then(Value::as_str)
71 .is_some_and(is_base64);
72 if !data_valid {
73 sink.push(
74 Some(seq),
75 format!("{kind} content data is not valid base64"),
76 );
77 }
78 let mime_present = content
79 .get("mimeType")
80 .and_then(Value::as_str)
81 .is_some_and(|mime| {
82 mime.split_once('/')
83 .is_some_and(|(t, s)| !t.is_empty() && !s.is_empty())
84 });
85 if !mime_present {
86 sink.push(
87 Some(seq),
88 format!("{kind} content lacks a valid mimeType (expected type/subtype)"),
89 );
90 }
91 }
92}
93
94pub(super) fn embedded_resource_shape(context: &TraceContext<'_>, sink: &mut FindingSink) {
97 for (seq, content) in prompt_content_items(context) {
98 if content.get("type").and_then(Value::as_str) != Some("resource") {
99 continue;
100 }
101 let Some(resource) = content.get("resource") else {
102 sink.push(
103 Some(seq),
104 "embedded resource content lacks the resource member".to_owned(),
105 );
106 continue;
107 };
108 let uri_ok = resource
109 .get("uri")
110 .and_then(Value::as_str)
111 .is_some_and(has_rfc3986_scheme);
112 if !uri_ok {
113 sink.push(
114 Some(seq),
115 "embedded resource lacks a valid resource URI".to_owned(),
116 );
117 }
118 if resource.get("mimeType").and_then(Value::as_str).is_none() {
119 sink.push(Some(seq), "embedded resource lacks a mimeType".to_owned());
120 }
121 let text = resource.get("text").and_then(Value::as_str);
122 let blob = resource.get("blob").and_then(Value::as_str);
123 match (text, blob) {
124 (Some(_), None) => {}
125 (None, Some(blob)) if is_base64(blob) => {}
126 (None, Some(_)) => sink.push(
127 Some(seq),
128 "embedded resource blob is not valid base64".to_owned(),
129 ),
130 (Some(_), Some(_)) => sink.push(
131 Some(seq),
132 "embedded resource carries both text and blob; expected exactly one".to_owned(),
133 ),
134 (None, None) => sink.push(
135 Some(seq),
136 "embedded resource carries neither text nor blob data".to_owned(),
137 ),
138 }
139 }
140}
141
142#[cfg(test)]
143#[allow(clippy::unwrap_used)]
144mod tests {
145 use crate::checks;
146 use crate::context::TraceContext;
147 use crate::reader::{Limits, parse_trace};
148
149 fn findings_for(check: &str, trace: &str) -> Vec<String> {
150 let events = parse_trace(trace, &Limits::default()).unwrap();
151 let context = TraceContext::new(&events);
152 checks::find(check)
153 .unwrap()
154 .run(&context)
155 .into_iter()
156 .map(|finding| finding.detail)
157 .collect()
158 }
159
160 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"}}}}
161{"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"}}}}
162{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#;
163
164 fn get_prompt_with_content(content: &str) -> String {
165 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"}}}"#;
166 let result = format!(
167 r#"{{"seq":4,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{{"jsonrpc":"2.0","id":2,"result":{{"messages":[{{"role":"user","content":{content}}}]}}}}}}"#
168 );
169 format!("{HANDSHAKE}\n{request}\n{result}")
170 }
171
172 #[test]
173 fn image_and_audio_checks_are_type_scoped() {
174 let trace = get_prompt_with_content(
176 r#"{"type":"audio","data":"not base64!","mimeType":"audio/wav"}"#,
177 );
178 assert!(findings_for("prompts.image-content-encoding", &trace).is_empty());
179 let findings = findings_for("prompts.audio-content-encoding", &trace);
180 assert_eq!(findings.len(), 1, "{findings:?}");
181 assert!(findings[0].contains("audio content data"), "{findings:?}");
182 }
183
184 #[test]
185 fn mime_type_must_be_type_slash_subtype() {
186 for (mime, expect_finding) in [
187 (r#""image/png""#, false),
188 (r#""image/""#, true),
189 (r#""png""#, true),
190 ("42", true),
191 ] {
192 let trace = get_prompt_with_content(&format!(
193 r#"{{"type":"image","data":"QUJDRA==","mimeType":{mime}}}"#
194 ));
195 let findings = findings_for("prompts.image-content-encoding", &trace);
196 assert_eq!(!findings.is_empty(), expect_finding, "{mime}: {findings:?}");
197 }
198 }
199
200 #[test]
201 fn embedded_resource_shape_flags_each_defect_once() {
202 let trace = get_prompt_with_content(
203 r#"{"type":"resource","resource":{"uri":"no scheme","text":"x","blob":"QUJDRA=="}}"#,
204 );
205 let findings = findings_for("prompts.embedded-resource-shape", &trace);
206 assert_eq!(findings.len(), 3, "{findings:?}");
208 }
209
210 #[test]
211 fn well_formed_embedded_resource_passes() {
212 let trace = get_prompt_with_content(
213 r#"{"type":"resource","resource":{"uri":"file:///a.txt","mimeType":"text/plain","text":"hello"}}"#,
214 );
215 assert!(findings_for("prompts.embedded-resource-shape", &trace).is_empty());
216 }
217
218 #[test]
219 fn blob_only_embedded_resources_hinge_on_base64_validity() {
220 let valid = get_prompt_with_content(
222 r#"{"type":"resource","resource":{"uri":"file:///a.png","mimeType":"image/png","blob":"QUJDRA=="}}"#,
223 );
224 assert!(findings_for("prompts.embedded-resource-shape", &valid).is_empty());
225
226 let invalid = get_prompt_with_content(
228 r#"{"type":"resource","resource":{"uri":"file:///a.png","mimeType":"image/png","blob":"not base64!"}}"#,
229 );
230 let findings = findings_for("prompts.embedded-resource-shape", &invalid);
231 assert_eq!(findings.len(), 1, "{findings:?}");
232 assert!(findings[0].contains("not valid base64"), "{findings:?}");
233 }
234}