Skip to main content

mcp_trace_validator/checks/draft/transport/
stream.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! The `2026-07-28` response-stream clauses: what may travel on the stream a
5//! POST opened, and for how long.
6//!
7//! They judge the server's side of a stream — message direction, shape and
8//! ordering, and the response headers that open it — never a request header and
9//! never the body/header agreement a POST claims. That is why they sit apart
10//! from the request-header clauses in [`super::headers`] and the rejection
11//! clauses in [`super::validation`].
12
13use std::collections::BTreeSet;
14
15use serde_json::Value;
16
17use super::super::super::FindingSink;
18use crate::context::TraceContext;
19use mcp_conformance_core::trace::{Direction, EventBody, LifecycleEvent, TransportKind};
20
21#[cfg(test)]
22mod tests;
23
24/// `TRAN-060` and `TRAN-119`: clients do not send JSON-RPC responses.
25///
26/// Judged on every binding, not just Streamable HTTP. Both binding pages state
27/// the rule — "The client **MUST NOT** write JSON-RPC _responses_" on stdio,
28/// and the POST form on HTTP — and it is one rule, because the revision removed
29/// server-initiated requests outright: there is nothing on any transport for a
30/// client response to answer. The earlier HTTP-only filter would have made this
31/// silently vacuous for the stdio clause, reporting `pass` on a trace it never
32/// inspected.
33pub(in crate::checks) fn client_no_responses(context: &TraceContext<'_>, sink: &mut FindingSink) {
34    for (event, _, _) in context.messages() {
35        if event.direction != Direction::ClientToServer {
36            continue;
37        }
38        let Some(payload) = event.message_payload() else {
39            continue;
40        };
41        sink.examined();
42        let is_response = payload.get("id").is_some()
43            && payload.get("method").is_none()
44            && (payload.get("result").is_some() || payload.get("error").is_some());
45        if is_response {
46            sink.push(
47                Some(event.seq),
48                "client sent a JSON-RPC response; 2026-07-28 removed server-initiated \
49                 requests, so there is nothing for one to answer"
50                    .to_owned(),
51            );
52        }
53    }
54}
55
56/// `TRAN-066`: the server sends no independent requests on a response stream.
57///
58/// Server-initiated requests are gone at this revision: what a server needs from
59/// a client it asks for through MRTR, inside the result of the client's own
60/// request. A server message carrying both `method` and a non-null `id` is
61/// therefore a request it had no way to issue.
62pub(in crate::checks) fn no_independent_server_requests(
63    context: &TraceContext<'_>,
64    sink: &mut FindingSink,
65) {
66    for (event, _, _) in context.messages() {
67        if event.direction != Direction::ServerToClient {
68            continue;
69        }
70        let Some(payload) = event.message_payload() else {
71            continue;
72        };
73        sink.examined();
74        if let Some(method) = payload.get("method").and_then(Value::as_str)
75            && payload.get("id").is_some_and(|id| !id.is_null())
76        {
77            sink.push(
78                Some(event.seq),
79                format!(
80                    "server sent an independent request `{method}`; 2026-07-28 replaces \
81                     server-initiated requests with MRTR input requests"
82                ),
83            );
84        }
85    }
86}
87
88/// `TRAN-068`: an SSE response carries `X-Accel-Buffering: no`.
89pub(in crate::checks) fn accel_buffering_header(
90    context: &TraceContext<'_>,
91    sink: &mut FindingSink,
92) {
93    for event in context.events() {
94        if event.direction != Direction::ServerToClient {
95            continue;
96        }
97        let EventBody::Http { headers, .. } = &event.body else {
98            continue;
99        };
100        let is_sse = headers
101            .get("content-type")
102            .is_some_and(|value| value.starts_with("text/event-stream"));
103        if !is_sse {
104            continue; // Only an event stream can be buffered by a proxy.
105        }
106        sink.examined();
107        if headers.get("x-accel-buffering").map(String::as_str) != Some("no") {
108            sink.push(
109                Some(event.seq),
110                "SSE response does not carry `X-Accel-Buffering: no`".to_owned(),
111            );
112        }
113    }
114}
115
116/// `TRAN-070`: nothing further is sent for a request whose stream was closed.
117///
118/// The revision makes closing a request's SSE response stream the cancellation
119/// signal (TRAN-069), so the recorded form of that signal is a transport close
120/// or abort on Streamable HTTP. Judged only against ids still outstanding when
121/// it happened: a message answering a request the server had already completed
122/// is a different defect, and not one this clause reaches.
123/// One pass over the events, flipping at the close rather than comparing
124/// sequence numbers against it. A `seq` comparison would be untestable here:
125/// the close is a lifecycle event, so no *message* can ever share its `seq`,
126/// and `<` versus `<=` would be a distinction no trace could exhibit.
127pub(in crate::checks) fn no_messages_after_cancellation(
128    context: &TraceContext<'_>,
129    sink: &mut FindingSink,
130) {
131    let mut outstanding: BTreeSet<String> = BTreeSet::new();
132    let mut closed_at: Option<u64> = None;
133    for event in context.events() {
134        if let Some(closed_at) = closed_at {
135            report_after_close(event, &outstanding, closed_at, sink);
136        } else if is_cancellation(event) {
137            closed_at = Some(event.seq);
138        } else {
139            track_outstanding(event, &mut outstanding);
140        }
141    }
142}
143
144/// Whether `event` is the recorded form of a response stream closing.
145fn is_cancellation(event: &mcp_conformance_core::trace::TraceEvent) -> bool {
146    let closed = matches!(
147        event.body,
148        EventBody::Lifecycle {
149            event: LifecycleEvent::TransportClose | LifecycleEvent::TransportAbort
150        }
151    );
152    closed && event.transport == TransportKind::StreamableHttp
153}
154
155/// Opens an id on a request and closes it on the answer, so `outstanding` holds
156/// exactly the ids in flight.
157fn track_outstanding(
158    event: &mcp_conformance_core::trace::TraceEvent,
159    outstanding: &mut BTreeSet<String>,
160) {
161    let Some(payload) = event.message_payload() else {
162        return;
163    };
164    let Some(id) = payload.get("id").filter(|id| !id.is_null()) else {
165        return;
166    };
167    if payload.get("method").is_some() {
168        outstanding.insert(id.to_string());
169    } else {
170        outstanding.remove(&id.to_string());
171    }
172}
173
174/// Reports a server message for an id that was still in flight at the close.
175fn report_after_close(
176    event: &mcp_conformance_core::trace::TraceEvent,
177    outstanding: &BTreeSet<String>,
178    closed_at: u64,
179    sink: &mut FindingSink,
180) {
181    if event.direction != Direction::ServerToClient {
182        return;
183    }
184    let Some(id) = event
185        .message_payload()
186        .and_then(|payload| payload.get("id"))
187        .filter(|id| !id.is_null())
188    else {
189        return;
190    };
191    // The subject is a server message carrying an id *after* the close: before
192    // one, nothing is forbidden, and a session with no close is untested.
193    sink.examined();
194    if outstanding.contains(&id.to_string()) {
195        sink.push(
196            Some(event.seq),
197            format!(
198                "server sent a further message for request id {id}, whose response \
199                 stream closed at seq {closed_at}; a close is cancellation at this revision"
200            ),
201        );
202    }
203}