use core::fmt;
use core::str::FromStr;
use std::sync::Arc;
use crate::domain::TransportKind;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TransportSpec {
kind: TransportKind,
target: Arc<str>,
}
impl TransportSpec {
pub fn new(kind: TransportKind, target: impl Into<Arc<str>>) -> Result<Self, SpecError> {
let target = target.into();
if target.is_empty() {
return Err(SpecError::EmptyTarget);
}
Ok(Self { kind, target })
}
#[must_use]
pub const fn kind(&self) -> TransportKind {
self.kind
}
#[must_use]
pub fn target(&self) -> &str {
&self.target
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SpecError {
#[error("{name:?} is not a transport this build speaks; expected one of {expected}")]
UnknownTransport {
name: String,
expected: &'static str,
},
#[error("expected a transport, found nothing")]
Empty,
#[error("expected a target after the ':', found nothing")]
EmptyTarget,
#[error("{kind} names a process or a socket, so a target is required")]
MissingTarget {
kind: &'static str,
},
}
impl FromStr for TransportSpec {
type Err = SpecError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(SpecError::Empty);
}
let (name, target) = match trimmed.split_once(':') {
Some((name, target)) => (name, Some(target)),
None => (trimmed, None),
};
let kind = parse_kind(name)?;
let target = target.map(|rest| {
if rest.starts_with("//") {
format!("{name}:{rest}")
} else {
rest.to_owned()
}
});
match target.as_deref() {
Some(target) if !target.is_empty() => Self::new(kind, target),
Some(_) => Err(SpecError::EmptyTarget),
None if is_process(name) => Err(SpecError::MissingTarget {
kind: process_name(name).unwrap_or("stdio"),
}),
None => Self::new(kind, DEFAULT_TARGET_FOR_HTTP),
}
}
}
impl fmt::Display for TransportSpec {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let kind = self.kind.as_str();
if self.target.starts_with(&format!("{kind}:")) {
formatter.write_str(&self.target)
} else {
write!(formatter, "{kind}:{}", self.target)
}
}
}
const ACCEPTED: &str = "stdio, sse, http, http-stateless (alias: streamable)";
fn parse_kind(name: &str) -> Result<TransportKind, SpecError> {
match name {
"streamable" => Ok(TransportKind::StreamableHttp),
other => TransportKind::ALL
.into_iter()
.find(|kind| kind.as_str() == other)
.ok_or_else(|| SpecError::UnknownTransport {
name: other.to_owned(),
expected: ACCEPTED,
}),
}
}
fn is_process(name: &str) -> bool {
name == "stdio" || name == "sse"
}
fn process_name(name: &str) -> Option<&'static str> {
match name {
"stdio" => Some("stdio"),
"sse" => Some("sse"),
_ => None,
}
}
const DEFAULT_TARGET_FOR_HTTP: &str = "http://127.0.0.1:3000/mcp";
#[cfg(test)]
mod tests {
use core::str::FromStr;
use crate::domain::TransportKind;
use super::{SpecError, TransportSpec};
type TestError = Box<dyn std::error::Error>;
type Expected = fn(&SpecError) -> bool;
#[test]
fn a_fully_qualified_spec_parses() -> Result<(), TestError> {
let cases: [(&str, TransportKind, &str); 4] = [
("stdio:./server", TransportKind::Stdio, "./server"),
(
"http://srv.example/mcp",
TransportKind::StreamableHttp,
"http://srv.example/mcp",
),
(
"sse:http://old.example/sse",
TransportKind::LegacySse,
"http://old.example/sse",
),
(
"http-stateless:http://new.example/mcp",
TransportKind::StatelessHttp,
"http://new.example/mcp",
),
];
for (input, kind, target) in cases {
let spec = TransportSpec::from_str(input)?;
assert_eq!(spec.kind(), kind, "{input} named the wrong transport");
assert_eq!(spec.target(), target, "{input} took the wrong target");
}
Ok(())
}
#[test]
fn a_url_target_keeps_its_own_colons() -> Result<(), TestError> {
let spec = TransportSpec::from_str("http://example.com:8443/mcp")?;
assert_eq!(spec.kind(), TransportKind::StreamableHttp);
assert_eq!(spec.target(), "http://example.com:8443/mcp");
Ok(())
}
#[test]
fn a_url_target_survives_parsing_whole() -> Result<(), TestError> {
for input in [
"http://srv.example/mcp",
"http-stateless://srv.example/mcp",
"sse://old.example/sse",
] {
let spec = TransportSpec::from_str(input)?;
assert_eq!(spec.target(), input, "{input} did not survive parsing");
}
Ok(())
}
#[test]
fn a_spec_displays_as_something_that_parses_back() -> Result<(), TestError> {
for input in [
"stdio:./server",
"http://srv.example:8443/mcp",
"sse:http://old.example/sse",
"http-stateless:http://new.example/mcp",
] {
let spec = TransportSpec::from_str(input)?;
let shown = spec.to_string();
assert_eq!(shown, input, "{input} displayed as {shown}");
assert_eq!(
TransportSpec::from_str(&shown)?,
spec,
"{shown} did not parse back to the same spec"
);
}
Ok(())
}
#[test]
fn malformed_input_is_refused_by_kind() -> Result<(), TestError> {
let cases: [(&str, Expected); 4] = [
("", |e| matches!(e, SpecError::Empty)),
(" ", |e| matches!(e, SpecError::Empty)),
("carrier-pigeon", |e| {
matches!(e, SpecError::UnknownTransport { .. })
}),
("stdio:", |e| matches!(e, SpecError::EmptyTarget)),
];
for (input, expected) in cases {
let Err(refusal) = TransportSpec::from_str(input) else {
return Err(format!("{input:?} should have been refused").into());
};
assert!(
expected(&refusal),
"{input:?} was refused as {refusal:?}, which is not its own case"
);
}
Ok(())
}
#[test]
fn a_bare_stdio_spec_is_refused_and_a_bare_http_spec_is_not() -> Result<(), TestError> {
let Err(refusal) = TransportSpec::from_str("stdio") else {
return Err("a bare stdio spec should be refused".into());
};
assert_eq!(refusal, SpecError::MissingTarget { kind: "stdio" });
let http = TransportSpec::from_str("http")?;
assert_eq!(http.target(), "http://127.0.0.1:3000/mcp");
Ok(())
}
#[test]
fn a_bare_sse_spec_is_refused() {
assert_eq!(
TransportSpec::from_str("sse"),
Err(SpecError::MissingTarget { kind: "sse" })
);
}
#[test]
fn display_reproduces_a_parsed_spec() -> Result<(), TestError> {
for input in ["stdio:./server", "sse:http://old.example/sse"] {
let spec = TransportSpec::from_str(input)?;
assert_eq!(spec.to_string(), input);
}
Ok(())
}
}