use std::collections::BTreeSet;
use std::str::FromStr as _;
use mcp_conformance_core::requirement::RegistrySet;
use mcp_conformance_core::revision::ProtocolRevision;
use mcp_conformance_core::trace::{EventBody, TraceEvent};
use serde_json::Value;
const META_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion";
const PROTOCOL_VERSION_HEADER: &str = "mcp-protocol-version";
#[must_use]
pub fn declared_revisions(events: &[TraceEvent]) -> Vec<String> {
collect(events)
.into_iter()
.map(|revision| revision.to_string())
.collect()
}
fn collect(events: &[TraceEvent]) -> BTreeSet<ProtocolRevision> {
let refused = refused_request_ids(events);
let mut found: BTreeSet<ProtocolRevision> = BTreeSet::new();
let mut pending_header: Option<&str> = None;
for event in events {
match &event.body {
EventBody::Message { payload } => {
if is_refused(payload, &refused) {
pending_header = None;
continue;
}
collect_from_message(payload, &mut found);
if let Some(value) = pending_header.take() {
insert(value, &mut found);
}
}
EventBody::Http { headers, .. } => {
if let Some(value) = pending_header.take() {
insert(value, &mut found);
}
pending_header = headers.get(PROTOCOL_VERSION_HEADER).map(String::as_str);
}
_ => {}
}
}
if let Some(value) = pending_header {
insert(value, &mut found);
}
found
}
fn refused_request_ids(events: &[TraceEvent]) -> BTreeSet<String> {
events
.iter()
.filter_map(|event| event.message_payload())
.filter(|payload| payload.get("error").is_some())
.filter_map(|payload| payload.get("id"))
.map(ToString::to_string)
.collect()
}
fn is_refused(payload: &Value, refused: &BTreeSet<String>) -> bool {
payload.get("method").is_some()
&& payload
.get("id")
.is_some_and(|id| refused.contains(&id.to_string()))
}
#[must_use]
pub fn mismatch(registry_revision: ProtocolRevision, events: &[TraceEvent]) -> Option<Vec<String>> {
let declared = collect(events);
if declared.is_empty() || declared.contains(®istry_revision) {
return None;
}
Some(
declared
.into_iter()
.map(|revision| revision.to_string())
.collect(),
)
}
#[must_use]
pub fn mismatch_any(
registry_revisions: &[ProtocolRevision],
events: &[TraceEvent],
) -> Option<Vec<String>> {
let declared = collect(events);
if declared.is_empty()
|| registry_revisions
.iter()
.any(|revision| declared.contains(revision))
{
return None;
}
Some(
declared
.into_iter()
.map(|revision| revision.to_string())
.collect(),
)
}
fn collect_from_message(payload: &Value, found: &mut BTreeSet<ProtocolRevision>) {
for envelope in [payload.get("params"), payload.get("result")]
.into_iter()
.flatten()
{
if let Some(Value::String(version)) = envelope.get("protocolVersion") {
insert(version, found);
}
if let Some(Value::String(version)) = envelope
.get("_meta")
.and_then(|meta| meta.get(META_PROTOCOL_VERSION))
{
insert(version, found);
}
}
}
fn insert(value: &str, found: &mut BTreeSet<ProtocolRevision>) {
if let Ok(revision) = ProtocolRevision::from_str(value)
&& is_known(revision)
{
found.insert(revision);
}
}
fn is_known(revision: ProtocolRevision) -> bool {
RegistrySet::builtin().is_ok_and(|set| set.revisions().contains(&revision))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::reader::{Limits, parse_trace};
fn events(document: &str) -> Vec<TraceEvent> {
parse_trace(document, &Limits::default()).unwrap()
}
fn rev(revision: &str) -> ProtocolRevision {
revision.parse().unwrap()
}
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":{},"serverInfo":{"name":"s","version":"0"}}}}"#;
#[test]
fn the_handshake_states_the_revision_from_both_ends() {
assert_eq!(declared_revisions(&events(HANDSHAKE)), ["2025-11-25"]);
assert!(mismatch(rev("2025-11-25"), &events(HANDSHAKE)).is_none());
}
#[test]
#[cfg(feature = "draft-2026-07-28")]
fn a_stateless_session_states_it_per_request() {
let document = r#"{"seq":0,"direction":"client-to-server","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}}"#;
assert_eq!(declared_revisions(&events(document)), ["2026-07-28"]);
assert_eq!(
mismatch(rev("2025-11-25"), &events(document)),
Some(vec!["2026-07-28".to_owned()])
);
}
#[test]
fn the_http_header_states_it_too() {
let document = r#"{"seq":0,"direction":"client-to-server","transport":"streamable-http","kind":"http","method":"POST","headers":{"mcp-protocol-version":"2025-11-25"}}"#;
assert_eq!(declared_revisions(&events(document)), ["2025-11-25"]);
}
#[test]
#[cfg(feature = "draft-2026-07-28")]
fn a_session_that_touched_the_registrys_revision_draws_no_note() {
let document = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","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":{},"serverInfo":{"name":"s","version":"0"}}}}"#;
assert_eq!(
declared_revisions(&events(document)),
["2025-11-25", "2026-07-28"]
);
assert!(mismatch(rev("2025-11-25"), &events(document)).is_none());
assert!(mismatch(rev("2026-07-28"), &events(document)).is_none());
}
#[test]
fn a_session_that_declares_nothing_draws_no_note() {
let document = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"tools/list"}}"#;
assert!(declared_revisions(&events(document)).is_empty());
assert!(mismatch(rev("2025-11-25"), &events(document)).is_none());
}
#[test]
fn a_malformed_version_is_not_evidence_of_a_revision() {
let document = r#"{"seq":0,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"draft","capabilities":{},"serverInfo":{"name":"s","version":"0"}}}}"#;
assert!(declared_revisions(&events(document)).is_empty());
assert!(mismatch(rev("2025-11-25"), &events(document)).is_none());
}
#[test]
fn a_version_this_build_cannot_judge_is_not_a_declaration() {
let document = r#"{"seq":0,"direction":"client-to-server","transport":"streamable-http","kind":"http","method":"POST","headers":{"mcp-protocol-version":"1900-01-01"}}
{"seq":1,"direction":"client-to-server","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"1900-01-01"}}}}"#;
assert!(declared_revisions(&events(document)).is_empty());
assert!(mismatch(rev("2025-11-25"), &events(document)).is_none());
}
#[test]
#[cfg(feature = "draft-2026-07-28")]
fn a_refused_request_states_no_revision() {
let document = 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,"error":{"code":-32601,"message":"Method not found"}}}"#;
assert!(declared_revisions(&events(document)).is_empty());
assert!(mismatch(rev("2026-07-28"), &events(document)).is_none());
}
#[test]
fn a_headers_only_capture_still_states_its_revision() {
let document = r#"{"seq":0,"direction":"client-to-server","transport":"streamable-http","kind":"http","method":"POST","headers":{"mcp-protocol-version":"2025-11-25"}}
{"seq":1,"direction":"client-to-server","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":9,"method":"tools/list"}}
{"seq":2,"direction":"server-to-client","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":9,"result":{"tools":[]}}}"#;
assert_eq!(declared_revisions(&events(document)), ["2025-11-25"]);
}
#[test]
fn a_refused_request_takes_its_own_header_down_with_it() {
let document = r#"{"seq":0,"direction":"client-to-server","transport":"streamable-http","kind":"http","method":"POST","headers":{"mcp-protocol-version":"2025-11-25"}}
{"seq":1,"direction":"client-to-server","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":9,"method":"tools/list"}}
{"seq":2,"direction":"server-to-client","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":9,"error":{"code":-32022,"message":"Unsupported protocol version"}}}"#;
assert!(declared_revisions(&events(document)).is_empty());
}
#[test]
fn a_non_string_version_is_ignored_rather_than_stringified() {
let document = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":20251125}}}"#;
assert!(declared_revisions(&events(document)).is_empty());
}
#[test]
#[cfg(feature = "draft-2026-07-28")]
fn declarations_are_deduplicated_and_ordered() {
let document = format!(
"{HANDSHAKE}\n{}",
r#"{"seq":2,"direction":"client-to-server","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}}"#
);
assert_eq!(
declared_revisions(&events(&document)),
["2025-11-25", "2026-07-28"]
);
}
}