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::{Declaration, 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    // The subject is an answered resources exchange, declaration or not; a
31    // session that never touched resources leaves this clause untested.
32    let declared = match server_capability(context, &["resources"]) {
33        Declaration::Declared => true,
34        Declaration::Withheld => false,
35        // Nothing in this trace could have declared anything, so it shows
36        // neither compliance nor violation: abstain before counting a subject.
37        Declaration::Unknowable => return,
38    };
39    for exchange in context.exchanges() {
40        if RESOURCE_METHODS.contains(&exchange.method) && exchange.result.is_some() {
41            sink.examined();
42            if !declared {
43                sink.push(
44                    Some(exchange.response.seq),
45                    format!(
46                        "server answered {:?} without declaring the resources capability",
47                        exchange.method
48                    ),
49                );
50            }
51        }
52    }
53}
54
55/// Every URI the server stated for a resource, with the event `seq` it appeared at:
56/// `resources/list` result entries, `resources/read` result contents, and
57/// `notifications/resources/updated` params.
58fn server_stated_uris<'a>(context: &TraceContext<'a>) -> Vec<(u64, &'a str)> {
59    let mut uris = Vec::new();
60    for exchange in context.exchanges_for("resources/list") {
61        let entries = exchange
62            .result
63            .and_then(|result| result.get("resources"))
64            .and_then(Value::as_array);
65        for entry in entries.into_iter().flatten() {
66            if let Some(uri) = entry.get("uri").and_then(Value::as_str) {
67                uris.push((exchange.response.seq, uri));
68            }
69        }
70    }
71    for exchange in context.exchanges_for("resources/read") {
72        let contents = exchange
73            .result
74            .and_then(|result| result.get("contents"))
75            .and_then(Value::as_array);
76        for content in contents.into_iter().flatten() {
77            if let Some(uri) = content.get("uri").and_then(Value::as_str) {
78                uris.push((exchange.response.seq, uri));
79            }
80        }
81    }
82    uris
83}
84
85/// `RES-004`: URI schemes must follow RFC 3986 §3.1 syntax. Scheme syntax is the
86/// trace-judgeable core of "in accordance with RFC3986"; the registry quote carries
87/// the full clause.
88pub(super) fn uri_scheme_rfc3986(context: &TraceContext<'_>, sink: &mut FindingSink) {
89    for (seq, uri) in server_stated_uris(context) {
90        sink.examined();
91        if !has_rfc3986_scheme(uri) {
92            sink.push(
93                Some(seq),
94                format!(
95                    "resource URI {uri:?} does not begin with an RFC 3986 scheme (ALPHA *( ALPHA / DIGIT / \"+\" / \"-\" / \".\" ) followed by \":\")"
96                ),
97            );
98        }
99    }
100}
101
102/// `RES-006`: "Binary data MUST be properly encoded" — every `blob` member in
103/// `resources/read` contents must be standard base64.
104pub(super) fn blob_base64(context: &TraceContext<'_>, sink: &mut FindingSink) {
105    for exchange in context.exchanges_for("resources/read") {
106        let contents = exchange
107            .result
108            .and_then(|result| result.get("contents"))
109            .and_then(Value::as_array);
110        for content in contents.into_iter().flatten() {
111            let Some(blob) = content.get("blob") else {
112                continue;
113            };
114            sink.examined();
115            let valid = blob.as_str().is_some_and(is_base64);
116            if !valid {
117                let uri = content
118                    .get("uri")
119                    .and_then(Value::as_str)
120                    .unwrap_or("(no uri)");
121                sink.push(
122                    Some(exchange.response.seq),
123                    format!("resource {uri:?} carries a blob that is not valid base64"),
124                );
125            }
126        }
127    }
128}
129
130#[cfg(test)]
131#[allow(clippy::unwrap_used)]
132mod tests {
133    use crate::checks;
134    use crate::context::TraceContext;
135    use crate::reader::{Limits, parse_trace};
136
137    fn findings_for(check: &str, trace: &str) -> Vec<String> {
138        let events = parse_trace(trace, &Limits::default()).unwrap();
139        let context = TraceContext::new(&events);
140        checks::find(check)
141            .unwrap()
142            .run(&context)
143            .findings
144            .into_iter()
145            .map(|finding| finding.detail)
146            .collect()
147    }
148
149    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"}}}}
150{"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"}}}}
151{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#;
152
153    #[test]
154    fn scheme_check_reads_list_and_read_uris() {
155        let trace = format!(
156            "{HANDSHAKE}\n{}\n{}\n{}\n{}",
157            r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"resources/list"}}"#,
158            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"}]}}}"#,
159            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"}}}"#,
160            r#"{"seq":6,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"result":{"contents":[{"uri":"3http://x","text":"hi"}]}}}"#,
161        );
162        let findings = findings_for("resources.uri-scheme-rfc3986", &trace);
163        assert_eq!(findings.len(), 2, "{findings:?}");
164        assert!(findings[0].contains("not a uri"), "{findings:?}");
165        assert!(findings[1].contains("3http"), "{findings:?}");
166    }
167
168    #[test]
169    fn blob_check_flags_non_base64_and_non_string_blobs() {
170        let trace = format!(
171            "{HANDSHAKE}\n{}\n{}",
172            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"}}}"#,
173            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=="}]}}}"#,
174        );
175        let findings = findings_for("resources.blob-base64", &trace);
176        assert_eq!(findings.len(), 2, "{findings:?}");
177    }
178
179    #[test]
180    fn capability_check_needs_successful_service() {
181        let trace = format!(
182            "{}\n{}\n{}",
183            HANDSHAKE.replace(r#""resources":{}"#, r#""prompts":{}"#),
184            r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"resources/templates/list"}}"#,
185            r#"{"seq":4,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"result":{"resourceTemplates":[]}}}"#,
186        );
187        let findings = findings_for("resources.capability-declared", &trace);
188        assert_eq!(findings.len(), 1, "{findings:?}");
189        assert!(
190            findings[0].contains("resources/templates/list"),
191            "{findings:?}"
192        );
193    }
194}