mcp/http.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2//! The MCP **Streamable HTTP** client transport.
3//!
4//! A conformant remote MCP server is reached by `POST`ing a JSON-RPC message to a
5//! single endpoint; the server replies with either a `application/json` body (one
6//! message) or a `text/event-stream` (SSE) carrying one or more messages. A
7//! server-assigned `Mcp-Session-Id` (returned on `initialize`) is echoed on every
8//! subsequent request. Server→client notifications ride an optional long-lived
9//! `GET` SSE stream.
10//!
11//! The transport is stream-agnostic (it reuses the hand-rolled [`net::http`]
12//! client): `https://` runs over TCP+TLS (optionally mutual TLS), `http://` over
13//! plain TCP (a local sidecar), `unix:` over a unix socket, and `vsock:` over
14//! AF_VSOCK. None of these spawns a process: the transport has no local exec
15//! surface, so a hostile server config cannot turn into command execution here.
16
17use net::http::{self, SseEvent, Url};
18#[cfg(feature = "tls")]
19use net::tls::ClientIdentity;
20use serde_json::Value;
21use std::io;
22use std::sync::Mutex;
23use std::time::Duration;
24
25/// A resolved MCP endpoint: where to connect + the HTTP `path`/`Host` to send.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum McpEndpoint {
28 /// `https://host[:port]/path` (TCP + TLS) or `http://…` (plain TCP).
29 Tcp {
30 host: String,
31 port: u16,
32 tls: bool,
33 path: String,
34 host_header: String,
35 },
36 /// `unix:/socket/path` — HTTP over a unix socket to a local sidecar.
37 Unix { socket: String, path: String },
38 /// `vsock:cid:port` — HTTP over AF_VSOCK to an enclave/microVM peer.
39 Vsock { cid: u32, port: u32, path: String },
40}
41
42impl McpEndpoint {
43 /// Parse a `--mcp name=<url>` endpoint. Accepts `https://`, `http://`,
44 /// `unix:/path`, and `vsock:cid:port`. For `unix:`/`vsock:` the HTTP request
45 /// path defaults to `/` (the sidecar routes); use `https://` for a specific
46 /// server path (e.g. `/mcp`).
47 pub fn parse(s: &str) -> Result<McpEndpoint, String> {
48 if let Some(sock) = s.strip_prefix("unix:") {
49 if sock.is_empty() {
50 return Err(format!("empty unix socket path: {s}"));
51 }
52 return Ok(McpEndpoint::Unix {
53 socket: sock.to_string(),
54 path: "/".to_string(),
55 });
56 }
57 if let Some(rest) = s.strip_prefix("vsock:") {
58 let (cid, port) = rest
59 .split_once(':')
60 .and_then(|(c, p)| Some((c.trim().parse().ok()?, p.trim().parse().ok()?)))
61 .ok_or_else(|| format!("bad vsock endpoint (want vsock:cid:port): {s}"))?;
62 return Ok(McpEndpoint::Vsock {
63 cid,
64 port,
65 path: "/".to_string(),
66 });
67 }
68 // http(s)
69 let url = Url::parse(s)?;
70 Ok(McpEndpoint::Tcp {
71 tls: url.is_tls(),
72 host_header: url.host_header(),
73 host: url.host,
74 port: url.port,
75 path: url.path,
76 })
77 }
78
79 /// The transport scheme name for the manifest/logs (never the address/creds).
80 pub fn scheme(&self) -> &'static str {
81 match self {
82 McpEndpoint::Tcp { tls: true, .. } => "https",
83 McpEndpoint::Tcp { tls: false, .. } => "http",
84 McpEndpoint::Unix { .. } => "unix",
85 McpEndpoint::Vsock { .. } => "vsock",
86 }
87 }
88
89 fn http_path(&self) -> &str {
90 match self {
91 McpEndpoint::Tcp { path, .. }
92 | McpEndpoint::Unix { path, .. }
93 | McpEndpoint::Vsock { path, .. } => path,
94 }
95 }
96
97 fn host_header(&self) -> &str {
98 match self {
99 McpEndpoint::Tcp { host_header, .. } => host_header,
100 McpEndpoint::Unix { .. } | McpEndpoint::Vsock { .. } => "localhost",
101 }
102 }
103}
104
105/// An MCP transport error (connect / HTTP / protocol).
106#[derive(Debug)]
107pub enum HttpError {
108 Connect(io::Error),
109 Http(io::Error),
110 /// A non-2xx HTTP status, with the (capped) response body — carried so the
111 /// caller can classify a modern JSON-RPC error (era detection, `-32022`
112 /// version retry) from the body rather than just the status code.
113 Status(u16, Vec<u8>),
114 /// The build lacks the feature this endpoint needs (e.g. `vsock`).
115 Unsupported(String),
116 /// No JSON-RPC response matched the request id before the stream ended.
117 NoResponse,
118}
119
120impl std::fmt::Display for HttpError {
121 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122 match self {
123 HttpError::Connect(e) => write!(f, "mcp-http: connect: {e}"),
124 HttpError::Http(e) => write!(f, "mcp-http: {e}"),
125 HttpError::Status(s, _) => write!(f, "mcp-http: server returned HTTP {s}"),
126 HttpError::Unsupported(m) => write!(f, "mcp-http: {m}"),
127 HttpError::NoResponse => write!(f, "mcp-http: no JSON-RPC response before stream end"),
128 }
129 }
130}
131impl std::error::Error for HttpError {}
132
133/// The `Host` authority (host[:port]) of an MCP endpoint URL — the `@authority`
134/// AAuth signs over. `localhost` for non-TCP endpoints. Best-effort (a parse
135/// failure yields an empty string).
136pub fn authority_of(endpoint: &str) -> String {
137 McpEndpoint::parse(endpoint)
138 .map(|e| e.host_header().to_string())
139 .unwrap_or_default()
140}
141
142/// The classification of one [`HttpTransport::send_once`] attempt (AAuth loop).
143enum SendOutcome {
144 /// A final JSON-RPC result (or `None` for a notification ack).
145 Result(Option<Value>),
146 /// A terminal transport/HTTP error.
147 Error(HttpError),
148 /// The signer satisfied an `AAuth-Requirement`; re-sign and retry.
149 RetryAuth,
150}
151
152/// The AAuth-relevant fields of a server response. Handed to
153/// [`RequestSigner::on_response`] so the signer can satisfy a runtime
154/// `AAuth-Requirement` and decide whether a retry would now succeed.
155#[derive(Debug, Clone, Default)]
156pub struct AuthResponse {
157 pub status: u16,
158 /// The `AAuth-Requirement` header value (e.g. `agent-token`,
159 /// `auth-token; resource-token="…"`, `interaction; url=…; code=…`).
160 pub requirement: Option<String>,
161 /// An opaque `AAuth-Access` token the server issued (Case B).
162 pub access: Option<String>,
163 /// A `Location` for a pending interaction (202) to poll.
164 pub location: Option<String>,
165 /// A `Signature-Error` / `AAuth-Error` detail (diagnostics only).
166 pub error: Option<String>,
167}
168
169/// A per-request AAuth signer. The transport calls [`sign`] just before each
170/// POST (the returned `(name, value)` pairs become request headers — the
171/// RFC 9421 `Signature-Input`/`Signature`/`Signature-Key`), and [`on_response`]
172/// after, to react to the server's `AAuth-Requirement` (adopt an access token,
173/// run the Person-Server flow) and re-sign+retry. Deliberately a trait, not an
174/// implementation: the crypto lives in the caller, so this crate stays free of
175/// any crypto dependency.
176pub trait RequestSigner: Send + Sync {
177 /// Sign one request. `authority` is the `Host` value (host[:port]); `path`
178 /// is the request-target. `body` is the JSON-RPC bytes (for a
179 /// `content-digest` cover when the server requires it). Returns headers to
180 /// add; an empty vec = send unsigned (let the server answer with its
181 /// requirement).
182 fn sign(&self, method: &str, authority: &str, path: &str, body: &[u8])
183 -> Vec<(String, String)>;
184 /// React to a response: adopt an `AAuth-Access` token, satisfy
185 /// an `AAuth-Requirement` (e.g. run the Person-Server exchange), and return
186 /// `true` iff the request should be RE-SIGNED and retried (a requirement was
187 /// newly satisfied). The default reacts to nothing. May do network I/O
188 /// (the PS token exchange).
189 fn on_response(&self, _resp: &AuthResponse, _authority: &str) -> bool {
190 false
191 }
192 /// An optional `AAuth-Capabilities` header value (interaction shapes the
193 /// agent can drive). `None` = omit.
194 fn capabilities(&self) -> Option<String> {
195 None
196 }
197 /// Whether this server requires a `content-digest` cover (learned at
198 /// discovery). The transport adds/covers the body digest when true.
199 fn wants_content_digest(&self, _authority: &str) -> bool {
200 false
201 }
202}
203
204/// The Streamable HTTP transport for one MCP server. Cheap to hold; each request
205/// opens a fresh connection (`Connection: close`), so there is no persistent
206/// socket to reap. `session` is set from the server's `Mcp-Session-Id` on the
207/// first response and echoed thereafter.
208pub struct HttpTransport {
209 endpoint: McpEndpoint,
210 /// Caller-owned auth + framing headers (e.g. `Authorization`, `x-api-key`).
211 /// Values may be secrets. They are never logged and never rendered into an
212 /// error: this transport only writes them onto the wire.
213 headers: Vec<(String, String)>,
214 /// A client identity for mutual TLS (TCP+TLS endpoints only).
215 #[cfg(feature = "tls")]
216 identity: Option<ClientIdentity>,
217 session: Mutex<Option<String>>,
218 /// The protocol version negotiated at `initialize`, echoed on every later
219 /// request as `MCP-Protocol-Version`, which Streamable HTTP requires. `None`
220 /// until the client sets it, so the `initialize` request itself carries no
221 /// header — there is no agreed version to declare before the handshake.
222 protocol_version: Mutex<Option<String>>,
223 /// An optional per-request AAuth signer. `None` = the endpoint is called
224 /// unsigned (the default; static-bearer/mTLS auth is unaffected).
225 signer: Option<std::sync::Arc<dyn RequestSigner>>,
226}
227
228impl HttpTransport {
229 pub fn new(endpoint: McpEndpoint, headers: Vec<(String, String)>) -> Self {
230 HttpTransport {
231 endpoint,
232 headers,
233 #[cfg(feature = "tls")]
234 identity: None,
235 session: Mutex::new(None),
236 protocol_version: Mutex::new(None),
237 signer: None,
238 }
239 }
240
241 /// Install a per-request signer (AAuth). Builder-style; call before use.
242 pub fn with_signer(mut self, signer: Option<std::sync::Arc<dyn RequestSigner>>) -> Self {
243 self.signer = signer;
244 self
245 }
246
247 /// Attach a mutual-TLS client identity (used only for `https://` endpoints).
248 #[cfg(feature = "tls")]
249 pub fn set_identity(&mut self, identity: Option<ClientIdentity>) {
250 self.identity = identity;
251 }
252
253 /// Record the negotiated protocol version, sent as `MCP-Protocol-Version` on
254 /// every subsequent request (called by the client after `initialize`/discovery).
255 pub fn set_protocol_version(&self, version: String) {
256 *self
257 .protocol_version
258 .lock()
259 .unwrap_or_else(|e| e.into_inner()) = Some(version);
260 }
261
262 /// Clear the negotiated version — the legacy `initialize` request must carry no
263 /// `MCP-Protocol-Version` header (nothing agreed yet), so this resets what a
264 /// prior modern probe set.
265 pub fn clear_protocol_version(&self) {
266 *self
267 .protocol_version
268 .lock()
269 .unwrap_or_else(|e| e.into_inner()) = None;
270 }
271
272 pub fn scheme(&self) -> &'static str {
273 self.endpoint.scheme()
274 }
275
276 /// Open a fresh connection to the endpoint as a boxed byte stream, applying
277 /// `timeout` as the connect + read/write bound (each request opens its own
278 /// connection, so the per-call timeout governs the whole exchange).
279 fn connect(&self, timeout: Duration) -> Result<Box<dyn http::Stream>, HttpError> {
280 match &self.endpoint {
281 McpEndpoint::Tcp {
282 host, port, tls, ..
283 } => {
284 let tcp = http::connect_tcp(host, *port, timeout).map_err(HttpError::Connect)?;
285 if *tls {
286 #[cfg(feature = "tls")]
287 {
288 let s = net::tls::connect(tcp, host, self.identity.as_ref())
289 .map_err(HttpError::Connect)?;
290 Ok(Box::new(s))
291 }
292 #[cfg(not(feature = "tls"))]
293 {
294 Err(HttpError::Unsupported(
295 "https:// MCP requires building with --features tls".into(),
296 ))
297 }
298 } else {
299 Ok(Box::new(tcp))
300 }
301 }
302 McpEndpoint::Unix { socket, .. } => {
303 // `net::unixsock::connect` exists on every platform (a non-unix
304 // build returns an Unsupported error), matching the intel path.
305 let s = net::unixsock::connect(socket, timeout).map_err(HttpError::Connect)?;
306 Ok(Box::new(s))
307 }
308 McpEndpoint::Vsock { cid, port, .. } => {
309 #[cfg(feature = "vsock")]
310 {
311 let s =
312 net::vsock::connect(*cid, *port, timeout).map_err(HttpError::Connect)?;
313 Ok(Box::new(s))
314 }
315 #[cfg(not(feature = "vsock"))]
316 {
317 let _ = (cid, port);
318 Err(HttpError::Unsupported(
319 "vsock: MCP requires building with --features vsock".into(),
320 ))
321 }
322 }
323 }
324 }
325
326 /// The AAuth headers for ONE dial: the signature over
327 /// `@method`/`@authority`/`@path` (over `body` too when the server requires a
328 /// content-digest cover), plus the optional `AAuth-Capabilities` advert.
329 /// Empty without a signer — the endpoint is then called unsigned.
330 ///
331 /// Every dial goes through here, not just the request POST: a signed server
332 /// answers an unsigned dial with a challenge, and on the long-lived
333 /// notification stream that failure is silent in the worst way — the daemon
334 /// keeps answering requests and simply never wakes.
335 fn auth_headers(&self, method: &str, body: &[u8]) -> Vec<(String, String)> {
336 match &self.signer {
337 Some(s) => {
338 let authority = self.endpoint.host_header();
339 let mut sig = s.sign(method, authority, self.endpoint.http_path(), body);
340 if let Some(caps) = s.capabilities() {
341 sig.push(("AAuth-Capabilities".into(), caps));
342 }
343 sig
344 }
345 None => Vec::new(),
346 }
347 }
348
349 /// POST one JSON-RPC message. For a REQUEST (`id` present), return the JSON-RPC
350 /// response with the matching id — parsed from the `application/json` body or
351 /// pumped out of the `text/event-stream` (queuing any interleaved
352 /// notifications via `on_notification`). For a NOTIFICATION (`id` absent), the
353 /// server replies `202 Accepted` with no body and `Ok(None)` is returned.
354 /// Captures/echoes `Mcp-Session-Id`.
355 pub fn send<F: FnMut(Value)>(
356 &self,
357 request_id: Option<i64>,
358 body: &[u8],
359 timeout: Duration,
360 extra_headers: &[(&str, &str)],
361 mut on_notification: F,
362 ) -> Result<Option<Value>, HttpError> {
363 // AAuth request loop: send signed; if the server answers
364 // with an `AAuth-Requirement` the signer can satisfy (adopt an access
365 // token, run the Person-Server exchange), re-sign and retry — bounded,
366 // so a mis-satisfied requirement cannot spin. Without a signer this is
367 // exactly one pass.
368 const MAX_AUTH_ATTEMPTS: usize = 3;
369 let mut attempt = 0;
370 loop {
371 attempt += 1;
372 match self.send_once(
373 request_id,
374 body,
375 timeout,
376 extra_headers,
377 &mut on_notification,
378 )? {
379 SendOutcome::Result(v) => return Ok(v),
380 SendOutcome::Error(e) => return Err(e),
381 SendOutcome::RetryAuth if attempt < MAX_AUTH_ATTEMPTS => continue,
382 // Out of retries: re-send once more unsigned-of-retry to surface
383 // the server's real error rather than looping.
384 SendOutcome::RetryAuth => {
385 return match self.send_once(
386 request_id,
387 body,
388 timeout,
389 extra_headers,
390 &mut on_notification,
391 )? {
392 SendOutcome::Result(v) => Ok(v),
393 SendOutcome::Error(e) => Err(e),
394 SendOutcome::RetryAuth => Err(HttpError::NoResponse),
395 };
396 }
397 }
398 }
399 }
400
401 /// One send attempt: build headers (+ AAuth signing), POST, and classify the
402 /// response — a parsed result, a terminal error, or `RetryAuth` (the signer
403 /// satisfied an `AAuth-Requirement`; the caller re-signs and retries).
404 fn send_once<F: FnMut(Value)>(
405 &self,
406 request_id: Option<i64>,
407 body: &[u8],
408 timeout: Duration,
409 extra_headers: &[(&str, &str)],
410 on_notification: &mut F,
411 ) -> Result<SendOutcome, HttpError> {
412 let mut stream = self.connect(timeout)?;
413 let mut headers: Vec<(&str, &str)> = vec![
414 ("Content-Type", "application/json"),
415 ("Accept", "application/json, text/event-stream"),
416 ];
417 let session = self
418 .session
419 .lock()
420 .unwrap_or_else(|e| e.into_inner())
421 .clone();
422 if let Some(sid) = &session {
423 headers.push(("Mcp-Session-Id", sid));
424 }
425 // MCP-Protocol-Version on every post-initialize request (a Streamable HTTP
426 // MUST). `None` only before/at initialize, when no version is agreed yet.
427 let protocol = self
428 .protocol_version
429 .lock()
430 .unwrap_or_else(|e| e.into_inner())
431 .clone();
432 if let Some(v) = &protocol {
433 headers.push(("MCP-Protocol-Version", v));
434 }
435 // Caller-supplied per-request headers (the modern era's Mcp-Method /
436 // Mcp-Name routing headers).
437 for (k, v) in extra_headers {
438 headers.push((k, v));
439 }
440 for (k, v) in &self.headers {
441 headers.push((k.as_str(), v.as_str()));
442 }
443 // AAuth request signing. The owned strings must outlive the borrowed
444 // header slice, so `signed` stays in scope until the request is sent.
445 let signed = self.auth_headers("POST", body);
446 for (k, v) in &signed {
447 headers.push((k.as_str(), v.as_str()));
448 }
449
450 let resp = http::send_streaming(
451 stream.as_mut(),
452 self.endpoint.host_header(),
453 "POST",
454 self.endpoint.http_path(),
455 &headers,
456 body,
457 )
458 .map_err(HttpError::Http)?;
459
460 // Adopt a server-assigned session id (initialize response).
461 if let Some(sid) = resp.header("mcp-session-id") {
462 *self.session.lock().unwrap_or_else(|e| e.into_inner()) = Some(sid.to_string());
463 }
464
465 // AAuth response reaction: let the signer adopt an access
466 // token / satisfy a requirement. `on_response` returns whether a retry
467 // would now differ. Only consulted when a signer is present AND the
468 // response carries an AAuth signal (a requirement, an access token, or a
469 // 401/202) — a plain success skips it.
470 if let Some(signer) = &self.signer {
471 let ar = AuthResponse {
472 status: resp.status,
473 requirement: resp.header("aauth-requirement").map(str::to_string),
474 access: resp.header("aauth-access").map(str::to_string),
475 location: resp.header("location").map(str::to_string),
476 error: resp
477 .header("signature-error")
478 .or_else(|| resp.header("aauth-error"))
479 .map(str::to_string),
480 };
481 if ar.requirement.is_some()
482 || ar.access.is_some()
483 || resp.status == 401
484 || resp.status == 202
485 {
486 let authority = self.endpoint.host_header().to_string();
487 if signer.on_response(&ar, &authority) {
488 return Ok(SendOutcome::RetryAuth);
489 }
490 }
491 }
492
493 if !resp.is_success() {
494 // Capture the body so the caller can classify a modern JSON-RPC error.
495 let status = resp.status;
496 let body = resp.into_body().unwrap_or_default();
497 return Ok(SendOutcome::Error(HttpError::Status(status, body)));
498 }
499
500 // A notification POST is acknowledged with an empty body (often 202).
501 if request_id.is_none() {
502 return Ok(SendOutcome::Result(None));
503 }
504
505 if resp.is_event_stream() {
506 let mut sse = resp.sse();
507 while let Some(ev) = sse.next_event().map_err(HttpError::Http)? {
508 if let Some(msg) = route_message(&ev, request_id, on_notification) {
509 return Ok(SendOutcome::Result(Some(msg)));
510 }
511 }
512 Ok(SendOutcome::Error(HttpError::NoResponse))
513 } else {
514 let bytes = resp.into_body().map_err(HttpError::Http)?;
515 let v: Value = serde_json::from_slice(&bytes)
516 .map_err(|e| HttpError::Http(io::Error::new(io::ErrorKind::InvalidData, e)))?;
517 Ok(SendOutcome::Result(Some(v)))
518 }
519 }
520
521 /// The session id the server assigned, if this connection has one.
522 pub fn session_id(&self) -> Option<String> {
523 self.session
524 .lock()
525 .unwrap_or_else(|e| e.into_inner())
526 .clone()
527 }
528
529 /// Open the long-lived server→client notification stream: a `GET` that the
530 /// server answers with `text/event-stream`, carrying JSON-RPC notifications
531 /// (e.g. `resources/updated`). Returns an owning SSE reader. `read_timeout`
532 /// bounds each read so the caller's loop can poll a stop flag between events
533 /// (clean shutdown). Errors if the server has no push channel (non-2xx or a
534 /// non-SSE response) — the caller then runs without server-initiated pushes.
535 pub fn open_events(&self, read_timeout: Duration) -> Result<EventStream, HttpError> {
536 let stream = self.connect(read_timeout)?;
537 let mut headers: Vec<(&str, &str)> = vec![("Accept", "text/event-stream")];
538 let session = self
539 .session
540 .lock()
541 .unwrap_or_else(|e| e.into_inner())
542 .clone();
543 if let Some(sid) = &session {
544 headers.push(("Mcp-Session-Id", sid));
545 }
546 // The notification stream is opened post-initialize (from subscribe), so
547 // the negotiated version is always known here (Streamable HTTP MUST).
548 let protocol = self
549 .protocol_version
550 .lock()
551 .unwrap_or_else(|e| e.into_inner())
552 .clone();
553 if let Some(v) = &protocol {
554 headers.push(("MCP-Protocol-Version", v));
555 }
556 for (k, v) in &self.headers {
557 headers.push((k.as_str(), v.as_str()));
558 }
559 // The push channel is signed exactly like the request path — an
560 // `auth:`-configured server rejects an unsigned GET, and losing this
561 // stream costs the daemon its whole reactivity story. The
562 // challenge/re-sign loop stays on the POST path: this dial only ever
563 // happens post-initialize, so the signer has already satisfied whatever
564 // the server required and signs from what it learned there.
565 let signed = self.auth_headers("GET", b"");
566 for (k, v) in &signed {
567 headers.push((k.as_str(), v.as_str()));
568 }
569 let resp = http::send_streaming(
570 stream,
571 self.endpoint.host_header(),
572 "GET",
573 self.endpoint.http_path(),
574 &headers,
575 b"",
576 )
577 .map_err(HttpError::Http)?;
578 if !resp.is_success() {
579 let status = resp.status;
580 let body = resp.into_body().unwrap_or_default();
581 return Err(HttpError::Status(status, body));
582 }
583 if !resp.is_event_stream() {
584 return Err(HttpError::Unsupported(
585 "server has no GET SSE notification stream".into(),
586 ));
587 }
588 Ok(resp.sse())
589 }
590
591 /// Open the MODERN long-lived notification stream via a `subscriptions/listen`
592 /// POST. The modern era has no GET stream, so this response IS the push
593 /// channel. `body` is the full pre-built JSON-RPC request (its `_meta`
594 /// already injected); `routing`
595 /// are the Mcp-Method/Mcp-Name headers. The server answers with an SSE stream
596 /// that stays open, carrying the opted-in notifications; returns its reader.
597 pub fn open_listen(
598 &self,
599 read_timeout: Duration,
600 body: &[u8],
601 routing: &[(&str, &str)],
602 ) -> Result<EventStream, HttpError> {
603 let stream = self.connect(read_timeout)?;
604 let mut headers: Vec<(&str, &str)> = vec![
605 ("Content-Type", "application/json"),
606 ("Accept", "text/event-stream"),
607 ];
608 let protocol = self
609 .protocol_version
610 .lock()
611 .unwrap_or_else(|e| e.into_inner())
612 .clone();
613 if let Some(v) = &protocol {
614 headers.push(("MCP-Protocol-Version", v));
615 }
616 for (k, v) in routing {
617 headers.push((k, v));
618 }
619 for (k, v) in &self.headers {
620 headers.push((k.as_str(), v.as_str()));
621 }
622 // Signed like any other POST: the modern era's listen stream IS the
623 // notification channel, so an unsigned dial loses reactivity the same way.
624 let signed = self.auth_headers("POST", body);
625 for (k, v) in &signed {
626 headers.push((k.as_str(), v.as_str()));
627 }
628 let resp = http::send_streaming(
629 stream,
630 self.endpoint.host_header(),
631 "POST",
632 self.endpoint.http_path(),
633 &headers,
634 body,
635 )
636 .map_err(HttpError::Http)?;
637 if !resp.is_success() {
638 let status = resp.status;
639 let body = resp.into_body().unwrap_or_default();
640 return Err(HttpError::Status(status, body));
641 }
642 if !resp.is_event_stream() {
643 return Err(HttpError::Unsupported(
644 "subscriptions/listen did not return an SSE stream".into(),
645 ));
646 }
647 Ok(resp.sse())
648 }
649}
650
651/// An owning SSE reader over the notification `GET` stream (a boxed transport
652/// stream, so it survives on the notification thread).
653pub type EventStream = http::SseReader<std::io::BufReader<Box<dyn http::Stream>>>;
654
655/// Route one SSE event: if its `data` is the JSON-RPC response for `request_id`,
656/// return it; a message without a matching id (a notification/other) is handed to
657/// `on_notification` and `None` is returned so the caller keeps reading.
658fn route_message<F: FnMut(Value)>(
659 ev: &SseEvent,
660 request_id: Option<i64>,
661 on_notification: &mut F,
662) -> Option<Value> {
663 let v: Value = serde_json::from_str(&ev.data).ok()?;
664 let id_matches =
665 matches!((request_id, v.get("id").and_then(Value::as_i64)), (Some(a), Some(b)) if a == b);
666 if id_matches {
667 Some(v)
668 } else {
669 on_notification(v);
670 None
671 }
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677
678 #[test]
679 fn parse_https_endpoint() {
680 let e = McpEndpoint::parse("https://mcp.example.com/mcp").unwrap();
681 assert_eq!(e.scheme(), "https");
682 assert_eq!(e.http_path(), "/mcp");
683 assert_eq!(e.host_header(), "mcp.example.com");
684 match e {
685 McpEndpoint::Tcp {
686 host, port, tls, ..
687 } => {
688 assert_eq!(host, "mcp.example.com");
689 assert_eq!(port, 443);
690 assert!(tls);
691 }
692 _ => panic!("expected Tcp"),
693 }
694 }
695
696 #[test]
697 fn parse_http_unix_vsock() {
698 assert_eq!(
699 McpEndpoint::parse("http://localhost:8080/mcp")
700 .unwrap()
701 .scheme(),
702 "http"
703 );
704 let u = McpEndpoint::parse("unix:/run/fs.sock").unwrap();
705 assert_eq!(u.scheme(), "unix");
706 assert_eq!(u.host_header(), "localhost");
707 assert_eq!(u.http_path(), "/");
708 let v = McpEndpoint::parse("vsock:3:5000").unwrap();
709 assert_eq!(v.scheme(), "vsock");
710 assert!(matches!(
711 v,
712 McpEndpoint::Vsock {
713 cid: 3,
714 port: 5000,
715 ..
716 }
717 ));
718 }
719
720 #[test]
721 fn parse_rejects_bad_endpoints() {
722 assert!(McpEndpoint::parse("unix:").is_err());
723 assert!(McpEndpoint::parse("vsock:nope").is_err());
724 assert!(McpEndpoint::parse("ftp://x/").is_err());
725 }
726
727 #[test]
728 fn route_message_matches_response_id_and_queues_notifications() {
729 let mut notes: Vec<Value> = Vec::new();
730 // A notification (no id) is queued, returns None.
731 let n = SseEvent {
732 data: r#"{"jsonrpc":"2.0","method":"notifications/message","params":{}}"#.into(),
733 ..Default::default()
734 };
735 assert!(route_message(&n, Some(1), &mut |v| notes.push(v)).is_none());
736 assert_eq!(notes.len(), 1);
737 // The matching-id response is returned.
738 let r = SseEvent {
739 data: r#"{"jsonrpc":"2.0","id":1,"result":{"ok":true}}"#.into(),
740 ..Default::default()
741 };
742 let got = route_message(&r, Some(1), &mut |v| notes.push(v)).expect("response");
743 assert_eq!(got["result"]["ok"], true);
744 assert_eq!(notes.len(), 1, "response is not queued as a notification");
745 }
746}