mcp_trace_validator/checks/
resources.rs1use serde_json::Value;
12
13use super::FindingSink;
14use super::support::{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 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
46fn 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
76pub(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
92pub(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}