Skip to main content

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}