mcp_trace_validator/checks/draft/transport/stdio.rs
1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! The `2026-07-28` stdio binding's own clauses: cancellation by notification.
5//!
6//! stdio has no per-request stream to close, so where Streamable HTTP signals
7//! cancellation by closing the response stream (TRAN-069/070, [`super::stream`]),
8//! stdio signals it with `notifications/cancelled`. The two are the same
9//! protocol rule reached by different evidence, which is exactly why they cannot
10//! share a check: [`super::stream::no_messages_after_cancellation`] anchors on a
11//! `transport-close` lifecycle event and would inspect nothing at all on a stdio
12//! capture — a vacuous pass rather than a judgement.
13//!
14//! The rest of the stdio page reuses checks that already exist
15//! (`transport.stdio-{server-output,client-input}-valid`,
16//! `transport.client-no-responses`, `transport.no-independent-server-requests`,
17//! `discover.dual-era-probe-first`) or carries exclusions: `stderr`, process
18//! exit and stream closure are host-OS events the trace vocabulary does not
19//! record.
20
21use std::collections::BTreeMap;
22
23use mcp_conformance_core::trace::{Direction, TraceEvent};
24use serde_json::Value;
25
26use super::super::super::FindingSink;
27use crate::context::TraceContext;
28
29#[cfg(test)]
30mod tests;
31
32/// The cancellation notification.
33const CANCELLED: &str = "notifications/cancelled";
34
35/// The progress notification, the other message that can be "for" a request.
36const PROGRESS: &str = "notifications/progress";
37
38/// A client notification's method, when it has one.
39fn client_notification(event: &TraceEvent) -> Option<(&str, Option<&Value>)> {
40 if event.direction != Direction::ClientToServer {
41 return None;
42 }
43 let payload = event.message_payload()?;
44 if payload.get("id").is_some_and(|id| !id.is_null()) {
45 return None;
46 }
47 let method = payload.get("method")?.as_str()?;
48 Some((method, payload.get("params")))
49}
50
51/// `TRAN-123`: a cancellation names the request it cancels.
52///
53/// Only the shape is judged — that `params.requestId` is there — because that is
54/// what the clause states beyond the notification's existence. Whether the named
55/// id was still in flight is deliberately not reported: a recording that begins
56/// mid-session, or ends before the answer, would make a conforming client look
57/// like it cancelled a request that never existed.
58///
59/// The other half of the clause, that a client wanting to cancel *sends* one of
60/// these, has no falsifier: the intent to cancel produces no other message on
61/// stdio, so a session with no cancellation is indistinguishable from one where
62/// nothing was cancelled.
63pub(in crate::checks) fn cancel_notification_references_request(
64 context: &TraceContext<'_>,
65 sink: &mut FindingSink,
66) {
67 for event in context.events() {
68 let Some((method, params)) = client_notification(event) else {
69 continue;
70 };
71 if method != CANCELLED {
72 continue;
73 }
74 sink.examined();
75 let names_a_request = params
76 .and_then(|params| params.get("requestId"))
77 .is_some_and(|id| !id.is_null());
78 if !names_a_request {
79 sink.push(
80 Some(event.seq),
81 format!(
82 "`{CANCELLED}` carries no `params.requestId`, so it names no request \
83 to cancel"
84 ),
85 );
86 }
87 }
88}
89
90/// `TRAN-124`: nothing further is sent for a request after it is cancelled.
91///
92/// Two ways a later server message can be "for" the cancelled request, and both
93/// are judged: a response correlated by JSON-RPC `id`, and a
94/// `notifications/progress` correlated by the `progressToken` the request itself
95/// carried in `_meta`. Stopping at the first would leave the clause's "any
96/// further messages" covering only the answer the server had probably already
97/// decided not to send.
98///
99/// Cancellations of ids the trace never opened are still honoured, because the
100/// prohibition is on the id, not on this recording having witnessed its request.
101pub(in crate::checks) fn no_messages_after_cancel_notification(
102 context: &TraceContext<'_>,
103 sink: &mut FindingSink,
104) {
105 // Request id (canonical text) → the progress token it opted into, if any.
106 let mut tokens: BTreeMap<String, String> = BTreeMap::new();
107 // Cancelled request id → the seq of the notification that cancelled it. An
108 // id enters this map at the cancellation and is judged only for what comes
109 // *after*, which is the ordering the clause states — expressed by when the
110 // entry appears rather than by comparing sequence numbers later. A
111 // cancellation is a client notification and the messages judged are
112 // server-sent, so `seq > cancelled_at` and `seq >= cancelled_at` would be
113 // the same rule, and no trace could tell them apart.
114 let mut cancelled: BTreeMap<String, u64> = BTreeMap::new();
115 for event in context.events() {
116 if let Some((method, params)) = client_notification(event) {
117 if method == CANCELLED
118 && let Some(id) = params.and_then(|params| params.get("requestId"))
119 && !id.is_null()
120 {
121 cancelled.entry(id.to_string()).or_insert(event.seq);
122 }
123 continue;
124 }
125 if event.direction == Direction::ClientToServer {
126 record_progress_token(event, &mut tokens);
127 continue;
128 }
129 if cancelled.is_empty() {
130 continue; // Nothing has been cancelled yet, so nothing is forbidden.
131 }
132 // The subject is a server message sent while a cancellation stands: it
133 // may or may not belong to the cancelled request, and the trace shows
134 // which.
135 sink.examined();
136 report_if_cancelled(event, &tokens, &cancelled, sink);
137 }
138}
139
140/// Remembers the `_meta.progressToken` a client request opted into.
141fn record_progress_token(event: &TraceEvent, tokens: &mut BTreeMap<String, String>) {
142 let Some(payload) = event.message_payload() else {
143 return;
144 };
145 let (Some(id), Some(token)) = (
146 payload.get("id").filter(|id| !id.is_null()),
147 payload
148 .get("params")
149 .and_then(|params| params.get("_meta"))
150 .and_then(|meta| meta.get("progressToken")),
151 ) else {
152 return;
153 };
154 tokens.insert(id.to_string(), token.to_string());
155}
156
157/// Reports a server message that belongs to an already-cancelled request.
158fn report_if_cancelled(
159 event: &TraceEvent,
160 tokens: &BTreeMap<String, String>,
161 cancelled: &BTreeMap<String, u64>,
162 sink: &mut FindingSink,
163) {
164 let Some(payload) = event.message_payload() else {
165 return;
166 };
167 // A response: no `method`, and a non-null `id` naming what it answers.
168 let answered = payload
169 .get("method")
170 .is_none()
171 .then(|| payload.get("id").filter(|id| !id.is_null()))
172 .flatten()
173 .map(ToString::to_string);
174 let progressed = (payload.get("method").and_then(Value::as_str) == Some(PROGRESS))
175 .then(|| payload.get("params")?.get("progressToken"))
176 .flatten()
177 .map(ToString::to_string)
178 .and_then(|token| {
179 tokens
180 .iter()
181 .find_map(|(id, opted)| (*opted == token).then(|| id.clone()))
182 });
183 for (id, what) in [(answered, "a response"), (progressed, "progress")] {
184 let Some(id) = id else { continue };
185 let Some(&cancelled_at) = cancelled.get(&id) else {
186 continue;
187 };
188 sink.push(
189 Some(event.seq),
190 format!(
191 "server sent {what} for request {id}, which the client cancelled at \
192 seq {cancelled_at}"
193 ),
194 );
195 }
196}