Skip to main content

mcp_trace_validator/checks/draft/
meta.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! `2026-07-28` `_meta`-envelope checks.
5//!
6//! The stateless rework moves per-session context onto every request, so these
7//! read `params._meta` and `result._meta` and correlate a request with the
8//! answer it drew. Each reports only what a recorded session shows: none of
9//! them claims to prove a positive ("the server never relied on X"), which is
10//! why the clauses that *only* have a positive form are excluded in the
11//! registry rather than checked here.
12
13use std::collections::BTreeMap;
14
15use serde_json::Value;
16
17use super::super::FindingSink;
18use super::http_status_for;
19use crate::context::TraceContext;
20use mcp_conformance_core::trace::Direction;
21
22mod trace_context;
23
24#[cfg(test)]
25mod tests;
26
27pub(in crate::checks) use trace_context::trace_context_format;
28
29/// Fields `2026-07-28` requires in every client request's `_meta`.
30const REQUIRED_REQUEST_FIELDS: &[&str] = &[
31    "io.modelcontextprotocol/protocolVersion",
32    "io.modelcontextprotocol/clientCapabilities",
33];
34
35/// `MissingRequiredClientCapabilityError`.
36const MISSING_CAPABILITY_CODE: i64 = -32021;
37/// JSON-RPC `Invalid params`, which a malformed `_meta` envelope draws.
38const INVALID_PARAMS: i64 = -32602;
39
40/// The handshake this revision removed. A request for it is the previous era's
41/// opener, not a `2026-07-28` request with a malformed envelope.
42const LEGACY_HANDSHAKE: &str = "initialize";
43
44/// The `_meta` object of a message's `params`, when present.
45fn params_meta(payload: &Value) -> Option<&serde_json::Map<String, Value>> {
46    payload.get("params")?.get("_meta")?.as_object()
47}
48
49/// Client requests in the trace, as `(seq, id, payload)`.
50fn client_requests<'a>(
51    context: &'a TraceContext<'_>,
52) -> impl Iterator<Item = (u64, Option<&'a Value>, &'a Value)> + 'a {
53    context.messages().filter_map(|(event, _, _)| {
54        if !matches!(event.direction, Direction::ClientToServer) {
55            return None;
56        }
57        let payload = event.message_payload()?;
58        payload.get("method")?;
59        Some((event.seq, payload.get("id"), payload))
60    })
61}
62
63/// `BASE-030`: every client request carries the required `io.modelcontextprotocol/*`
64/// fields in `_meta`.
65///
66/// Notifications are excluded: the clause binds *requests*, which the stateless
67/// model defines as the messages a server must be able to process standalone.
68pub(in crate::checks) fn required_request_fields(
69    context: &TraceContext<'_>,
70    sink: &mut FindingSink,
71) {
72    for (seq, id, payload) in client_requests(context) {
73        if id.is_none() {
74            continue; // a notification, not a request
75        }
76        sink.examined();
77        let meta = params_meta(payload);
78        for field in REQUIRED_REQUEST_FIELDS {
79            let present = meta.is_some_and(|meta| meta.contains_key(*field));
80            if !present {
81                sink.push(
82                    Some(seq),
83                    format!("request `_meta` is missing required field `{field}`"),
84                );
85            }
86        }
87    }
88}
89
90/// Client requests whose `_meta` is missing a required field, by id text.
91///
92/// Shared by `BASE-031` (what such a request must draw) and `BASE-032` (what
93/// HTTP status that answer must ride), because both clauses are about the
94/// *same* request and neither binds a `-32602` raised for any other reason.
95fn malformed_requests(context: &TraceContext<'_>) -> BTreeMap<String, u64> {
96    client_requests(context)
97        .filter_map(|(seq, id, payload)| {
98            let id = id?;
99            // The one exchange the specification takes out of this rule: a legacy
100            // `initialize` arriving at a modern server. `basic/versioning`'s
101            // compatibility matrix states that there "the exact code is
102            // implementation-defined (`initialize` is an unknown method and the
103            // request also lacks the required `_meta` fields)" — two rules apply
104            // and the specification declines to pick, so a server answering
105            // `-32601` conforms. Without this, every cross-era capture would
106            // carry a MUST failure the specification has explicitly waived. The
107            // client's own defect is still reported, by BASE-030.
108            if payload.get("method").and_then(Value::as_str) == Some(LEGACY_HANDSHAKE) {
109                return None;
110            }
111            let meta = params_meta(payload);
112            let complete = REQUIRED_REQUEST_FIELDS
113                .iter()
114                .all(|field| meta.is_some_and(|meta| meta.contains_key(*field)));
115            (!complete).then(|| (id.to_string(), seq))
116        })
117        .collect()
118}
119
120/// `BASE-031`: a request missing a required `_meta` field must draw `-32602`.
121///
122/// Falsified when the server answered such a request with a *result*, or with
123/// some other error code — both of which the trace shows directly. A request
124/// left unanswered inside the recording is not reported: the session may simply
125/// have ended before the answer.
126pub(in crate::checks) fn missing_required_field_rejected(
127    context: &TraceContext<'_>,
128    sink: &mut FindingSink,
129) {
130    let malformed = malformed_requests(context);
131    if malformed.is_empty() {
132        return;
133    }
134    for (event, _, _) in context.messages() {
135        if !matches!(event.direction, Direction::ServerToClient) {
136            continue;
137        }
138        let Some(payload) = event.message_payload() else {
139            continue;
140        };
141        let Some(id) = payload.get("id") else {
142            continue;
143        };
144        let Some(&request_seq) = malformed.get(&id.to_string()) else {
145            continue;
146        };
147        // The subject is an *answer* to a malformed request; one the recording
148        // never saw answered settles nothing.
149        sink.examined();
150        match payload.get("error").and_then(|error| error.get("code")) {
151            Some(code) if code.as_i64() == Some(INVALID_PARAMS) => {}
152            Some(code) => sink.push(
153                Some(event.seq),
154                format!(
155                    "request at seq {request_seq} was missing a required `_meta` field; \
156                     the server answered with error code {code} rather than {INVALID_PARAMS}"
157                ),
158            ),
159            None => sink.push(
160                Some(event.seq),
161                format!(
162                    "request at seq {request_seq} was missing a required `_meta` field; \
163                     the server answered with a result rather than error {INVALID_PARAMS}"
164                ),
165            ),
166        }
167    }
168}
169
170/// Reports every server error carrying `code` whose HTTP response status is not
171/// `400` — the shared body of `BASE-032` and `BASE-036`.
172///
173/// `answering` narrows which errors of that code the clause reaches, by the id
174/// of the request each answers. `BASE-036` passes `None`: `-32021` has exactly
175/// one cause, so every one of them is its subject. `BASE-032` passes the
176/// malformed-request set, because its `-32602` is not the only `-32602` a
177/// conforming server emits — this revision *replaced* `-32002` with it, so a
178/// resource-not-found now carries the same code, and the clause says nothing
179/// about that answer's HTTP status.
180fn http_status_for_error(
181    context: &TraceContext<'_>,
182    sink: &mut FindingSink,
183    code: i64,
184    clause: &str,
185    answering: Option<&BTreeMap<String, u64>>,
186) {
187    for (event, _, _) in context.messages() {
188        if !matches!(event.direction, Direction::ServerToClient) {
189            continue;
190        }
191        let Some(payload) = event.message_payload() else {
192            continue;
193        };
194        let matches_code = payload
195            .get("error")
196            .and_then(|error| error.get("code"))
197            .and_then(Value::as_i64)
198            == Some(code);
199        if !matches_code {
200            continue;
201        }
202        if let Some(answering) = answering {
203            let answers_a_subject = payload
204                .get("id")
205                .is_some_and(|id| answering.contains_key(&id.to_string()));
206            if !answers_a_subject {
207                continue;
208            }
209        }
210        // Only judged when the recording actually carries HTTP framing; on stdio
211        // there is no status to check, and a trace without one evidences nothing.
212        let Some((status_seq, status)) = http_status_for(context, event.seq) else {
213            continue;
214        };
215        sink.examined();
216        if status != 400 {
217            sink.push(
218                Some(status_seq),
219                format!("{clause}: error {code} was returned with HTTP {status}, not 400"),
220            );
221        }
222    }
223}
224
225/// `BASE-032`: on HTTP, a `-32602` for a malformed `_meta` envelope is a `400`.
226pub(in crate::checks) fn missing_required_field_http_status(
227    context: &TraceContext<'_>,
228    sink: &mut FindingSink,
229) {
230    // Narrowed to the errors this clause is about. Before the enriched HTTP
231    // capture carried one, every `-32602` in every recording *was* a malformed
232    // envelope, so the difference could not show; a server answering a
233    // resource-not-found `-32602` with anything but 400 would have been
234    // reported for a clause that does not bind it.
235    let malformed = malformed_requests(context);
236    http_status_for_error(
237        context,
238        sink,
239        INVALID_PARAMS,
240        "missing required `_meta` field",
241        Some(&malformed),
242    );
243}
244
245/// `BASE-036`: on HTTP, `MissingRequiredClientCapabilityError` is a `400`.
246pub(in crate::checks) fn missing_capability_http_status(
247    context: &TraceContext<'_>,
248    sink: &mut FindingSink,
249) {
250    http_status_for_error(
251        context,
252        sink,
253        MISSING_CAPABILITY_CODE,
254        "missing required client capability",
255        None,
256    );
257}
258
259/// `BASE-035`: a `-32021` must carry `data.requiredCapabilities` naming what
260/// was missing.
261///
262/// The trace cannot show that the server *needed* a capability, so the positive
263/// direction is out of reach; what it can show is a `-32021` whose shape does
264/// not carry what the clause requires.
265///
266/// The clause's word is "lists", and this check read that as a JSON array until
267/// 2026-08-17. The schema disagrees, and the schema is the authority:
268/// `MissingRequiredClientCapabilityError.error.data.requiredCapabilities` is
269/// typed [`ClientCapabilities`][schema] — the same nested object a client sends
270/// in its `_meta`, carrying the *shape* of what is missing rather than a list of
271/// names. Judged as an array, this check reported a conforming server, which is
272/// the worst thing a conformance check can do; it now requires an object.
273///
274/// An empty object is still reported. `{}` declares nothing missing, so it
275/// leaves the client with no more information than an error carrying no `data`
276/// at all — the very thing the clause exists to prevent.
277///
278/// [schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2026-07-28/schema.ts
279pub(in crate::checks) fn missing_capability_error(
280    context: &TraceContext<'_>,
281    sink: &mut FindingSink,
282) {
283    for (event, _, _) in context.messages() {
284        let Some(error) = event
285            .message_payload()
286            .and_then(|payload| payload.get("error"))
287        else {
288            continue;
289        };
290        if error.get("code").and_then(Value::as_i64) != Some(MISSING_CAPABILITY_CODE) {
291            continue;
292        }
293        sink.examined();
294        match error
295            .get("data")
296            .and_then(|data| data.get("requiredCapabilities"))
297        {
298            Some(required) if required.is_object() => {
299                if required.as_object().is_some_and(serde_json::Map::is_empty) {
300                    sink.push(
301                        Some(event.seq),
302                        format!(
303                            "error {MISSING_CAPABILITY_CODE} carries an empty \
304                             `data.requiredCapabilities`; it must name the missing capabilities"
305                        ),
306                    );
307                }
308            }
309            Some(_) => sink.push(
310                Some(event.seq),
311                format!(
312                    "error {MISSING_CAPABILITY_CODE} has `data.requiredCapabilities` \
313                     that is not a `ClientCapabilities` object"
314                ),
315            ),
316            None => sink.push(
317                Some(event.seq),
318                format!(
319                    "error {MISSING_CAPABILITY_CODE} has no `data.requiredCapabilities` \
320                     naming the missing capabilities"
321                ),
322            ),
323        }
324    }
325}
326
327/// `BASE-034`: a server must not rely on capabilities the client did not declare.
328///
329/// Reliance is internal, so this reports its one wire-visible form: the server
330/// asking the client for input (`resultType: "input_required"`, SEP-2322) of a
331/// kind the request's own `clientCapabilities` never advertised.
332pub(in crate::checks) fn no_undeclared_capability_reliance(
333    context: &TraceContext<'_>,
334    sink: &mut FindingSink,
335) {
336    // Capabilities declared per request id, from the request's own `_meta` —
337    // there is no session-wide declaration to fall back on at this revision.
338    let mut declared: BTreeMap<String, Vec<String>> = BTreeMap::new();
339    for (_, id, payload) in client_requests(context) {
340        let Some(id) = id else { continue };
341        let names = params_meta(payload)
342            .and_then(|meta| meta.get("io.modelcontextprotocol/clientCapabilities"))
343            .and_then(Value::as_object)
344            .map(|caps| caps.keys().cloned().collect())
345            .unwrap_or_default();
346        declared.insert(id.to_string(), names);
347    }
348    for (event, _, _) in context.messages() {
349        if !matches!(event.direction, Direction::ServerToClient) {
350            continue;
351        }
352        let Some(payload) = event.message_payload() else {
353            continue;
354        };
355        let Some(result) = payload.get("result") else {
356            continue;
357        };
358        if result.get("resultType").and_then(Value::as_str) != Some("input_required") {
359            continue;
360        }
361        let Some(id) = payload.get("id") else {
362            continue;
363        };
364        let Some(declared) = declared.get(&id.to_string()) else {
365            continue;
366        };
367        let requests = result
368            .get("inputRequests")
369            .and_then(Value::as_object)
370            .map(|map| map.values().collect::<Vec<_>>())
371            .unwrap_or_default();
372        for request in requests {
373            let Some(method) = request.get("method").and_then(Value::as_str) else {
374                continue;
375            };
376            let needed = match method {
377                "elicitation/create" => "elicitation",
378                "sampling/createMessage" => "sampling",
379                "roots/list" => "roots",
380                _ => continue,
381            };
382            // The subject is an input request of a kind a capability governs;
383            // the revision's other input kinds need none.
384            sink.examined();
385            if !declared.iter().any(|name| name == needed) {
386                sink.push(
387                    Some(event.seq),
388                    format!(
389                        "server asked for `{method}`, which needs the `{needed}` capability, \
390                         but the request's `clientCapabilities` did not declare it"
391                    ),
392                );
393            }
394        }
395    }
396}
397
398/// `BASE-039`: notifications on a `subscriptions/listen` stream carry
399/// `io.modelcontextprotocol/subscriptionId`.
400///
401/// Scoped to traces that actually opened such a stream: without one, a
402/// notification is request-scoped (progress, logging) and the clause does not
403/// bind it.
404pub(in crate::checks) fn subscription_id_present(
405    context: &TraceContext<'_>,
406    sink: &mut FindingSink,
407) {
408    let listening = context.messages().any(|(event, _, _)| {
409        event
410            .message_payload()
411            .and_then(|payload| payload.get("method"))
412            .and_then(Value::as_str)
413            == Some("subscriptions/listen")
414    });
415    if !listening {
416        return;
417    }
418    for (event, _, _) in context.messages() {
419        if !matches!(event.direction, Direction::ServerToClient) {
420            continue;
421        }
422        let Some(payload) = event.message_payload() else {
423            continue;
424        };
425        // A notification: a method with no id.
426        if payload.get("id").is_some() || payload.get("method").is_none() {
427            continue;
428        }
429        let method = payload.get("method").and_then(Value::as_str).unwrap_or("");
430        // Request-scoped notifications ride their request's response stream and
431        // are outside this clause.
432        if method.starts_with("notifications/progress")
433            || method.starts_with("notifications/message")
434        {
435            continue;
436        }
437        sink.examined();
438        let tagged = params_meta(payload)
439            .is_some_and(|meta| meta.contains_key("io.modelcontextprotocol/subscriptionId"));
440        if !tagged {
441            sink.push(
442                Some(event.seq),
443                format!(
444                    "notification `{method}` on a subscriptions/listen stream has no \
445                     `io.modelcontextprotocol/subscriptionId` in `_meta`"
446                ),
447            );
448        }
449    }
450}