Skip to main content

mcp_trace_validator/checks/
prompts.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! Checks for the `2025-11-25` prompts requirements (`PROM-*`).
5//!
6//! Content-shape evidence comes from `prompts/get` results: image and audio content
7//! items must carry base64 data with a MIME type, and embedded resources must carry a
8//! URI, a MIME type, and exactly one of text or blob.
9
10use serde_json::Value;
11
12use super::FindingSink;
13use super::support::{has_rfc3986_scheme, is_base64, server_capability};
14use crate::context::TraceContext;
15
16/// `PROM-001`: "Servers that support prompts MUST declare the `prompts` capability
17/// during initialization:" — successfully serving prompts traffic is the observable
18/// form of support.
19pub(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
36/// The content items of every `prompts/get` result message, with their event `seq`.
37fn 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
53/// `PROM-003`: image content data must be base64 with a MIME type present.
54pub(super) fn image_content_encoding(context: &TraceContext<'_>, sink: &mut FindingSink) {
55    binary_content_encoding(context, sink, "image");
56}
57
58/// `PROM-004`: audio content data must be base64 with a MIME type present.
59pub(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
94/// `PROM-005`: embedded resources must include a valid resource URI, the appropriate
95/// MIME type, and either text or base64 blob data.
96pub(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        // A bad *audio* item must not produce *image* findings, and vice versa.
175        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        // Bad URI, missing mimeType, and text+blob together: three findings.
207        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        // Valid blob, no text: well-formed, zero findings.
221        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        // Invalid blob, no text: exactly the base64 finding.
227        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}