Skip to main content

mcp_trace_validator/checks/transport/
post.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! The check that a client's JSON-RPC messages ride HTTP POSTs
5//! (`TRAN-049` at `2025-11-25`, `TRAN-056` at `2026-07-28`).
6//!
7//! Both clauses say the same thing in the same words — *the client MUST use
8//! HTTP POST to send JSON-RPC messages* — and both were excluded from judgment
9//! for one reason: the recorded `http` event did not carry the verb. It does
10//! now ([`EventBody::Http::method`]), so the obligation is judged rather than
11//! documented as invisible.
12//!
13//! **What binds a message to a request.** A trace states one order, `seq`, and
14//! the capture convention every recorder here follows is to write the request's
15//! `http` event and then the message it carried. So the request a client
16//! message rode is the nearest preceding client `http` event, and that is what
17//! this check reads. It examines a message only where such an event exists and
18//! records a method: a trace of bare messages with no HTTP framing (a
19//! host-side capture, or stdio) carries no evidence about verbs, and reports
20//! the clause *not observed* rather than passing it vacuously.
21//!
22//! **What stays excluded.** `TRAN-024`/`TRAN-055` add that each message must be
23//! a *new* POST — one request per message. That is connection framing the trace
24//! summarizes rather than reproduces, and no field added here recovers it, so
25//! those two keep their exclusions with the framing as the stated reason.
26//!
27//! [`EventBody::Http::method`]: mcp_conformance_core::trace::EventBody
28
29use mcp_conformance_core::trace::{Direction, EventBody, TransportKind};
30
31use super::super::FindingSink;
32use crate::context::TraceContext;
33
34/// `TRAN-049` / `TRAN-056`: every client JSON-RPC message travels by POST.
35pub(in crate::checks) fn client_messages_use_post(
36    context: &TraceContext<'_>,
37    sink: &mut FindingSink,
38) {
39    let mut carrying: Option<(u64, &str)> = None;
40    for event in context.events() {
41        if event.transport != TransportKind::StreamableHttp
42            || event.direction != Direction::ClientToServer
43        {
44            continue;
45        }
46        match &event.body {
47            EventBody::Http { method, .. } => {
48                carrying = method.as_deref().map(|method| (event.seq, method));
49            }
50            EventBody::Message { .. } => {
51                let Some((request_seq, method)) = carrying else {
52                    continue;
53                };
54                sink.examined();
55                if method != "POST" {
56                    sink.push(
57                        Some(event.seq),
58                        format!(
59                            "client JSON-RPC message rode an HTTP {method} request (seq \
60                             {request_seq}); messages must be sent by POST"
61                        ),
62                    );
63                }
64            }
65            _ => {}
66        }
67    }
68}
69
70#[cfg(test)]
71#[allow(clippy::unwrap_used)]
72mod tests {
73    use mcp_conformance_core::trace::TraceEvent;
74
75    use crate::checks;
76    use crate::context::TraceContext;
77    use crate::reader::{Limits, parse_trace};
78
79    const CHECK: &str = "transport.client-messages-use-post";
80
81    fn outcome(lines: &[String]) -> (Vec<String>, u32) {
82        let document = lines.join("\n");
83        let events: Vec<TraceEvent> = parse_trace(&document, &Limits::default()).unwrap();
84        let context = TraceContext::new(&events);
85        let outcome = checks::find(CHECK).unwrap().run(&context);
86        (
87            outcome
88                .findings
89                .into_iter()
90                .map(|finding| finding.detail)
91                .collect(),
92            outcome.subjects,
93        )
94    }
95
96    fn http(seq: u64, method: Option<&str>) -> String {
97        let verb = method.map_or_else(String::new, |method| format!(r#""method":"{method}","#));
98        format!(
99            r#"{{"seq":{seq},"direction":"client-to-server","transport":"streamable-http","kind":"http",{verb}"headers":{{}}}}"#
100        )
101    }
102
103    fn message(seq: u64) -> String {
104        format!(
105            r#"{{"seq":{seq},"direction":"client-to-server","transport":"streamable-http","kind":"message","payload":{{"jsonrpc":"2.0","id":1,"method":"tools/list"}}}}"#
106        )
107    }
108
109    #[test]
110    fn a_message_carried_by_a_post_passes() {
111        let (findings, subjects) = outcome(&[http(0, Some("POST")), message(1)]);
112        assert!(findings.is_empty(), "{findings:?}");
113        assert_eq!(subjects, 1);
114    }
115
116    #[test]
117    fn a_message_carried_by_another_verb_is_a_violation() {
118        let (findings, _) = outcome(&[http(0, Some("PUT")), message(1)]);
119        assert_eq!(findings.len(), 1, "{findings:?}");
120        assert!(findings[0].contains("PUT"), "{findings:?}");
121        assert!(findings[0].contains("seq 0"), "{findings:?}");
122    }
123
124    #[test]
125    fn a_teardown_delete_carries_no_message_and_is_not_judged() {
126        // The DELETE follows a conforming POST exchange and carries nothing, so
127        // nothing attributes it a message it never sent.
128        let (findings, subjects) =
129            outcome(&[http(0, Some("POST")), message(1), http(2, Some("DELETE"))]);
130        assert!(findings.is_empty(), "{findings:?}");
131        assert_eq!(subjects, 1, "only the POST's message was a subject");
132    }
133
134    #[test]
135    fn a_stream_opening_get_is_not_credited_with_the_previous_posts_message() {
136        // The GET resets what is carrying: a later message must not be blamed
137        // on it, and it must not be excused by an earlier POST.
138        let (findings, subjects) = outcome(&[
139            http(0, Some("POST")),
140            message(1),
141            http(2, Some("GET")),
142            message(3),
143        ]);
144        assert_eq!(findings.len(), 1, "{findings:?}");
145        assert!(findings[0].contains("GET"), "{findings:?}");
146        assert_eq!(subjects, 2);
147    }
148
149    #[test]
150    fn messages_with_no_recorded_request_are_not_judged() {
151        // A message-level capture (the host's own recorder, or stdio) carries
152        // no verb to judge, so the clause reports not-observed.
153        let (findings, subjects) = outcome(&[message(0), message(1)]);
154        assert!(findings.is_empty(), "{findings:?}");
155        assert_eq!(subjects, 0);
156
157        // An HTTP event whose method the capture dropped is the same case.
158        let (findings, subjects) = outcome(&[http(0, None), message(1)]);
159        assert!(findings.is_empty(), "{findings:?}");
160        assert_eq!(subjects, 0);
161    }
162
163    #[test]
164    fn a_servers_reply_is_not_the_clients_message() {
165        // The direction filter, pinned: a response arriving after the POST
166        // that carried the request must not be counted as a second message
167        // riding that POST. Without this the direction half of the scope guard
168        // is unobserved, and a build that dropped it would report twice the
169        // subjects it examined.
170        let reply = r#"{"seq":2,"direction":"server-to-client","transport":"streamable-http","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}}"#.to_owned();
171        let (findings, subjects) = outcome(&[http(0, Some("POST")), message(1), reply]);
172        assert!(findings.is_empty(), "{findings:?}");
173        assert_eq!(subjects, 1, "only the client's message rode the POST");
174    }
175
176    #[test]
177    fn stdio_messages_are_out_of_scope() {
178        let stdio = [
179            r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"tools/list"}}"#.to_owned(),
180        ];
181        let (findings, subjects) = outcome(&stdio);
182        assert!(findings.is_empty(), "{findings:?}");
183        assert_eq!(subjects, 0);
184    }
185}