Skip to main content

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}