use std::collections::BTreeSet;
use mcp_conformance_core::message::MessageKind;
use mcp_conformance_core::revision::ProtocolRevision;
use mcp_conformance_core::trace::Direction;
use serde_json::Value;
use super::super::FindingSink;
use super::super::base::validate_meta_key;
use crate::context::TraceContext;
#[cfg(test)]
mod tests;
const UNSUPPORTED_VERSION: i64 = -32022;
const META_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion";
const META_CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities";
const INITIALIZE: &str = "initialize";
fn declared_version(payload: &Value) -> Option<&str> {
payload
.get("params")?
.get("_meta")?
.get(META_PROTOCOL_VERSION)?
.as_str()
}
fn supported_from_error(payload: &Value) -> Option<BTreeSet<String>> {
let error = payload.get("error")?;
if error.get("code")?.as_i64()? != UNSUPPORTED_VERSION {
return None;
}
let listed: BTreeSet<String> = error
.get("data")?
.get("supported")?
.as_array()?
.iter()
.filter_map(|version| version.as_str().map(str::to_owned))
.collect();
(!listed.is_empty()).then_some(listed)
}
pub(in crate::checks) fn retry_uses_supported_version(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
let mut supported: Option<(u64, BTreeSet<String>)> = None;
for (event, kind, _) in context.messages() {
let Some(payload) = event.message_payload() else {
continue;
};
match event.direction {
Direction::ServerToClient => {
if let Some(listed) = supported_from_error(payload) {
supported = Some((event.seq, listed));
}
}
Direction::ClientToServer => {
let (MessageKind::Request { .. }, Some((error_seq, listed))) =
(kind, supported.as_ref())
else {
continue;
};
let Some(requested) = declared_version(payload) else {
continue;
};
sink.examined();
if !listed.contains(requested) {
sink.push(
Some(event.seq),
format!(
"the request declares protocol version {requested:?}, which the \
`supported` list in the {UNSUPPORTED_VERSION} at seq {error_seq} \
does not offer ({})",
listed
.iter()
.map(|version| format!("{version:?}"))
.collect::<Vec<_>>()
.join(", ")
),
);
}
}
}
}
}
fn extension_identifiers<'a>(
context: &'a TraceContext<'_>,
) -> Vec<(u64, &'static str, &'a String)> {
let mut out = Vec::new();
let mut push = |seq, surface, capabilities: Option<&'a Value>| {
let extensions = capabilities
.and_then(|capabilities| capabilities.get("extensions"))
.and_then(Value::as_object);
if let Some(extensions) = extensions {
out.extend(extensions.keys().map(|id| (seq, surface, id)));
}
};
for (event, kind, _) in context.messages() {
let Some(payload) = event.message_payload() else {
continue;
};
match (event.direction, kind) {
(
Direction::ClientToServer,
MessageKind::Request { .. } | MessageKind::Notification { .. },
) => push(
event.seq,
"client capabilities",
payload
.get("params")
.and_then(|params| params.get("_meta"))
.and_then(|meta| meta.get(META_CLIENT_CAPABILITIES)),
),
(Direction::ServerToClient, MessageKind::Result { .. }) => push(
event.seq,
"server capabilities",
payload
.get("result")
.and_then(|result| result.get("capabilities")),
),
_ => {}
}
}
out
}
pub(in crate::checks) fn extension_identifier_format(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
for (seq, surface, identifier) in extension_identifiers(context) {
sink.examined();
if let Err(reason) = validate_meta_key(identifier) {
sink.push(
Some(seq),
format!("{surface} extension identifier {identifier:?} {reason}"),
);
} else if !identifier.contains('/') {
sink.push(
Some(seq),
format!(
"{surface} extension identifier {identifier:?} has no prefix; the prefix \
is optional in a `_meta` key but mandatory for an extension identifier"
),
);
}
}
}
fn names_a_revision(value: &Value) -> bool {
match value {
Value::String(text) => contains_revision(text),
Value::Array(items) => items.iter().any(names_a_revision),
Value::Object(members) => members.values().any(names_a_revision),
_ => false,
}
}
fn contains_revision(text: &str) -> bool {
(0..text.len()).any(|start| {
text.get(start..start + 10)
.is_some_and(|window| window.parse::<ProtocolRevision>().is_ok())
})
}
pub(in crate::checks) fn initialize_error_names_versions(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
for exchange in context.exchanges_for(INITIALIZE) {
if exchange.request.direction != Direction::ClientToServer {
continue;
}
let Some(error) = exchange
.response
.message_payload()
.and_then(|payload| payload.get("error"))
else {
continue;
};
sink.examined();
if !names_a_revision(error) {
sink.push(
Some(exchange.response.seq),
"the error refusing `initialize` names no protocol version, leaving a legacy \
client — which has no fall-forward mechanism — nothing to surface"
.to_owned(),
);
}
}
}