use serde_json::Value;
use super::FindingSink;
use super::support::{Declaration, has_rfc3986_scheme, is_base64, server_capability};
use crate::context::TraceContext;
const RESOURCE_METHODS: &[&str] = &[
"resources/list",
"resources/templates/list",
"resources/read",
"resources/subscribe",
"resources/unsubscribe",
];
pub(super) fn capability_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
let declared = match server_capability(context, &["resources"]) {
Declaration::Declared => true,
Declaration::Withheld => false,
Declaration::Unknowable => return,
};
for exchange in context.exchanges() {
if RESOURCE_METHODS.contains(&exchange.method) && exchange.result.is_some() {
sink.examined();
if !declared {
sink.push(
Some(exchange.response.seq),
format!(
"server answered {:?} without declaring the resources capability",
exchange.method
),
);
}
}
}
}
fn server_stated_uris<'a>(context: &TraceContext<'a>) -> Vec<(u64, &'a str)> {
let mut uris = Vec::new();
for exchange in context.exchanges_for("resources/list") {
let entries = exchange
.result
.and_then(|result| result.get("resources"))
.and_then(Value::as_array);
for entry in entries.into_iter().flatten() {
if let Some(uri) = entry.get("uri").and_then(Value::as_str) {
uris.push((exchange.response.seq, uri));
}
}
}
for exchange in context.exchanges_for("resources/read") {
let contents = exchange
.result
.and_then(|result| result.get("contents"))
.and_then(Value::as_array);
for content in contents.into_iter().flatten() {
if let Some(uri) = content.get("uri").and_then(Value::as_str) {
uris.push((exchange.response.seq, uri));
}
}
}
uris
}
pub(super) fn uri_scheme_rfc3986(context: &TraceContext<'_>, sink: &mut FindingSink) {
for (seq, uri) in server_stated_uris(context) {
sink.examined();
if !has_rfc3986_scheme(uri) {
sink.push(
Some(seq),
format!(
"resource URI {uri:?} does not begin with an RFC 3986 scheme (ALPHA *( ALPHA / DIGIT / \"+\" / \"-\" / \".\" ) followed by \":\")"
),
);
}
}
}
pub(super) fn blob_base64(context: &TraceContext<'_>, sink: &mut FindingSink) {
for exchange in context.exchanges_for("resources/read") {
let contents = exchange
.result
.and_then(|result| result.get("contents"))
.and_then(Value::as_array);
for content in contents.into_iter().flatten() {
let Some(blob) = content.get("blob") else {
continue;
};
sink.examined();
let valid = blob.as_str().is_some_and(is_base64);
if !valid {
let uri = content
.get("uri")
.and_then(Value::as_str)
.unwrap_or("(no uri)");
sink.push(
Some(exchange.response.seq),
format!("resource {uri:?} carries a blob that is not valid base64"),
);
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use crate::checks;
use crate::context::TraceContext;
use crate::reader::{Limits, parse_trace};
fn findings_for(check: &str, trace: &str) -> Vec<String> {
let events = parse_trace(trace, &Limits::default()).unwrap();
let context = TraceContext::new(&events);
checks::find(check)
.unwrap()
.run(&context)
.findings
.into_iter()
.map(|finding| finding.detail)
.collect()
}
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"}}}}
{"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"}}}}
{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#;
#[test]
fn scheme_check_reads_list_and_read_uris() {
let trace = format!(
"{HANDSHAKE}\n{}\n{}\n{}\n{}",
r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"resources/list"}}"#,
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"}]}}}"#,
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"}}}"#,
r#"{"seq":6,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"result":{"contents":[{"uri":"3http://x","text":"hi"}]}}}"#,
);
let findings = findings_for("resources.uri-scheme-rfc3986", &trace);
assert_eq!(findings.len(), 2, "{findings:?}");
assert!(findings[0].contains("not a uri"), "{findings:?}");
assert!(findings[1].contains("3http"), "{findings:?}");
}
#[test]
fn blob_check_flags_non_base64_and_non_string_blobs() {
let trace = format!(
"{HANDSHAKE}\n{}\n{}",
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"}}}"#,
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=="}]}}}"#,
);
let findings = findings_for("resources.blob-base64", &trace);
assert_eq!(findings.len(), 2, "{findings:?}");
}
#[test]
fn capability_check_needs_successful_service() {
let trace = format!(
"{}\n{}\n{}",
HANDSHAKE.replace(r#""resources":{}"#, r#""prompts":{}"#),
r#"{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"resources/templates/list"}}"#,
r#"{"seq":4,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"result":{"resourceTemplates":[]}}}"#,
);
let findings = findings_for("resources.capability-declared", &trace);
assert_eq!(findings.len(), 1, "{findings:?}");
assert!(
findings[0].contains("resources/templates/list"),
"{findings:?}"
);
}
}