Skip to main content

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}