use std::collections::HashMap;
use mcp_conformance_core::canonical::to_canonical_string;
use mcp_conformance_core::message::{MessageKind, is_notification_method};
use mcp_conformance_core::trace::Direction;
use serde_json::Value;
use super::FindingSink;
use crate::context::TraceContext;
mod meta;
pub(super) use meta::meta_key_format;
fn type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(number) => {
if number.is_i64() || number.is_u64() {
"an integer"
} else {
"a non-integer number"
}
}
Value::String(_) => "a string",
Value::Array(_) => "an array",
Value::Object(_) => "an object",
}
}
fn id_is_string_or_integer(id: &Value) -> bool {
match id {
Value::String(_) => true,
Value::Number(number) => number.is_i64() || number.is_u64(),
_ => false,
}
}
pub(super) fn request_id_type(context: &TraceContext<'_>, sink: &mut FindingSink) {
for (event, kind, _) in context.messages() {
if let MessageKind::Request { method, id } = kind
&& !id_is_string_or_integer(id)
{
sink.push(
Some(event.seq),
format!(
"request {method:?} carries {} as its id; the ID must be a string or an integer",
type_name(id)
),
);
}
}
}
pub(super) fn request_id_not_null(context: &TraceContext<'_>, sink: &mut FindingSink) {
for (event, kind, _) in context.messages() {
if let MessageKind::Request { method, id } = kind
&& id.is_null()
{
sink.push(
Some(event.seq),
format!("request {method:?} carries a null id, which MCP forbids"),
);
}
}
}
pub(super) fn request_id_unique(context: &TraceContext<'_>, sink: &mut FindingSink) {
let mut first_use: HashMap<(Direction, String), u64> = HashMap::new();
for (event, kind, _) in context.messages() {
if let MessageKind::Request { method, id } = kind {
if id.is_null() {
continue; }
let key = (event.direction, to_canonical_string(id));
match first_use.get(&key) {
Some(previous) => sink.push(
Some(event.seq),
format!(
"request {method:?} reuses id {}, already used by the same party at seq {previous}",
key.1
),
),
None => {
first_use.insert(key, event.seq);
}
}
}
}
}
fn responses_match_requests(
context: &TraceContext<'_>,
sink: &mut FindingSink,
want_results: bool,
) {
let mut outstanding: HashMap<(Direction, String), u64> = HashMap::new();
for (event, kind, _) in context.messages() {
match kind {
MessageKind::Request { id, .. } => {
if !id.is_null() {
outstanding.insert((event.direction, to_canonical_string(id)), event.seq);
}
}
MessageKind::Result { id } => {
if want_results {
check_response_id(
event.seq,
event.direction,
*id,
&mut outstanding,
sink,
"result",
);
} else {
consume_outstanding(event.direction, *id, &mut outstanding);
}
}
MessageKind::Error { id, .. } => {
if want_results {
consume_outstanding(event.direction, *id, &mut outstanding);
} else if id.is_some_and(|id| !id.is_null()) {
check_response_id(
event.seq,
event.direction,
*id,
&mut outstanding,
sink,
"error",
);
}
}
_ => {}
}
}
}
fn consume_outstanding(
response_direction: Direction,
id: Option<&Value>,
outstanding: &mut HashMap<(Direction, String), u64>,
) {
if let Some(id) = id.filter(|id| !id.is_null()) {
let requester = match response_direction {
Direction::ClientToServer => Direction::ServerToClient,
Direction::ServerToClient => Direction::ClientToServer,
};
outstanding.remove(&(requester, to_canonical_string(id)));
}
}
fn check_response_id(
seq: u64,
response_direction: Direction,
id: Option<&Value>,
outstanding: &mut HashMap<(Direction, String), u64>,
sink: &mut FindingSink,
flavor: &str,
) {
let requester = match response_direction {
Direction::ClientToServer => Direction::ServerToClient,
Direction::ServerToClient => Direction::ClientToServer,
};
match id {
None => sink.push(
Some(seq),
format!("{flavor} response is missing its id; responses must echo the request id"),
),
Some(id) if id.is_null() => sink.push(
Some(seq),
format!("{flavor} response carries a null id; responses must echo the request id"),
),
Some(id) => {
let key = (requester, to_canonical_string(id));
if outstanding.remove(&key).is_none() {
sink.push(
Some(seq),
format!(
"{flavor} response answers id {}, but that party has no outstanding request with that id (never sent, or already answered)",
key.1
),
);
}
}
}
}
pub(super) fn result_id_matches(context: &TraceContext<'_>, sink: &mut FindingSink) {
responses_match_requests(context, sink, true);
}
pub(super) fn error_id_matches(context: &TraceContext<'_>, sink: &mut FindingSink) {
responses_match_requests(context, sink, false);
}
pub(super) fn notification_no_id(context: &TraceContext<'_>, sink: &mut FindingSink) {
for (event, kind, _) in context.messages() {
if let MessageKind::Request { method, .. } = kind
&& is_notification_method(method)
{
sink.push(
Some(event.seq),
format!(
"{method:?} is a notification method but the message carries an id; notifications must not include one"
),
);
}
}
}
pub(super) fn error_shape(context: &TraceContext<'_>, sink: &mut FindingSink) {
for (event, kind, _) in context.messages() {
if let MessageKind::Error { error, .. } = kind {
let Some(object) = error.as_object() else {
sink.push(
Some(event.seq),
format!("error member is {}, expected an object", type_name(error)),
);
continue;
};
if !object.contains_key("code") {
sink.push(
Some(event.seq),
"error object lacks a code member".to_owned(),
);
}
match object.get("message") {
None => sink.push(
Some(event.seq),
"error object lacks a message member".to_owned(),
),
Some(message) if !message.is_string() => sink.push(
Some(event.seq),
format!(
"error message member is {}, expected a string",
type_name(message)
),
),
Some(_) => {}
}
}
}
}
pub(super) fn error_code_integer(context: &TraceContext<'_>, sink: &mut FindingSink) {
for (event, kind, _) in context.messages() {
if let MessageKind::Error { error, .. } = kind
&& let Some(code) = error.get("code")
&& !code.is_i64()
&& !code.is_u64()
{
sink.push(
Some(event.seq),
format!("error code is {}, expected an integer", type_name(code)),
);
}
}
}
pub(super) fn result_field(context: &TraceContext<'_>, sink: &mut FindingSink) {
for (event, kind, _) in context.messages() {
if !matches!(kind, MessageKind::Invalid { .. }) {
continue;
}
let Some(object) = event.message_payload().and_then(Value::as_object) else {
continue;
};
if object.contains_key("id")
&& !object.contains_key("method")
&& !object.contains_key("result")
&& !object.contains_key("error")
{
sink.push(
Some(event.seq),
"response-shaped message (id present, no method) carries no result field"
.to_owned(),
);
}
}
}
pub(super) fn jsonrpc_version(context: &TraceContext<'_>, sink: &mut FindingSink) {
for (event, kind, _) in context.messages() {
if let MessageKind::Invalid { reason } = kind {
sink.push(
Some(event.seq),
format!("message is not a JSON-RPC request, notification, or response: {reason}"),
);
continue;
}
let version = event
.message_payload()
.and_then(|payload| payload.get("jsonrpc"));
match version {
Some(Value::String(version)) if version == "2.0" => {}
Some(other) => sink.push(
Some(event.seq),
format!("jsonrpc member is {other}, expected the string \"2.0\""),
),
None => sink.push(
Some(event.seq),
"message lacks the jsonrpc member; JSON-RPC 2.0 requires \"jsonrpc\": \"2.0\""
.to_owned(),
),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use crate::checks;
use crate::context::TraceContext;
use crate::reader::{Limits, parse_trace};
use crate::report::Finding;
use mcp_conformance_core::trace::TraceEvent;
fn run_check(check_id: &str, trace: &str) -> Vec<Finding> {
let events: Vec<TraceEvent> = parse_trace(trace, &Limits::default()).unwrap();
let context = TraceContext::new(&events);
checks::find(check_id).unwrap().run(&context)
}
const INIT: &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"}}}}"#;
#[test]
fn result_response_with_null_id_gets_the_null_detail() {
let trace = format!(
"{INIT}\n{}",
r#"{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":null,"result":{}}}"#
);
let findings = run_check("base.result-id-matches", &trace);
assert_eq!(findings.len(), 1);
assert!(
findings[0].detail.contains("null id"),
"{}",
findings[0].detail
);
}
#[test]
fn error_message_member_type_is_named_precisely() {
let trace = format!(
"{INIT}\n{}",
r#"{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":-5}}}"#
);
let findings = run_check("base.error-shape", &trace);
assert_eq!(findings.len(), 1);
assert!(
findings[0]
.detail
.contains("is an integer, expected a string"),
"{}",
findings[0].detail
);
}
#[test]
fn u64_only_request_ids_are_valid_integers() {
let trace = format!(
"{INIT}\n{}",
r#"{"seq":1,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":18446744073709551615,"method":"tools/list"}}"#
);
assert!(run_check("base.request-id-type", &trace).is_empty());
}
const REQUEST: &str = r#"{"seq":1,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#;
const RESULT_2: &str = r#"{"seq":3,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"result":{}}}"#;
const ERROR_2: &str = r#"{"seq":2,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"error":{"code":-32000,"message":"x"}}}"#;
#[test]
fn error_then_result_flags_the_second_answer_as_a_result() {
let trace = format!("{INIT}\n{REQUEST}\n{ERROR_2}\n{RESULT_2}");
let results = run_check("base.result-id-matches", &trace);
assert_eq!(results.len(), 1, "{results:?}");
assert_eq!(results[0].seq, Some(3));
assert!(
results[0].detail.contains("already answered"),
"{results:?}"
);
assert!(
run_check("base.error-id-matches", &trace).is_empty(),
"the error was the legitimate first answer"
);
}
#[test]
fn result_then_error_flags_the_second_answer_as_an_error() {
let result_seq2 = RESULT_2.replace("\"seq\":3", "\"seq\":2");
let error_seq3 = ERROR_2.replace("\"seq\":2", "\"seq\":3");
let trace = format!("{INIT}\n{REQUEST}\n{result_seq2}\n{error_seq3}");
let errors = run_check("base.error-id-matches", &trace);
assert_eq!(errors.len(), 1, "{errors:?}");
assert_eq!(errors[0].seq, Some(3));
assert!(errors[0].detail.contains("already answered"), "{errors:?}");
assert!(
run_check("base.result-id-matches", &trace).is_empty(),
"the result was the legitimate first answer"
);
}
#[test]
fn single_flavor_answer_is_not_flagged_by_the_other_pass() {
let trace = format!(
"{INIT}\n{REQUEST}\n{}",
RESULT_2.replace("\"seq\":3", "\"seq\":2")
);
assert!(run_check("base.result-id-matches", &trace).is_empty());
assert!(run_check("base.error-id-matches", &trace).is_empty());
}
}