mcp_trace_validator/checks/draft/discovery.rs
1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! `2026-07-28` discovery checks (`server/discover`).
5//!
6//! Two of the page's four clauses are wire-observable and judged here. The
7//! other two bind a server's self-identification policy and a client's private
8//! use of `serverInfo`, and carry documented exclusions in the registry.
9//!
10//! Both checks read the classification the context already made
11//! ([`MessageKind`]) rather than re-deriving message shape from the payload:
12//! the only thing they need beyond it is the request's `_meta`, which is a
13//! payload lookup by design.
14
15use std::collections::BTreeMap;
16
17use mcp_conformance_core::message::MessageKind;
18use mcp_conformance_core::trace::Direction;
19
20use super::super::FindingSink;
21use crate::context::TraceContext;
22
23#[cfg(test)]
24mod tests;
25
26/// The discovery probe every `2026-07-28` server must answer.
27const DISCOVER: &str = "server/discover";
28
29/// The removed handshake. A client that sends it is, by that act, a client that
30/// still speaks the legacy era — the method exists in no other.
31const INITIALIZE: &str = "initialize";
32
33/// The `_meta` field by which a request declares the era it speaks.
34const PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion";
35
36/// JSON-RPC `Method not found`.
37const METHOD_NOT_FOUND: i64 = -32601;
38
39/// `DISC-001`: servers implement `server/discover`.
40///
41/// Falsified by the one answer that proves absence — `-32601` (`Method not
42/// found`) to a `server/discover` request. Other errors do not prove it:
43/// `-32022` (unsupported protocol version) and `-32602` are answers *from* an
44/// implementation. A session that never probes says nothing either way, so this
45/// abstains rather than reporting the method missing; a MUST that no recorded
46/// message bears on is not a MUST the trace can fail.
47///
48/// A legacy server's session, judged at this revision, does report here. That is
49/// the correct reading and not a false positive: the clause binds servers at
50/// `2026-07-28`, and a server answering `-32601` is not one — the *client's*
51/// conduct in that same exchange is DISC-002's business, separately.
52pub(in crate::checks) fn implemented(context: &TraceContext<'_>, sink: &mut FindingSink) {
53 let probes: BTreeMap<String, u64> = context
54 .messages()
55 .filter_map(|(event, kind, _)| match kind {
56 MessageKind::Request { method, id }
57 if *method == DISCOVER && event.direction == Direction::ClientToServer =>
58 {
59 Some((id.to_string(), event.seq))
60 }
61 _ => None,
62 })
63 .collect();
64 if probes.is_empty() {
65 return;
66 }
67 for (event, kind, _) in context.messages() {
68 if event.direction != Direction::ServerToClient {
69 continue;
70 }
71 // The subject is an *answered* probe, whichever way it was answered: a
72 // result proves the method is implemented just as an error may disprove
73 // it, and a session whose probe went unanswered settles nothing.
74 let (id, error) = match kind {
75 MessageKind::Result { id: Some(id) } => (*id, None),
76 MessageKind::Error {
77 id: Some(id),
78 error,
79 } => (*id, Some(*error)),
80 _ => continue,
81 };
82 if !probes.contains_key(&id.to_string()) {
83 continue;
84 }
85 sink.examined();
86 if error.and_then(|error| error.get("code")?.as_i64()) == Some(METHOD_NOT_FOUND) {
87 sink.push(
88 Some(event.seq),
89 "`server/discover` was answered with -32601 (method not found); \
90 2026-07-28 servers must implement it"
91 .to_owned(),
92 );
93 }
94 }
95}
96
97/// `DISC-002`: a client that speaks both eras probes with `server/discover` first.
98///
99/// The clause's antecedent — "supports both modern \[…\] and legacy \[…\] servers"
100/// — is a property of the client, not of the wire, so this fires only when the
101/// session witnesses *both* halves of it: an `initialize` request (legacy; the
102/// method exists in no other era) and a request carrying `PROTOCOL_VERSION` in
103/// its `_meta` (modern). A legacy-only client shows the first and never the
104/// second, and is not judged here, because it does not match the antecedent —
105/// getting that wrong would report every legacy session as a client defect.
106///
107/// "First" is the clause's own word and is read literally: the probe must be the
108/// client's first *request*. Notifications do not count, matching
109/// `basic/transports/stdio#backward-compatibility`, which puts the probe
110/// "before sending any other request".
111pub(in crate::checks) fn dual_era_probe_first(context: &TraceContext<'_>, sink: &mut FindingSink) {
112 let mut first: Option<(u64, &str)> = None;
113 let mut legacy = false;
114 let mut modern = false;
115 for (event, kind, _) in context.messages() {
116 if event.direction != Direction::ClientToServer {
117 continue;
118 }
119 let MessageKind::Request { method, .. } = kind else {
120 continue;
121 };
122 if first.is_none() {
123 first = Some((event.seq, method));
124 }
125 legacy |= *method == INITIALIZE;
126 modern |= event
127 .message_payload()
128 .and_then(|payload| payload.get("params")?.get("_meta")?.get(PROTOCOL_VERSION))
129 .is_some();
130 }
131 let Some((seq, method)) = first else {
132 return;
133 };
134 if !legacy || !modern {
135 return; // The antecedent — a dual-era client — is not witnessed here.
136 }
137 sink.examined();
138 if method != DISCOVER {
139 sink.push(
140 Some(seq),
141 format!(
142 "the client's first request is `{method}`, but this session shows both \
143 eras (an `initialize` request and a modern `_meta` protocol version), \
144 so it should have probed with `server/discover` first"
145 ),
146 );
147 }
148}