use net::http::{self, SseEvent, Url};
#[cfg(feature = "tls")]
use net::tls::ClientIdentity;
use serde_json::Value;
use std::io;
use std::sync::Mutex;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpEndpoint {
Tcp {
host: String,
port: u16,
tls: bool,
path: String,
host_header: String,
},
Unix { socket: String, path: String },
Vsock { cid: u32, port: u32, path: String },
}
impl McpEndpoint {
pub fn parse(s: &str) -> Result<McpEndpoint, String> {
if let Some(sock) = s.strip_prefix("unix:") {
if sock.is_empty() {
return Err(format!("empty unix socket path: {s}"));
}
return Ok(McpEndpoint::Unix {
socket: sock.to_string(),
path: "/".to_string(),
});
}
if let Some(rest) = s.strip_prefix("vsock:") {
let (cid, port) = rest
.split_once(':')
.and_then(|(c, p)| Some((c.trim().parse().ok()?, p.trim().parse().ok()?)))
.ok_or_else(|| format!("bad vsock endpoint (want vsock:cid:port): {s}"))?;
return Ok(McpEndpoint::Vsock {
cid,
port,
path: "/".to_string(),
});
}
let url = Url::parse(s)?;
Ok(McpEndpoint::Tcp {
tls: url.is_tls(),
host_header: url.host_header(),
host: url.host,
port: url.port,
path: url.path,
})
}
pub fn scheme(&self) -> &'static str {
match self {
McpEndpoint::Tcp { tls: true, .. } => "https",
McpEndpoint::Tcp { tls: false, .. } => "http",
McpEndpoint::Unix { .. } => "unix",
McpEndpoint::Vsock { .. } => "vsock",
}
}
fn http_path(&self) -> &str {
match self {
McpEndpoint::Tcp { path, .. }
| McpEndpoint::Unix { path, .. }
| McpEndpoint::Vsock { path, .. } => path,
}
}
fn host_header(&self) -> &str {
match self {
McpEndpoint::Tcp { host_header, .. } => host_header,
McpEndpoint::Unix { .. } | McpEndpoint::Vsock { .. } => "localhost",
}
}
}
#[derive(Debug)]
pub enum HttpError {
Connect(io::Error),
Http(io::Error),
Status(u16, Vec<u8>),
Unsupported(String),
NoResponse,
}
impl std::fmt::Display for HttpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HttpError::Connect(e) => write!(f, "mcp-http: connect: {e}"),
HttpError::Http(e) => write!(f, "mcp-http: {e}"),
HttpError::Status(s, _) => write!(f, "mcp-http: server returned HTTP {s}"),
HttpError::Unsupported(m) => write!(f, "mcp-http: {m}"),
HttpError::NoResponse => write!(f, "mcp-http: no JSON-RPC response before stream end"),
}
}
}
impl std::error::Error for HttpError {}
pub fn authority_of(endpoint: &str) -> String {
McpEndpoint::parse(endpoint)
.map(|e| e.host_header().to_string())
.unwrap_or_default()
}
enum SendOutcome {
Result(Option<Value>),
Error(HttpError),
RetryAuth,
}
#[derive(Debug, Clone, Default)]
pub struct AuthResponse {
pub status: u16,
pub requirement: Option<String>,
pub access: Option<String>,
pub location: Option<String>,
pub error: Option<String>,
}
pub trait RequestSigner: Send + Sync {
fn sign(&self, method: &str, authority: &str, path: &str, body: &[u8])
-> Vec<(String, String)>;
fn on_response(&self, _resp: &AuthResponse, _authority: &str) -> bool {
false
}
fn capabilities(&self) -> Option<String> {
None
}
fn wants_content_digest(&self, _authority: &str) -> bool {
false
}
}
pub struct HttpTransport {
endpoint: McpEndpoint,
headers: Vec<(String, String)>,
#[cfg(feature = "tls")]
identity: Option<ClientIdentity>,
session: Mutex<Option<String>>,
protocol_version: Mutex<Option<String>>,
signer: Option<std::sync::Arc<dyn RequestSigner>>,
}
impl HttpTransport {
pub fn new(endpoint: McpEndpoint, headers: Vec<(String, String)>) -> Self {
HttpTransport {
endpoint,
headers,
#[cfg(feature = "tls")]
identity: None,
session: Mutex::new(None),
protocol_version: Mutex::new(None),
signer: None,
}
}
pub fn with_signer(mut self, signer: Option<std::sync::Arc<dyn RequestSigner>>) -> Self {
self.signer = signer;
self
}
#[cfg(feature = "tls")]
pub fn set_identity(&mut self, identity: Option<ClientIdentity>) {
self.identity = identity;
}
pub fn set_protocol_version(&self, version: String) {
*self
.protocol_version
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some(version);
}
pub fn clear_protocol_version(&self) {
*self
.protocol_version
.lock()
.unwrap_or_else(|e| e.into_inner()) = None;
}
pub fn scheme(&self) -> &'static str {
self.endpoint.scheme()
}
fn connect(&self, timeout: Duration) -> Result<Box<dyn http::Stream>, HttpError> {
match &self.endpoint {
McpEndpoint::Tcp {
host, port, tls, ..
} => {
let tcp = http::connect_tcp(host, *port, timeout).map_err(HttpError::Connect)?;
if *tls {
#[cfg(feature = "tls")]
{
let s = net::tls::connect(tcp, host, self.identity.as_ref())
.map_err(HttpError::Connect)?;
Ok(Box::new(s))
}
#[cfg(not(feature = "tls"))]
{
Err(HttpError::Unsupported(
"https:// MCP requires building with --features tls".into(),
))
}
} else {
Ok(Box::new(tcp))
}
}
McpEndpoint::Unix { socket, .. } => {
let s = net::unixsock::connect(socket, timeout).map_err(HttpError::Connect)?;
Ok(Box::new(s))
}
McpEndpoint::Vsock { cid, port, .. } => {
#[cfg(feature = "vsock")]
{
let s =
net::vsock::connect(*cid, *port, timeout).map_err(HttpError::Connect)?;
Ok(Box::new(s))
}
#[cfg(not(feature = "vsock"))]
{
let _ = (cid, port);
Err(HttpError::Unsupported(
"vsock: MCP requires building with --features vsock".into(),
))
}
}
}
}
fn auth_headers(&self, method: &str, body: &[u8]) -> Vec<(String, String)> {
match &self.signer {
Some(s) => {
let authority = self.endpoint.host_header();
let mut sig = s.sign(method, authority, self.endpoint.http_path(), body);
if let Some(caps) = s.capabilities() {
sig.push(("AAuth-Capabilities".into(), caps));
}
sig
}
None => Vec::new(),
}
}
pub fn send<F: FnMut(Value)>(
&self,
request_id: Option<i64>,
body: &[u8],
timeout: Duration,
extra_headers: &[(&str, &str)],
mut on_notification: F,
) -> Result<Option<Value>, HttpError> {
const MAX_AUTH_ATTEMPTS: usize = 3;
let mut attempt = 0;
loop {
attempt += 1;
match self.send_once(
request_id,
body,
timeout,
extra_headers,
&mut on_notification,
)? {
SendOutcome::Result(v) => return Ok(v),
SendOutcome::Error(e) => return Err(e),
SendOutcome::RetryAuth if attempt < MAX_AUTH_ATTEMPTS => continue,
SendOutcome::RetryAuth => {
return match self.send_once(
request_id,
body,
timeout,
extra_headers,
&mut on_notification,
)? {
SendOutcome::Result(v) => Ok(v),
SendOutcome::Error(e) => Err(e),
SendOutcome::RetryAuth => Err(HttpError::NoResponse),
};
}
}
}
}
fn send_once<F: FnMut(Value)>(
&self,
request_id: Option<i64>,
body: &[u8],
timeout: Duration,
extra_headers: &[(&str, &str)],
on_notification: &mut F,
) -> Result<SendOutcome, HttpError> {
let mut stream = self.connect(timeout)?;
let mut headers: Vec<(&str, &str)> = vec![
("Content-Type", "application/json"),
("Accept", "application/json, text/event-stream"),
];
let session = self
.session
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
if let Some(sid) = &session {
headers.push(("Mcp-Session-Id", sid));
}
let protocol = self
.protocol_version
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
if let Some(v) = &protocol {
headers.push(("MCP-Protocol-Version", v));
}
for (k, v) in extra_headers {
headers.push((k, v));
}
for (k, v) in &self.headers {
headers.push((k.as_str(), v.as_str()));
}
let signed = self.auth_headers("POST", body);
for (k, v) in &signed {
headers.push((k.as_str(), v.as_str()));
}
let resp = http::send_streaming(
stream.as_mut(),
self.endpoint.host_header(),
"POST",
self.endpoint.http_path(),
&headers,
body,
)
.map_err(HttpError::Http)?;
if let Some(sid) = resp.header("mcp-session-id") {
*self.session.lock().unwrap_or_else(|e| e.into_inner()) = Some(sid.to_string());
}
if let Some(signer) = &self.signer {
let ar = AuthResponse {
status: resp.status,
requirement: resp.header("aauth-requirement").map(str::to_string),
access: resp.header("aauth-access").map(str::to_string),
location: resp.header("location").map(str::to_string),
error: resp
.header("signature-error")
.or_else(|| resp.header("aauth-error"))
.map(str::to_string),
};
if ar.requirement.is_some()
|| ar.access.is_some()
|| resp.status == 401
|| resp.status == 202
{
let authority = self.endpoint.host_header().to_string();
if signer.on_response(&ar, &authority) {
return Ok(SendOutcome::RetryAuth);
}
}
}
if !resp.is_success() {
let status = resp.status;
let body = resp.into_body().unwrap_or_default();
return Ok(SendOutcome::Error(HttpError::Status(status, body)));
}
if request_id.is_none() {
return Ok(SendOutcome::Result(None));
}
if resp.is_event_stream() {
let mut sse = resp.sse();
while let Some(ev) = sse.next_event().map_err(HttpError::Http)? {
if let Some(msg) = route_message(&ev, request_id, on_notification) {
return Ok(SendOutcome::Result(Some(msg)));
}
}
Ok(SendOutcome::Error(HttpError::NoResponse))
} else {
let bytes = resp.into_body().map_err(HttpError::Http)?;
let v: Value = serde_json::from_slice(&bytes)
.map_err(|e| HttpError::Http(io::Error::new(io::ErrorKind::InvalidData, e)))?;
Ok(SendOutcome::Result(Some(v)))
}
}
pub fn session_id(&self) -> Option<String> {
self.session
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
pub fn open_events(&self, read_timeout: Duration) -> Result<EventStream, HttpError> {
let stream = self.connect(read_timeout)?;
let mut headers: Vec<(&str, &str)> = vec![("Accept", "text/event-stream")];
let session = self
.session
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
if let Some(sid) = &session {
headers.push(("Mcp-Session-Id", sid));
}
let protocol = self
.protocol_version
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
if let Some(v) = &protocol {
headers.push(("MCP-Protocol-Version", v));
}
for (k, v) in &self.headers {
headers.push((k.as_str(), v.as_str()));
}
let signed = self.auth_headers("GET", b"");
for (k, v) in &signed {
headers.push((k.as_str(), v.as_str()));
}
let resp = http::send_streaming(
stream,
self.endpoint.host_header(),
"GET",
self.endpoint.http_path(),
&headers,
b"",
)
.map_err(HttpError::Http)?;
if !resp.is_success() {
let status = resp.status;
let body = resp.into_body().unwrap_or_default();
return Err(HttpError::Status(status, body));
}
if !resp.is_event_stream() {
return Err(HttpError::Unsupported(
"server has no GET SSE notification stream".into(),
));
}
Ok(resp.sse())
}
pub fn open_listen(
&self,
read_timeout: Duration,
body: &[u8],
routing: &[(&str, &str)],
) -> Result<EventStream, HttpError> {
let stream = self.connect(read_timeout)?;
let mut headers: Vec<(&str, &str)> = vec![
("Content-Type", "application/json"),
("Accept", "text/event-stream"),
];
let protocol = self
.protocol_version
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
if let Some(v) = &protocol {
headers.push(("MCP-Protocol-Version", v));
}
for (k, v) in routing {
headers.push((k, v));
}
for (k, v) in &self.headers {
headers.push((k.as_str(), v.as_str()));
}
let signed = self.auth_headers("POST", body);
for (k, v) in &signed {
headers.push((k.as_str(), v.as_str()));
}
let resp = http::send_streaming(
stream,
self.endpoint.host_header(),
"POST",
self.endpoint.http_path(),
&headers,
body,
)
.map_err(HttpError::Http)?;
if !resp.is_success() {
let status = resp.status;
let body = resp.into_body().unwrap_or_default();
return Err(HttpError::Status(status, body));
}
if !resp.is_event_stream() {
return Err(HttpError::Unsupported(
"subscriptions/listen did not return an SSE stream".into(),
));
}
Ok(resp.sse())
}
}
pub type EventStream = http::SseReader<std::io::BufReader<Box<dyn http::Stream>>>;
fn route_message<F: FnMut(Value)>(
ev: &SseEvent,
request_id: Option<i64>,
on_notification: &mut F,
) -> Option<Value> {
let v: Value = serde_json::from_str(&ev.data).ok()?;
let id_matches =
matches!((request_id, v.get("id").and_then(Value::as_i64)), (Some(a), Some(b)) if a == b);
if id_matches {
Some(v)
} else {
on_notification(v);
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_https_endpoint() {
let e = McpEndpoint::parse("https://mcp.example.com/mcp").unwrap();
assert_eq!(e.scheme(), "https");
assert_eq!(e.http_path(), "/mcp");
assert_eq!(e.host_header(), "mcp.example.com");
match e {
McpEndpoint::Tcp {
host, port, tls, ..
} => {
assert_eq!(host, "mcp.example.com");
assert_eq!(port, 443);
assert!(tls);
}
_ => panic!("expected Tcp"),
}
}
#[test]
fn parse_http_unix_vsock() {
assert_eq!(
McpEndpoint::parse("http://localhost:8080/mcp")
.unwrap()
.scheme(),
"http"
);
let u = McpEndpoint::parse("unix:/run/fs.sock").unwrap();
assert_eq!(u.scheme(), "unix");
assert_eq!(u.host_header(), "localhost");
assert_eq!(u.http_path(), "/");
let v = McpEndpoint::parse("vsock:3:5000").unwrap();
assert_eq!(v.scheme(), "vsock");
assert!(matches!(
v,
McpEndpoint::Vsock {
cid: 3,
port: 5000,
..
}
));
}
#[test]
fn parse_rejects_bad_endpoints() {
assert!(McpEndpoint::parse("unix:").is_err());
assert!(McpEndpoint::parse("vsock:nope").is_err());
assert!(McpEndpoint::parse("ftp://x/").is_err());
}
#[test]
fn route_message_matches_response_id_and_queues_notifications() {
let mut notes: Vec<Value> = Vec::new();
let n = SseEvent {
data: r#"{"jsonrpc":"2.0","method":"notifications/message","params":{}}"#.into(),
..Default::default()
};
assert!(route_message(&n, Some(1), &mut |v| notes.push(v)).is_none());
assert_eq!(notes.len(), 1);
let r = SseEvent {
data: r#"{"jsonrpc":"2.0","id":1,"result":{"ok":true}}"#.into(),
..Default::default()
};
let got = route_message(&r, Some(1), &mut |v| notes.push(v)).expect("response");
assert_eq!(got["result"]["ok"], true);
assert_eq!(notes.len(), 1, "response is not queued as a notification");
}
}