use mcp_conformance_core::message::MessageKind;
use mcp_conformance_core::trace::Direction;
use super::super::FindingSink;
use crate::context::TraceContext;
#[cfg(test)]
mod tests;
const DISCOVER: &str = "server/discover";
fn declares(context: &TraceContext<'_>, capability: &str) -> Option<bool> {
let capabilities = context
.exchanges_for(DISCOVER)
.find_map(|exchange| exchange.result)?
.get("capabilities");
let Some(capabilities) = capabilities else {
return Some(false);
};
Some(
capabilities
.get(capability)
.is_some_and(|value| !value.is_null() && value.as_bool() != Some(false)),
)
}
fn answered_undeclared(
context: &TraceContext<'_>,
sink: &mut FindingSink,
capability: &str,
methods: &[&str],
) {
let Some(declared) = declares(context, capability) else {
return;
};
for method in methods {
for exchange in context.exchanges_for(method) {
if exchange.result.is_none() {
continue;
}
sink.examined();
if !declared {
sink.push(
Some(exchange.response.seq),
format!(
"server answered `{method}` while its `{DISCOVER}` result declared no \
`{capability}` capability"
),
);
}
}
}
}
pub(in crate::checks) fn completions_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
answered_undeclared(context, sink, "completions", &["completion/complete"]);
}
pub(in crate::checks) fn logging_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
let Some(declared) = declares(context, "logging") else {
return;
};
for (event, kind, _) in context.messages() {
if event.direction != Direction::ServerToClient {
continue;
}
if matches!(kind, MessageKind::Notification { method } if *method == "notifications/message")
{
sink.examined();
if !declared {
sink.push(
Some(event.seq),
format!(
"server emitted `notifications/message` while its `{DISCOVER}` result \
declared no `logging` capability"
),
);
}
}
}
}
const LIST_METHOD: &[(&str, &str)] = &[
("tools", "tools/list"),
("resources", "resources/list"),
("prompts", "prompts/list"),
];
fn declared_list_answered(context: &TraceContext<'_>, sink: &mut FindingSink, capability: &str) {
if declares(context, capability) != Some(true) {
return;
}
let Some((_, method)) = LIST_METHOD.iter().find(|(name, _)| *name == capability) else {
return;
};
for exchange in context.exchanges_for(method) {
sink.examined();
let code = exchange
.response
.message_payload()
.and_then(|payload| payload.get("error"))
.and_then(|error| error.get("code"))
.and_then(serde_json::Value::as_i64);
if code == Some(-32601) {
sink.push(
Some(exchange.response.seq),
format!(
"server declared the `{capability}` capability but answered `{method}` \
with -32601; a declared capability must be served"
),
);
}
}
}
pub(in crate::checks) fn tools_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
answered_undeclared(context, sink, "tools", &["tools/list", "tools/call"]);
}
pub(in crate::checks) fn resources_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
answered_undeclared(
context,
sink,
"resources",
&[
"resources/list",
"resources/templates/list",
"resources/read",
],
);
}
pub(in crate::checks) fn prompts_declared(context: &TraceContext<'_>, sink: &mut FindingSink) {
answered_undeclared(context, sink, "prompts", &["prompts/list", "prompts/get"]);
}
pub(in crate::checks) fn tools_list_implemented(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
declared_list_answered(context, sink, "tools");
}
pub(in crate::checks) fn resources_list_implemented(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
declared_list_answered(context, sink, "resources");
}
pub(in crate::checks) fn prompts_list_implemented(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
declared_list_answered(context, sink, "prompts");
}
pub(in crate::checks) fn embedded_resource_declared(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
let Some(declared) = declares(context, "resources") else {
return;
};
for exchange in context.exchanges_for("tools/call") {
let embedded = exchange
.result
.and_then(|result| result.get("content"))
.and_then(serde_json::Value::as_array)
.is_some_and(|blocks| {
blocks.iter().any(|block| {
block.get("type").and_then(serde_json::Value::as_str) == Some("resource")
})
});
if !embedded {
continue;
}
sink.examined();
if !declared {
sink.push(
Some(exchange.response.seq),
format!(
"a `tools/call` result embeds a resource while the `{DISCOVER}` result \
declared no `resources` capability"
),
);
}
}
}