protocol_droid/domain/transport/mod.rs
1//! `TransportKind` — the four transports the specification has ever defined,
2//! and which revision defines each.
3//!
4//! # Where the boundaries come from
5//!
6//! Every entry below cites the vendored changelog that moved the boundary,
7//! because these are the kind of facts that are wrong in the model's memory and
8//! right in the repository:
9//!
10//! | Transport | Defined for | Source |
11//! | -------------------- | ----------------- | ------ |
12//! | [`Stdio`] | every revision | The first revision defines it; `2025-11-25` clarifies that servers may log to stderr, and `2026-07-28` still specifies `server/discover` "on STDIO" |
13//! | [`LegacySse`] | `2024-11-05` only | `2025-03-26` changelog: "Replaced the previous HTTP+SSE transport with a more flexible Streamable HTTP transport" |
14//! | [`StreamableHttp`] | `2025-03-26` onward | Same changelog entry; `2026-07-28` then removes protocol-level sessions and SSE resumability from it |
15//! | [`StatelessHttp`] | `2026-07-28` onward | `2026-07-28` changelog: "Make MCP stateless: remove the `initialize`/`notifications/initialized` handshake" |
16//!
17//! [`Stdio`]: Self::Stdio
18//! [`LegacySse`]: Self::LegacySse
19//! [`StreamableHttp`]: Self::StreamableHttp
20//! [`StatelessHttp`]: Self::StatelessHttp
21//!
22//! # What else is here
23//!
24//! [`framing`] — the byte-level delimiter, which is not an MCP concept and
25//! carries no revision. It lives beside `TransportKind` rather than under an
26//! adapter because the stdio server and the stdio client are *both* its
27//! consumers, and a shared piece of a transport that only one of the two can
28//! reach is a shared piece of a transport that one of them has to fork.
29
30// Private, like this module: the items are re-exported by name rather than the
31// module itself, so `unreachable_pub` does not apply and there is exactly one
32// path to each of them.
33mod endpoint;
34mod framing;
35mod rate_limit;
36mod transport_spec;
37
38pub use endpoint::{MessageEndpoint, MessageEndpointError};
39pub use framing::{FrameDecoder, FramingError, MaxFrameSize, MaxFrameSizeError, encode_frame};
40pub use rate_limit::{RateLimit, Verdict, check as check_rate_limit};
41pub use transport_spec::{SpecError, TransportSpec};
42
43use core::fmt;
44
45use crate::domain::ProtocolVersion;
46
47/// A transport the specification defines.
48///
49/// Ordered by the revision that introduced it, so the declaration order is
50/// chronological and `Ord` is derived rather than written. See
51/// `tests::derived_ordering_agrees_in_both_directions`.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
53pub enum TransportKind {
54 /// Newline-delimited JSON over a child process's standard streams.
55 ///
56 /// Defined by every revision, `2024-11-05` through the newest. It predates
57 /// the HTTP transports and is not a lifecycle the protocol version changes.
58 Stdio,
59 /// HTTP+SSE as defined for `2024-11-05`, where the client opened a GET
60 /// stream and the server held a single POST endpoint.
61 ///
62 /// Removed in `2025-03-26`. Kept as a distinct variant rather than folded
63 /// into [`StreamableHttp`] because a bridge that receives a message from
64 /// one of these has to be able to say so, and because it is the only
65 /// revision that can require this branch to exist.
66 ///
67 /// [`StreamableHttp`]: Self::StreamableHttp
68 LegacySse,
69 /// The stateful Streamable HTTP transport introduced in `2025-03-26`: a
70 /// single endpoint that upgrades to SSE, with an `Mcp-Session-Id` header
71 /// tying calls together.
72 StreamableHttp,
73 /// The stateless lifecycle introduced in `2026-07-28`.
74 ///
75 /// Distinct from [`StreamableHttp`] rather than a mode of it, because the
76 /// two differ in what the session tracks: this one has no protocol-level
77 /// session at all, every request carries its version and capabilities in
78 /// `_meta`, and there is no `initialize` handshake. Code that caches per
79 /// session cannot treat them alike.
80 ///
81 /// [`StreamableHttp`]: Self::StreamableHttp
82 StatelessHttp,
83}
84
85impl TransportKind {
86 /// Every transport, in the order the revisions introduced them.
87 pub const ALL: [Self; 4] = [
88 Self::Stdio,
89 Self::LegacySse,
90 Self::StreamableHttp,
91 Self::StatelessHttp,
92 ];
93
94 /// The transport's short name, for logs, errors and CLI arguments.
95 ///
96 /// These are this project's own vocabulary rather than the specification's
97 /// — the spec never names its transports in a versioned field — so
98 /// `StreamableHttp` is `http` and `StatelessHttp` is `http-stateless`. The
99 /// CLI surfaces the same spellings, and changing one means changing both.
100 #[must_use]
101 pub const fn as_str(self) -> &'static str {
102 match self {
103 Self::Stdio => "stdio",
104 Self::LegacySse => "sse",
105 Self::StreamableHttp => "http",
106 Self::StatelessHttp => "http-stateless",
107 }
108 }
109}
110
111impl fmt::Display for TransportKind {
112 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
113 formatter.write_str(self.as_str())
114 }
115}
116
117/// Whether the specification defines `transport` for `version`.
118///
119/// A pure predicate over the two value objects: no session, no negotiation, no
120/// I/O. T06 consults it when deciding what a negotiated pair can be run over,
121/// and the adapters consult it when deciding whether a transport is even
122/// registerable.
123///
124/// # Unknown revisions deny
125///
126/// [`ProtocolVersion::Unknown`] returns `false` for every transport, and that
127/// is the load-bearing decision rather than an omission. This build has no wire
128/// types for an unknown revision, so nothing can be lowered to it, and the
129/// project's posture is fail-secure default deny. The tempting alternative —
130/// assuming a future revision keeps the transports its predecessors had — is
131/// exactly the confident wrong the crate exists to avoid: a revision is free to
132/// remove a transport, and a client that assumed otherwise would speak a
133/// transport the peer had deleted.
134///
135/// A peer that offers only unknown revisions is not left hanging, either:
136/// [`SupportedVersions::best_match`](crate::domain::SupportedVersions::best_match) picks the newest revision both sides
137/// actually share, and JSON-RPC has an error code for there being none.
138///
139/// # Examples
140///
141/// ```
142/// use protocol_droid::domain::{ProtocolVersion, TransportKind, transport_allowed};
143///
144/// assert!(transport_allowed(ProtocolVersion::V2024_11_05, TransportKind::LegacySse));
145/// assert!(!transport_allowed(ProtocolVersion::V2025_03_26, TransportKind::LegacySse));
146/// assert!(transport_allowed(ProtocolVersion::V2026_07_28, TransportKind::StatelessHttp));
147/// assert!(!transport_allowed(ProtocolVersion::V2025_11_25, TransportKind::StatelessHttp));
148/// ```
149#[must_use]
150pub const fn transport_allowed(version: ProtocolVersion, transport: TransportKind) -> bool {
151 match version {
152 // Every revision defines stdio; nothing in any changelog removes it.
153 ProtocolVersion::V2024_11_05 => {
154 matches!(transport, TransportKind::Stdio | TransportKind::LegacySse)
155 }
156 ProtocolVersion::V2025_03_26
157 | ProtocolVersion::V2025_06_18
158 | ProtocolVersion::V2025_11_25 => {
159 matches!(
160 transport,
161 TransportKind::Stdio | TransportKind::StreamableHttp
162 )
163 }
164 ProtocolVersion::V2026_07_28 => matches!(
165 transport,
166 TransportKind::Stdio | TransportKind::StreamableHttp | TransportKind::StatelessHttp
167 ),
168 // No wire types for an unknown revision, so nothing can be served.
169 ProtocolVersion::Unknown(_) => false,
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 // Test code unwraps deliberately: the strict profile exists to keep
176 // production paths fallible, and a test that cannot unwrap cannot assert.
177 // The messages name the failing precondition, which is the useful part.
178 #![expect(
179 clippy::unwrap_used,
180 reason = "tests assert on fallible calls and panic with the failing precondition; propagating errors would replace a clear failure message with noise"
181 )]
182
183 use super::{TransportKind, transport_allowed};
184 use crate::domain::{ProtocolVersion, ReleaseDate};
185
186 /// The 1.98 trap: `derive(PartialOrd)` has a fast path that assumes a
187 /// hand-written `PartialOrd` agrees with a derived `Ord`. A pair that
188 /// disagrees silently yields wrong comparisons instead of a compile error.
189 /// Every ordered type in this crate derives both traits from the same
190 /// variant order and copies this test when it is added.
191 #[test]
192 fn derived_ordering_agrees_in_both_directions() {
193 for left in TransportKind::ALL {
194 for right in TransportKind::ALL {
195 let total = left.cmp(&right);
196 assert_eq!(left.partial_cmp(&right), Some(total));
197 assert_eq!(left < right, total.is_lt());
198 assert_eq!(left > right, total.is_gt());
199 }
200 }
201 assert!(TransportKind::Stdio < TransportKind::LegacySse);
202 assert!(TransportKind::LegacySse < TransportKind::StreamableHttp);
203 assert!(TransportKind::StreamableHttp < TransportKind::StatelessHttp);
204 }
205
206 /// The exhaustive table the task asks for: every `(version, transport)`
207 /// pair, asserted. Written as a table rather than as a pile of `assert!`
208 /// calls so that adding a transport or a revision shows up as a diff that
209 /// has to be answered, instead of as a silently untested pair.
210 #[test]
211 fn the_whole_matrix_is_what_the_changelogs_say() {
212 // Stdio SSE Streamable Stateless
213 let expected: [[bool; 4]; 5] = [
214 // 2024-11-05 — stdio and the legacy HTTP+SSE transport.
215 [true, true, false, false],
216 // 2025-03-26 — HTTP+SSE replaced by Streamable HTTP.
217 [true, false, true, false],
218 // 2025-06-18 — unchanged.
219 [true, false, true, false],
220 // 2025-11-25 — unchanged.
221 [true, false, true, false],
222 // 2026-07-28 — the stateless lifecycle arrives.
223 [true, false, true, true],
224 ];
225 for (version, row) in ProtocolVersion::KNOWN.iter().zip(expected) {
226 for (transport, allowed) in TransportKind::ALL.iter().zip(row) {
227 assert_eq!(
228 transport_allowed(*version, *transport),
229 allowed,
230 "{version} over {transport}"
231 );
232 }
233 }
234 }
235
236 /// The fail-secure decision, asserted from both sides: nothing is allowed,
237 /// so nothing has to be argued about per transport.
238 #[test]
239 fn unknown_revisions_allow_nothing() {
240 for date in ["2027-01-01", "2030-12-31", "2999-01-01"] {
241 let version = ProtocolVersion::Unknown(date.parse::<ReleaseDate>().unwrap());
242 for transport in TransportKind::ALL {
243 assert!(
244 !transport_allowed(version, transport),
245 "{version} must not allow {transport}"
246 );
247 }
248 }
249 }
250
251 #[test]
252 fn stdio_is_defined_for_every_known_revision() {
253 for version in ProtocolVersion::KNOWN {
254 assert!(
255 transport_allowed(version, TransportKind::Stdio),
256 "{version}"
257 );
258 }
259 }
260
261 #[test]
262 fn short_names_are_stable_and_distinct() {
263 let names: Vec<&str> = TransportKind::ALL.iter().map(|t| t.as_str()).collect();
264 assert_eq!(names, vec!["stdio", "sse", "http", "http-stateless"]);
265 let unique: std::collections::BTreeSet<&str> = names.iter().copied().collect();
266 assert_eq!(unique.len(), names.len(), "short names collide");
267 for transport in TransportKind::ALL {
268 assert_eq!(transport.to_string(), transport.as_str());
269 }
270 }
271}