Skip to main content

mcp_trace_validator/checks/
resources.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! Checks for the `2025-11-25` resources requirements (`RES-*`).
5//!
6//! Evidence comes from `resources/list`, `resources/templates/list`, and
7//! `resources/read` exchanges plus subscription traffic. URI-template strings
8//! (`uriTemplate`) are RFC 6570 templates, not URIs, and are deliberately outside the
9//! RFC 3986 scheme check.
10
11use serde_json::Value;
12
13use super::FindingSink;
14use super::support::{has_rfc3986_scheme, is_base64, server_capability};
15use crate::context::TraceContext;
16
17/// The resources-area request methods whose successful service evidences support.
18const RESOURCE_METHODS: &[&str] = &[
19    "resources/list",
20    "resources/templates/list",
21    "resources/read",
22    "resources/subscribe",
23    "resources/unsubscribe",
24];
25
26/// `RES-001`: "Servers that support resources MUST declare the `resources`
27/// capability:" — successfully serving resources traffic is the observable form of
28/// support.
29pub(super) fn capability_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
30    if server_capability(context, &["resources"]) != Some(false) {
31        return;
32    }
33    for exchange in context.exchanges() {
34        if RESOURCE_METHODS.contains(&exchange.method) && exchange.result.is_some() {
35            sink.push(
36                Some(exchange.response.seq),
37                format!(
38                    "server answered {:?} without declaring the resources capability",
39                    exchange.method
40                ),
41            );
42        }
43    }
44}
45
46/// Every URI the server stated for a resource, with the event `seq` it appeared at:
47/// `resources/list` result entries, `resources/read` result contents, and
48/// `notifications/resources/updated` params.
49fn server_stated_uris<'a>(context: &TraceContext<'a>) -> Vec<(u64, &'a str)> {
50    let mut uris = Vec::new();
51    for exchange in context.exchanges_for("resources/list") {
52        let entries = exchange
53            .result
54            .and_then(|result| result.get("resources"))
55            .and_then(Value::as_array);
56        for entry in entries.into_iter().flatten() {
57            if let Some(uri) = entry.get("uri").and_then(Value::as_str) {
58                uris.push((exchange.response.seq, uri));
59            }
60        }
61    }
62    for exchange in context.exchanges_for("resources/read") {
63        let contents = exchange
64            .result
65            .and_then(|result| result.get("contents"))
66            .and_then(Value::as_array);
67        for content in contents.into_iter().flatten() {
68            if let Some(uri) = content.get("uri").and_then(Value::as_str) {
69                uris.push((exchange.response.seq, uri));
70            }
71        }
72    }
73    uris
74}
75
76/// `RES-004`: URI schemes must follow RFC 3986 §3.1 syntax. Scheme syntax is the
77/// trace-judgeable core of "in accordance with RFC3986"; the registry quote carries
78/// the full clause.
79pub(super) fn uri_scheme_rfc3986(context: &TraceContext<'_>, sink: &mut FindingSink) {
80    for (seq, uri) in server_stated_uris(context) {
81        if !has_rfc3986_scheme(uri) {
82            sink.push(
83                Some(seq),
84                format!(
85                    "resource URI {uri:?} does not begin with an RFC 3986 scheme (ALPHA *( ALPHA / DIGIT / \"+\" / \"-\" / \".\" ) followed by \":\")"
86                ),
87            );
88        }
89    }
90}
91
92/// `RES-006`: "Binary data MUST be properly encoded" — every `blob` member in
93/// `resources/read` contents must be standard base64.
94pub(super) fn blob_base64(context: &TraceContext<'_>, sink: &mut FindingSink) {
95    for exchange in context.exchanges_for("resources/read") {
96        let contents = exchange
97            .result
98            .and_then(|result| result.get("contents"))
99            .and_then(Value::as_array);
100        for content in contents.into_iter().flatten() {
101            let Some(blob) = content.get("blob") else {
102                continue;
103            };
104            let valid = blob.as_str().is_some_and(is_base64);
105            if !valid {
106                let uri = content
107                    .get("uri")
108                    .and_then(Value::as_str)
109                    .unwrap_or("(no uri)");
110                sink.push(
111                    Some(exchange.response.seq),
112                    format!("resource {uri:?} carries a blob that is not valid base64"),
113                );
114            }
115        }
116    }
117}
118
119#[cfg(test)]
120#[allow(clippy::unwrap_used)]
121mod tests {
122    use crate::checks;
123    use crate::context::TraceContext;
124    use crate::reader::{Limits, parse_trace};
125
126    fn findings_for(check: &str, trace: &str) -> Vec<String> {
127        let events = parse_trace(trace, &Limits::default()).unwrap();
128        let context = TraceContext::new(&events);
129        checks::find(check)
130            .unwrap()
131            .run(&context)
132            .into_iter()
133            .map(|finding| finding.detail)
134            .collect()
135    }
136
137    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"}}}}
138{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"resources":{}},"serverInfo":{"name":"s","version":"0"}}}}
139{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#;
140
141    #[test]
142    fn scheme_check_reads_list_and_read_uris() {
143        let trace = format!(
144            "{HANDSHAKE}\n{}\n{}\n{}\n{}",
145            r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"resources/list"}}"#,
146            r#"{"seq":4,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"result":{"resources":[{"uri":"file:///ok.txt","name":"ok"},{"uri":"not a uri","name":"bad"}]}}}"#,
147            r#"{"seq":5,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"method":"resources/read","params":{"uri":"file:///ok.txt"}}}"#,
148            r#"{"seq":6,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"result":{"contents":[{"uri":"3http://x","text":"hi"}]}}}"#,
149        );
150        let findings = findings_for("resources.uri-scheme-rfc3986", &trace);
151        assert_eq!(findings.len(), 2, "{findings:?}");
152        assert!(findings[0].contains("not a uri"), "{findings:?}");
153        assert!(findings[1].contains("3http"), "{findings:?}");
154    }
155
156    #[test]
157    fn blob_check_flags_non_base64_and_non_string_blobs() {
158        let trace = format!(
159            "{HANDSHAKE}\n{}\n{}",
160            r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"file:///img.png"}}}"#,
161            r#"{"seq":4,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"result":{"contents":[{"uri":"file:///img.png","blob":"not base64!"},{"uri":"file:///n.png","blob":42},{"uri":"file:///ok.png","blob":"QUJDRA=="}]}}}"#,
162        );
163        let findings = findings_for("resources.blob-base64", &trace);
164        assert_eq!(findings.len(), 2, "{findings:?}");
165    }
166
167    #[test]
168    fn capability_check_needs_successful_service() {
169        let trace = format!(
170            "{}\n{}\n{}",
171            HANDSHAKE.replace(r#""resources":{}"#, r#""prompts":{}"#),
172            r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"resources/templates/list"}}"#,
173            r#"{"seq":4,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"result":{"resourceTemplates":[]}}}"#,
174        );
175        let findings = findings_for("resources.capability-declared", &trace);
176        assert_eq!(findings.len(), 1, "{findings:?}");
177        assert!(
178            findings[0].contains("resources/templates/list"),
179            "{findings:?}"
180        );
181    }
182}