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}