Skip to main content

protocol_droid/domain/transport/
transport_spec.rs

1//! How a transport was named on a command line, as a value.
2//!
3//! The CLI's job is to turn `--upstream stdio:./server` into something the
4//! bridge can act on. `clap` can validate the *shape* of a string, but what
5//! arrives on this side is still text, and text is exactly the thing the
6//! project rules forbid from crossing into shared code. So the adapter parses
7//! it here, once, into this type, and everything downstream sees a
8//! [`TransportKind`] and a target it does not have to re-interpret.
9//!
10//! ## The grammar
11//!
12//! ```text
13//!   spec   := kind [ ":" target ]
14//!   kind   := "stdio" | "sse" | "http" | "http-stateless" | "streamable"
15//!   target := any non-empty, non-whitespace run
16//! ```
17//!
18//! Two decisions worth stating:
19//!
20//! **A missing target is an error for `stdio` and a default for the rest.**
21//! `stdio` *is* a target — the process on the other end — so `stdio` alone
22//! names nothing to connect to. `http://…` has an obvious default and making
23//! the user type it would be a papercut. Guessing the wrong way round fails
24//! late and confusingly, so the asymmetry is deliberate.
25//!
26//! **`sse` and `streamable` are distinct kinds, not synonyms.** They are
27//! different revisions' transports with different framing, different session
28//! semantics and different headers. Accepting one name for both would make a
29//! bridge silently pick a transport the user did not ask for, which is the
30//! worst thing a proxy can do.
31
32use core::fmt;
33use core::str::FromStr;
34use std::sync::Arc;
35
36use crate::domain::TransportKind;
37
38/// A transport named at an adapter boundary.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct TransportSpec {
41    /// Which transport this names.
42    kind: TransportKind,
43    /// Where it goes. Never empty.
44    target: Arc<str>,
45}
46
47impl TransportSpec {
48    /// A spec for `kind` pointed at `target`.
49    ///
50    /// # Errors
51    ///
52    /// [`SpecError::EmptyTarget`] when `target` is empty. A spec with no
53    /// target is a spec that cannot be acted on, and letting it exist means
54    /// the failure surfaces later as a connection to nothing.
55    pub fn new(kind: TransportKind, target: impl Into<Arc<str>>) -> Result<Self, SpecError> {
56        let target = target.into();
57        if target.is_empty() {
58            return Err(SpecError::EmptyTarget);
59        }
60        Ok(Self { kind, target })
61    }
62
63    /// The transport named.
64    #[must_use]
65    pub const fn kind(&self) -> TransportKind {
66        self.kind
67    }
68
69    /// Where it goes.
70    #[must_use]
71    pub fn target(&self) -> &str {
72        &self.target
73    }
74}
75
76/// Why a transport spec could not be read.
77#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
78pub enum SpecError {
79    /// The part before the `:` is not a transport this build knows.
80    #[error("{name:?} is not a transport this build speaks; expected one of {expected}")]
81    UnknownTransport {
82        /// What the caller wrote.
83        name: String,
84        /// What would have been accepted, in order.
85        expected: &'static str,
86    },
87
88    /// Nothing at all.
89    #[error("expected a transport, found nothing")]
90    Empty,
91
92    /// The part after the `:` was empty.
93    ///
94    /// Distinct from [`SpecError::Empty`] because `stdio:` is a caller who
95    /// clearly meant to name a target and did not, which is a different
96    /// mistake from naming no transport.
97    #[error("expected a target after the ':', found nothing")]
98    EmptyTarget,
99
100    /// A transport that needs a target was named without one.
101    #[error("{kind} names a process or a socket, so a target is required")]
102    MissingTarget {
103        /// The transport that was named.
104        kind: &'static str,
105    },
106}
107
108impl FromStr for TransportSpec {
109    type Err = SpecError;
110
111    /// Parses `kind[:target]`.
112    ///
113    /// # Errors
114    ///
115    /// Any [`SpecError`]: an unrecognised transport, nothing at all, or a
116    /// transport named without the target it needs.
117    fn from_str(input: &str) -> Result<Self, Self::Err> {
118        let trimmed = input.trim();
119        if trimmed.is_empty() {
120            return Err(SpecError::Empty);
121        }
122
123        let (name, target) = match trimmed.split_once(':') {
124            Some((name, target)) => (name, Some(target)),
125            None => (trimmed, None),
126        };
127
128        let kind = parse_kind(name)?;
129
130        // A remainder beginning `//` is the user writing a URL, not a target
131        // that happens to start with two slashes. `split_once` above split on
132        // the *scheme's* colon, so the scheme has to go back on — otherwise
133        // `http://host:3000/mcp` yields `//host:3000/mcp`, which is a
134        // protocol-relative URL, and every adapter that tries to build a
135        // request against it fails on a string the caller never wrote.
136        //
137        // This is deliberately keyed on `//` and not on the kind, so
138        // `sse:http://old.example/sse` — where the remainder already carries a
139        // full scheme and is left alone — still parses exactly as before.
140        let target = target.map(|rest| {
141            if rest.starts_with("//") {
142                format!("{name}:{rest}")
143            } else {
144                rest.to_owned()
145            }
146        });
147
148        match target.as_deref() {
149            Some(target) if !target.is_empty() => Self::new(kind, target),
150            Some(_) => Err(SpecError::EmptyTarget),
151            None if is_process(name) => Err(SpecError::MissingTarget {
152                kind: process_name(name).unwrap_or("stdio"),
153            }),
154            None => Self::new(kind, DEFAULT_TARGET_FOR_HTTP),
155        }
156    }
157}
158
159impl fmt::Display for TransportSpec {
160    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
161        let kind = self.kind.as_str();
162        // A URL-shaped target already carries its own scheme, so writing
163        // `kind:` in front of it produces `http:http://…`. The spec is the
164        // thing a user copies into a shell, so it has to read back as the one
165        // line that parses to this same spec.
166        if self.target.starts_with(&format!("{kind}:")) {
167            formatter.write_str(&self.target)
168        } else {
169            write!(formatter, "{kind}:{}", self.target)
170        }
171    }
172}
173
174/// The transport names accepted on a command line, in the order they are
175/// offered in an error message.
176const ACCEPTED: &str = "stdio, sse, http, http-stateless (alias: streamable)";
177
178fn parse_kind(name: &str) -> Result<TransportKind, SpecError> {
179    // The spellings are `TransportKind::as_str`, so the CLI and the error
180    // messages cannot drift from each other. `streamable` is accepted because
181    // it is what everyone actually types, and it is the *only* alias: the
182    // other three are short enough that a second name for each would be three
183    // more things to keep in sync for no gain.
184    match name {
185        "streamable" => Ok(TransportKind::StreamableHttp),
186        other => TransportKind::ALL
187            .into_iter()
188            .find(|kind| kind.as_str() == other)
189            .ok_or_else(|| SpecError::UnknownTransport {
190                name: other.to_owned(),
191                expected: ACCEPTED,
192            }),
193    }
194}
195
196/// Whether this transport cannot be named without saying where to go.
197///
198/// Derived from the spelling rather than from [`TransportKind`] because the
199/// caller named `http`, not `StreamableHttp` — the error has to quote back
200/// something they typed.
201fn is_process(name: &str) -> bool {
202    name == "stdio" || name == "sse"
203}
204
205/// The static spelling of a transport that names a process.
206///
207/// `MissingTarget` quotes back something the caller typed, and the error
208/// outlives the borrow it was built from, so it has to name a `&'static str`
209/// rather than the slice it was parsed out of.
210fn process_name(name: &str) -> Option<&'static str> {
211    match name {
212        "stdio" => Some("stdio"),
213        "sse" => Some("sse"),
214        _ => None,
215    }
216}
217
218/// Where an HTTP transport points when the caller did not say.
219const DEFAULT_TARGET_FOR_HTTP: &str = "http://127.0.0.1:3000/mcp";
220
221#[cfg(test)]
222mod tests {
223    use core::str::FromStr;
224
225    use crate::domain::TransportKind;
226
227    use super::{SpecError, TransportSpec};
228
229    type TestError = Box<dyn std::error::Error>;
230
231    /// Names the [`SpecError`] variant an input must be refused as.
232    ///
233    /// A function pointer rather than an expected `SpecError`, because the
234    /// cases are built as an array and constructing them all would need the
235    /// error values anyway — which is the thing under test.
236    type Expected = fn(&SpecError) -> bool;
237
238    /// The ordinary cases, each asserting both halves of the result — a parse
239    /// that got the kind right and the target wrong is not a successful parse.
240    #[test]
241    fn a_fully_qualified_spec_parses() -> Result<(), TestError> {
242        let cases: [(&str, TransportKind, &str); 4] = [
243            ("stdio:./server", TransportKind::Stdio, "./server"),
244            (
245                "http://srv.example/mcp",
246                TransportKind::StreamableHttp,
247                "http://srv.example/mcp",
248            ),
249            (
250                "sse:http://old.example/sse",
251                TransportKind::LegacySse,
252                "http://old.example/sse",
253            ),
254            (
255                "http-stateless:http://new.example/mcp",
256                TransportKind::StatelessHttp,
257                "http://new.example/mcp",
258            ),
259        ];
260
261        for (input, kind, target) in cases {
262            let spec = TransportSpec::from_str(input)?;
263            assert_eq!(spec.kind(), kind, "{input} named the wrong transport");
264            assert_eq!(spec.target(), target, "{input} took the wrong target");
265        }
266        Ok(())
267    }
268
269    /// `http` and `streamable` are two spellings of one transport, and the
270    /// target split on the *first* colon keeps a URL intact.
271    /// The port colon in a URL is part of the target, not another separator.
272    ///
273    /// This used to assert the target was `//example.com:8443/mcp`, which kept the
274    /// port and dropped the scheme. The port is the half of that claim that was
275    /// worth making; the scheme is what made the result unusable, and an adapter
276    /// building a request against it failed on a string the caller never wrote. The
277    /// caller wrote `http://example.com:8443/mcp`, and that is what is kept.
278    #[test]
279    fn a_url_target_keeps_its_own_colons() -> Result<(), TestError> {
280        let spec = TransportSpec::from_str("http://example.com:8443/mcp")?;
281
282        assert_eq!(spec.kind(), TransportKind::StreamableHttp);
283        assert_eq!(spec.target(), "http://example.com:8443/mcp");
284        Ok(())
285    }
286
287    /// The target a caller wrote is the target an adapter gets.
288    ///
289    /// `split_once(':')` finds the *scheme's* colon in `http://host/mcp`, so a
290    /// parser that stops there hands the adapter `//host/mcp` — a
291    /// protocol-relative URL, which `HttpClientConfig` rejects. This is the
292    /// test that would have caught it, and it asserts on the whole string for
293    /// the reason that matters: what leaves the parser is what the user wrote.
294    #[test]
295    fn a_url_target_survives_parsing_whole() -> Result<(), TestError> {
296        for input in [
297            "http://srv.example/mcp",
298            "http-stateless://srv.example/mcp",
299            "sse://old.example/sse",
300        ] {
301            let spec = TransportSpec::from_str(input)?;
302            assert_eq!(spec.target(), input, "{input} did not survive parsing");
303        }
304        Ok(())
305    }
306
307    /// Displayed as one line that parses back to the same spec.
308    ///
309    /// The round trip is the claim, not the string: a user copies this into a
310    /// shell, so a `Display` that differs from the accepted spelling is a
311    /// display that sends them somewhere else.
312    #[test]
313    fn a_spec_displays_as_something_that_parses_back() -> Result<(), TestError> {
314        for input in [
315            "stdio:./server",
316            "http://srv.example:8443/mcp",
317            "sse:http://old.example/sse",
318            "http-stateless:http://new.example/mcp",
319        ] {
320            let spec = TransportSpec::from_str(input)?;
321            let shown = spec.to_string();
322            assert_eq!(shown, input, "{input} displayed as {shown}");
323            assert_eq!(
324                TransportSpec::from_str(&shown)?,
325                spec,
326                "{shown} did not parse back to the same spec"
327            );
328        }
329        Ok(())
330    }
331
332    /// Each malformed input has its own variant. Asserting on the variant is
333    /// the point: a test that only checked "did it fail" would pass no matter
334    /// how wrong the message was.
335    #[test]
336    fn malformed_input_is_refused_by_kind() -> Result<(), TestError> {
337        let cases: [(&str, Expected); 4] = [
338            ("", |e| matches!(e, SpecError::Empty)),
339            ("   ", |e| matches!(e, SpecError::Empty)),
340            ("carrier-pigeon", |e| {
341                matches!(e, SpecError::UnknownTransport { .. })
342            }),
343            ("stdio:", |e| matches!(e, SpecError::EmptyTarget)),
344        ];
345
346        for (input, expected) in cases {
347            let Err(refusal) = TransportSpec::from_str(input) else {
348                return Err(format!("{input:?} should have been refused").into());
349            };
350            assert!(
351                expected(&refusal),
352                "{input:?} was refused as {refusal:?}, which is not its own case"
353            );
354        }
355        Ok(())
356    }
357
358    /// `stdio` names a process, so `stdio` alone names nothing to connect to.
359    /// `http` has an obvious default, so `http` alone is fine. The asymmetry is
360    /// deliberate and is worth pinning.
361    #[test]
362    fn a_bare_stdio_spec_is_refused_and_a_bare_http_spec_is_not() -> Result<(), TestError> {
363        let Err(refusal) = TransportSpec::from_str("stdio") else {
364            return Err("a bare stdio spec should be refused".into());
365        };
366        assert_eq!(refusal, SpecError::MissingTarget { kind: "stdio" });
367
368        let http = TransportSpec::from_str("http")?;
369        assert_eq!(http.target(), "http://127.0.0.1:3000/mcp");
370        Ok(())
371    }
372
373    /// `sse` is the legacy transport and also names a process or socket.
374    #[test]
375    fn a_bare_sse_spec_is_refused() {
376        assert_eq!(
377            TransportSpec::from_str("sse"),
378            Err(SpecError::MissingTarget { kind: "sse" })
379        );
380    }
381
382    /// Round-tripping through `Display` must reproduce what was parsed, or the
383    /// error messages a bridge prints will name a transport that does not
384    /// exist.
385    #[test]
386    fn display_reproduces_a_parsed_spec() -> Result<(), TestError> {
387        for input in ["stdio:./server", "sse:http://old.example/sse"] {
388            let spec = TransportSpec::from_str(input)?;
389            assert_eq!(spec.to_string(), input);
390        }
391        Ok(())
392    }
393}