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