use serde_json::Value;
use super::super::FindingSink;
use crate::context::TraceContext;
#[cfg(test)]
mod tests;
const LEGACY_RANGE: core::ops::RangeInclusive<i64> = -32019..=-32000;
const RESERVED_RANGE: core::ops::RangeInclusive<i64> = -32099..=-32020;
const JSONRPC_RESERVED: core::ops::RangeInclusive<i64> = -32768..=-32000;
const DEFINED_RESERVED: &[i64] = &[-32020, -32021, -32022];
const WITHDRAWN: &[(i64, &str)] = &[
(
-32002,
"resource not found (2025-11-25 and earlier; replaced by -32602)",
),
(-32042, "URL elicitation required (2025-11-25 only)"),
];
const STANDARD: &[i64] = &[-32700, -32600, -32601, -32602, -32603];
fn error_codes<'a>(context: &'a TraceContext<'_>) -> impl Iterator<Item = (u64, i64)> + 'a {
context.messages().filter_map(|(event, _, _)| {
let code = event
.message_payload()?
.get("error")?
.get("code")?
.as_i64()?;
Some((event.seq, code))
})
}
pub(in crate::checks) fn result_type_present(context: &TraceContext<'_>, sink: &mut FindingSink) {
for (event, _, _) in context.messages() {
let Some(payload) = event.message_payload() else {
continue;
};
let Some(result) = payload.get("result") else {
continue;
};
sink.examined();
if result.get("resultType").is_none() {
sink.push(
Some(event.seq),
"result has no `resultType`; 2026-07-28 requires it on every result".to_owned(),
);
} else if !result.get("resultType").is_some_and(Value::is_string) {
sink.push(
Some(event.seq),
"`resultType` is present but not a string".to_owned(),
);
}
}
}
pub(in crate::checks) fn request_id_unique_in_flight(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
let mut outstanding: std::collections::HashSet<(bool, String)> =
std::collections::HashSet::new();
for (event, _, _) in context.messages() {
let Some(payload) = event.message_payload() else {
continue;
};
let Some(id) = payload.get("id") else {
continue;
};
if id.is_null() {
continue;
}
let key = (
matches!(
event.direction,
mcp_conformance_core::trace::Direction::ClientToServer
),
id.to_string(),
);
if payload.get("method").is_some() {
sink.examined();
if !outstanding.insert(key) {
sink.push(
Some(event.seq),
format!(
"request id {id} is already outstanding for this sender; \
2026-07-28 forbids reusing an id before its response"
),
);
}
} else {
outstanding.remove(&(!key.0, key.1));
}
}
}
pub(in crate::checks) fn error_code_legacy_subrange(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
for (seq, code) in error_codes(context) {
sink.examined();
if LEGACY_RANGE.contains(&code) {
sink.push(
Some(seq),
format!(
"error code {code} is in the legacy sub-range (-32000..-32019), \
which 2026-07-28 implementations are not to use"
),
);
}
}
}
pub(in crate::checks) fn error_code_reserved_subrange(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
for (seq, code) in error_codes(context) {
sink.examined();
if RESERVED_RANGE.contains(&code) && !DEFINED_RESERVED.contains(&code) {
sink.push(
Some(seq),
format!(
"error code {code} is in the MCP-reserved sub-range \
(-32020..-32099) but is not defined by this specification"
),
);
}
}
}
pub(in crate::checks) fn error_code_withdrawn(context: &TraceContext<'_>, sink: &mut FindingSink) {
for (seq, code) in error_codes(context) {
sink.examined();
if let Some((_, meaning)) = WITHDRAWN.iter().find(|(withdrawn, _)| *withdrawn == code) {
sink.push(
Some(seq),
format!("error code {code} — {meaning} — must not be emitted at 2026-07-28"),
);
}
}
}
pub(in crate::checks) fn error_code_application_range(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
for (seq, code) in error_codes(context) {
sink.examined();
let accounted_for = STANDARD.contains(&code)
|| DEFINED_RESERVED.contains(&code)
|| LEGACY_RANGE.contains(&code)
|| RESERVED_RANGE.contains(&code);
if JSONRPC_RESERVED.contains(&code) && !accounted_for {
sink.push(
Some(seq),
format!(
"error code {code} is application-defined but sits inside the \
JSON-RPC reserved range (-32768..-32000)"
),
);
}
}
}