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::{Declaration, 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    // The subject is an answered prompts exchange, declaration or not; a
21    // session that never touched prompts leaves this clause untested.
22    let declared = match server_capability(context, &["prompts"]) {
23        Declaration::Declared => true,
24        Declaration::Withheld => false,
25        // Nothing in this trace could have declared anything, so it shows
26        // neither compliance nor violation: abstain before counting a subject.
27        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
45/// The content items of every `prompts/get` result message, with their event `seq`.
46fn 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
62/// The required-argument names each `prompts/list` result declared, by prompt name.
63///
64/// The server's own declaration is the ground truth this check needs, and it is in
65/// the trace — which is the whole reason `PROM-008` is judgeable at all.
66fn 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                // A later listing wins: the server may re-declare its surface.
89                declared.insert(name, required);
90            }
91        }
92    }
93    declared
94}
95
96/// `PROM-008`: "Servers SHOULD validate prompt arguments before processing".
97///
98/// Validation *thoroughness* is internal, but validation *failure* is not: a server
99/// that answers a `prompts/get` with a successful result, when that request omits an
100/// argument the server itself published as `required` in `prompts/list`, demonstrably
101/// processed input it had declared invalid. Both halves of that comparison — the
102/// declaration and the call — are recorded messages, so no server-side knowledge is
103/// needed. This is the same cross-message correlation `LIFE-009` uses for capability
104/// gating and `PAGE-002` for cursors.
105///
106/// Deliberately narrow, because a conformance verdict is only worth its soundness:
107/// - It fires only on prompts whose required arguments were *observed* being declared;
108///   an unlisted prompt, or a session with no `prompts/list`, is not judged.
109/// - It fires only when the server returned a **result**. An error response means the
110///   server did reject the call, which satisfies this clause; whether that error
111///   carries exactly `-32602` is `PROM-007`'s enumeration, and that requirement stays
112///   excluded because its other two cases (invalid name, internal error) remain
113///   server-side ground truth.
114pub(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
156/// `PROM-003`: image content data must be base64 with a MIME type present.
157pub(super) fn image_content_encoding(context: &TraceContext<'_>, sink: &mut FindingSink) {
158    binary_content_encoding(context, sink, "image");
159}
160
161/// `PROM-004`: audio content data must be base64 with a MIME type present.
162pub(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
198/// `PROM-005`: embedded resources must include a valid resource URI, the appropriate
199/// MIME type, and either text or base64 blob data.
200pub(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    /// Builds a session that lists `review` with a required `diff` argument, then
279    /// issues `prompts/get` with `arguments` and gets `response` back.
280    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        // `tone` is declared `required: false`; only `diff` may be demanded.
310        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        // The server *did* validate — an error response is the compliant answer,
317        // so the check must stay silent even though the argument was missing.
318        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        // Without an observed declaration there is no ground truth, so a
325        // `prompts/get` for an unlisted prompt is outside the check's reach.
326        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        // Omitting `arguments` altogether must read the same as omitting the
335        // required key inside it.
336        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        // A bad *audio* item must not produce *image* findings, and vice versa.
346        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        // Bad URI, missing mimeType, and text+blob together: three findings.
378        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        // Valid blob, no text: well-formed, zero findings.
392        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        // Invalid blob, no text: exactly the base64 finding.
398        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}