mcp_trace_validator/checks/
resources.rs1use serde_json::Value;
12
13use super::FindingSink;
14use super::support::{Declaration, has_rfc3986_scheme, is_base64, server_capability};
15use crate::context::TraceContext;
16
17const RESOURCE_METHODS: &[&str] = &[
19 "resources/list",
20 "resources/templates/list",
21 "resources/read",
22 "resources/subscribe",
23 "resources/unsubscribe",
24];
25
26pub(super) fn capability_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
30 let declared = match server_capability(context, &["resources"]) {
33 Declaration::Declared => true,
34 Declaration::Withheld => false,
35 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
55fn 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
85pub(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
102pub(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}