Skip to main content

mcpls_core/
transport.rs

1//! Transport selection for the MCP server.
2//!
3//! This module defines the [`Transport`] enum that controls how the MCP server
4//! communicates with clients. Stdio is always available; HTTP transport is
5//! opt-in via the `transport-http` Cargo feature.
6//!
7//! # Selecting a transport
8//!
9//! Pass a [`Transport`] value to [`crate::serve_with`] to choose the runtime
10//! binding. The default entry point [`crate::serve`] always uses
11//! [`Transport::Stdio`].
12
13/// The transport over which the MCP server communicates with clients.
14///
15/// # Examples
16///
17/// See [`crate::serve_with`]'s "Shutdown" section before copying this
18/// verbatim: under [`Transport::Stdio`], `main` must call
19/// `std::process::exit` rather than returning normally, or `SIGTERM`/
20/// `SIGINT` can hang while an MCP client's stdin write end is still open
21/// (#308).
22///
23/// ```rust,ignore
24/// use mcpls_core::{Transport, serve_with, ServerConfig};
25///
26/// #[tokio::main]
27/// async fn main() {
28///     let config = ServerConfig::load().expect("failed to load config");
29///     let result = serve_with(config, Transport::Stdio).await;
30///     std::process::exit(if result.is_ok() { 0 } else { 1 });
31/// }
32/// ```
33#[non_exhaustive]
34pub enum Transport {
35    /// Standard I/O transport (default).
36    ///
37    /// Reads from `stdin` and writes to `stdout`. This is the transport used
38    /// by MCP clients that launch mcpls as a child process.
39    Stdio,
40
41    /// Streamable HTTP transport (MCP spec 2025-11-25).
42    ///
43    /// Binds a TCP listener and serves the MCP protocol over HTTP, enabling
44    /// network-accessible deployments and clients that speak HTTP rather than
45    /// stdio. Only available when the `transport-http` feature is enabled.
46    #[cfg(feature = "transport-http")]
47    #[cfg_attr(docsrs, doc(cfg(feature = "transport-http")))]
48    Http(HttpConfig),
49}
50
51/// Configuration for the HTTP transport.
52///
53/// Passed inside [`Transport::Http`] to control the TCP bind address and the
54/// URL path the MCP service is mounted at.
55///
56/// # Note on DNS rebinding
57///
58/// `rmcp`'s `StreamableHttpService` validates the `Host` header against an
59/// allow-list that defaults to loopback addresses only (`localhost`,
60/// `127.0.0.1`, `::1`). If you bind to `0.0.0.0` or a non-loopback address,
61/// clients must send requests with a `Host` that matches the allow-list, or
62/// use a reverse proxy that rewrites the `Host` header.
63///
64/// # Examples
65///
66/// ```rust,ignore
67/// use std::net::SocketAddr;
68/// use mcpls_core::{HttpConfig, Transport};
69///
70/// let cfg = HttpConfig::new("127.0.0.1:3000".parse().unwrap(), "/mcp");
71/// let transport = Transport::Http(cfg);
72/// ```
73#[cfg(feature = "transport-http")]
74#[cfg_attr(docsrs, doc(cfg(feature = "transport-http")))]
75#[derive(Debug, Clone)]
76#[non_exhaustive]
77pub struct HttpConfig {
78    /// TCP address to bind (e.g. `127.0.0.1:3000`).
79    pub bind: std::net::SocketAddr,
80    /// URL path prefix the MCP service is mounted at (e.g. `"/mcp"`).
81    pub path: String,
82    /// Maximum size, in bytes, of a single POST request body.
83    ///
84    /// Enforced by `rmcp`'s `StreamableHttpService` while streaming the body,
85    /// independent of `Content-Length` or chunked transfer encoding. Requests
86    /// exceeding this limit receive `413 Payload Too Large`. Defaults to
87    /// [`HttpConfig::DEFAULT_MAX_REQUEST_BODY_BYTES`] (4 MiB), which
88    /// comfortably covers MCP tool-call request bodies (large results, e.g.
89    /// from `workspace/symbol` or bulk edits, are returned in the response,
90    /// which this limit does not constrain). A value of `0` rejects every
91    /// POST body.
92    pub max_request_body_bytes: usize,
93    /// Maximum number of concurrent HTTP sessions.
94    ///
95    /// This is a hard bound, enforced atomically at session creation via a
96    /// semaphore — never more than this many sessions can be active at once,
97    /// regardless of request concurrency.
98    /// Requests that would start a new session beyond this limit receive
99    /// `429 Too Many Requests`. Defaults to
100    /// [`HttpConfig::DEFAULT_MAX_CONCURRENT_SESSIONS`]. A value of `0`
101    /// rejects every session.
102    pub max_concurrent_sessions: usize,
103}
104
105#[cfg(feature = "transport-http")]
106#[cfg_attr(docsrs, doc(cfg(feature = "transport-http")))]
107impl HttpConfig {
108    /// Default request body size cap (4 MiB), matching `rmcp`'s own default.
109    pub const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 4 * 1024 * 1024;
110    /// Default concurrent HTTP session cap.
111    pub const DEFAULT_MAX_CONCURRENT_SESSIONS: usize = 100;
112
113    /// Create an [`HttpConfig`] with default body-size and session caps.
114    ///
115    /// # Examples
116    ///
117    /// ```rust,ignore
118    /// use mcpls_core::HttpConfig;
119    ///
120    /// let cfg = HttpConfig::new("127.0.0.1:3000".parse().unwrap(), "/mcp");
121    /// ```
122    pub fn new(bind: std::net::SocketAddr, path: impl Into<String>) -> Self {
123        Self {
124            bind,
125            path: path.into(),
126            max_request_body_bytes: Self::DEFAULT_MAX_REQUEST_BODY_BYTES,
127            max_concurrent_sessions: Self::DEFAULT_MAX_CONCURRENT_SESSIONS,
128        }
129    }
130
131    /// Override the maximum POST request body size in bytes.
132    #[must_use]
133    pub const fn with_max_request_body_bytes(mut self, bytes: usize) -> Self {
134        self.max_request_body_bytes = bytes;
135        self
136    }
137
138    /// Override the maximum number of concurrent HTTP sessions.
139    #[must_use]
140    pub const fn with_max_concurrent_sessions(mut self, max: usize) -> Self {
141        self.max_concurrent_sessions = max;
142        self
143    }
144}
145
146use rmcp::ServiceExt as _;
147#[cfg(feature = "transport-http")]
148use rmcp::model::{ClientJsonRpcMessage, ServerJsonRpcMessage};
149#[cfg(feature = "transport-http")]
150use rmcp::transport::streamable_http_server::session::local::{
151    LocalSessionManager, LocalSessionManagerError,
152};
153#[cfg(feature = "transport-http")]
154use rmcp::transport::streamable_http_server::session::{
155    ServerSseMessage, SessionId, SessionManager,
156};
157
158/// A registered handle for waiting on a shutdown signal: `SIGTERM`/`SIGINT`
159/// on Unix (as sent by containers, systemd, and `Ctrl-C`) or `Ctrl-C` on
160/// Windows.
161///
162/// Constructed once by [`crate::serve_with`], *before* any startup work
163/// (LSP-server discovery heuristics, `spawn_lsp_servers_background`) runs,
164/// and moved by value into whichever transport (`run_stdio`/`run_http`) ends
165/// up serving. Registering this early — rather than inside the transport
166/// function itself — closes the startup window between process start and the
167/// transport loop, during which a signal would otherwise hit the OS's
168/// default disposition (immediate termination, bypassing
169/// [`crate::bridge::Translator::shutdown_servers`] and risking an orphaned
170/// LSP child process that `spawn_lsp_servers_background` is mid-spawning;
171/// see #270).
172///
173/// Every signal kind is held as its own persistent stream
174/// (`tokio::signal::unix::Signal` / `tokio::signal::windows::CtrlC`) for the
175/// lifetime of this value, rather than re-registered on every
176/// [`ShutdownSignal::recv`] call via `tokio::signal::ctrl_c()`: a signal
177/// delivered while a *specific* listener isn't being polled is only observed
178/// by that same listener's next poll — a freshly (re-)subscribed one starts
179/// at the broadcast's current version and never sees it (tokio
180/// `signal/registry.rs`). Since [`recv`](ShutdownSignal::recv) is awaited
181/// from more than one call site — both by [`run_stdio`], which races it
182/// against the MCP handshake and then the post-handshake serve loop, and
183/// across the gap between construction in `serve_with` and the first await
184/// inside the transport — a fresh registration per call would risk losing a
185/// signal delivered in between.
186///
187/// This instance is dropped as soon as the transport function it was moved
188/// into returns — but that does *not* deregister the OS-level handler:
189/// `tokio::signal` installs it once per process and never uninstalls it, no
190/// matter how many `ShutdownSignal`s are constructed or dropped. What
191/// dropping the last live instance actually does is remove the only
192/// receiver a delivered signal could be broadcast to, so until a new one
193/// subscribes, a signal is recorded and then silently discarded rather than
194/// observed by anything — making that stretch of code uninterruptible
195/// rather than unsafe. [`crate::shutdown`] (the post-transport cleanup run
196/// immediately after) registers a *second* `ShutdownSignal` of its own so a
197/// repeat signal during cleanup has a receiver again and can force an exit;
198/// see #329.
199pub(crate) struct ShutdownSignal {
200    #[cfg(unix)]
201    sigterm: Option<tokio::signal::unix::Signal>,
202    #[cfg(unix)]
203    sigint: Option<tokio::signal::unix::Signal>,
204    #[cfg(windows)]
205    ctrl_c: Option<tokio::signal::windows::CtrlC>,
206}
207
208impl ShutdownSignal {
209    /// Registers the process's shutdown signal handler(s) up front.
210    pub(crate) fn new() -> Self {
211        #[cfg(unix)]
212        {
213            use tokio::signal::unix::{SignalKind, signal};
214            let sigterm = match signal(SignalKind::terminate()) {
215                Ok(sigterm) => Some(sigterm),
216                Err(e) => {
217                    tracing::warn!(
218                        "SIGTERM handler registration failed ({e}), SIGTERM will not be caught"
219                    );
220                    None
221                }
222            };
223            let sigint = match signal(SignalKind::interrupt()) {
224                Ok(sigint) => Some(sigint),
225                Err(e) => {
226                    tracing::warn!(
227                        "SIGINT handler registration failed ({e}), SIGINT will not be caught"
228                    );
229                    None
230                }
231            };
232            Self { sigterm, sigint }
233        }
234        #[cfg(windows)]
235        {
236            let ctrl_c = match tokio::signal::windows::ctrl_c() {
237                Ok(ctrl_c) => Some(ctrl_c),
238                Err(e) => {
239                    tracing::warn!("Ctrl-C handler registration failed ({e})");
240                    None
241                }
242            };
243            Self { ctrl_c }
244        }
245        #[cfg(not(any(unix, windows)))]
246        {
247            Self {}
248        }
249    }
250
251    /// Waits for the next shutdown signal. May be awaited repeatedly.
252    pub(crate) async fn recv(&mut self) {
253        #[cfg(unix)]
254        {
255            match (self.sigterm.as_mut(), self.sigint.as_mut()) {
256                (Some(sigterm), Some(sigint)) => {
257                    tokio::select! {
258                        _ = sigterm.recv() => {},
259                        _ = sigint.recv() => {},
260                    }
261                }
262                (Some(sigterm), None) => {
263                    sigterm.recv().await;
264                }
265                (None, Some(sigint)) => {
266                    sigint.recv().await;
267                }
268                (None, None) => {
269                    // Both registrations failed above; fall back to a
270                    // one-shot listener so shutdown is still possible, even
271                    // though it doesn't carry the same across-calls
272                    // durability the held streams above do (see the struct
273                    // docs).
274                    let _ = tokio::signal::ctrl_c().await;
275                }
276            }
277        }
278        #[cfg(windows)]
279        {
280            match self.ctrl_c.as_mut() {
281                Some(ctrl_c) => {
282                    ctrl_c.recv().await;
283                }
284                None => {
285                    let _ = tokio::signal::ctrl_c().await;
286                }
287            }
288        }
289        #[cfg(not(any(unix, windows)))]
290        {
291            // No persistent listener is available on this platform; same
292            // caveat as the Unix double-registration-failure fallback above.
293            let _ = tokio::signal::ctrl_c().await;
294        }
295    }
296}
297
298/// Run the MCP server over stdio.
299///
300/// Serves the given `mcp_server` using stdin/stdout and populates `peer_cell`
301/// once the transport is established so that diagnostic pump tasks can begin
302/// forwarding `resources/updated` notifications. Returns as soon as either
303/// the stdio transport closes (client disconnect / stdin EOF) or a `SIGTERM`/
304/// `SIGINT` is received, so callers can run orderly cleanup — such as
305/// [`crate::bridge::Translator::shutdown_servers`] — before the process
306/// exits. `shutdown_signal` is dropped when this function returns, and this
307/// function does no draining of its own after a signal arrives — so a
308/// repeat signal during the post-return cleanup in [`crate::shutdown`] is
309/// caught only by the second `ShutdownSignal` that function registers for
310/// itself, not by this one (see [`ShutdownSignal`]'s docs and #329).
311///
312/// `shutdown_signal` is constructed by [`crate::serve_with`] *before* any
313/// startup work runs (see [`ShutdownSignal`]'s docs) and is raced here
314/// against both the MCP handshake and, once it completes, the
315/// post-handshake serve loop. `serve(..)` awaits the full MCP `initialize`
316/// handshake internally (reading the client's request and writing the
317/// response) before resolving, so a signal arriving during that wait — which
318/// can be indefinite if the client is slow to send `initialize` — must be
319/// caught there too, not only after the handshake finishes. On signal, the
320/// in-flight handshake or `RunningService` is dropped rather than awaited to
321/// completion; `rmcp` closes it asynchronously in that case, which is
322/// acceptable here since the process exits shortly after -- callers must
323/// exit via `std::process::exit` rather than returning normally from `main`,
324/// or an uncancellable `tokio::io::stdin()` blocking thread can stall
325/// runtime shutdown indefinitely (see `mcpls-cli`'s `main.rs` and #308).
326pub(crate) async fn run_stdio(
327    mcp_server: crate::mcp::McplsServer,
328    peer_cell: &tokio::sync::OnceCell<rmcp::Peer<rmcp::RoleServer>>,
329    mut shutdown_signal: ShutdownSignal,
330) -> Result<(), crate::Error> {
331    let service = tokio::select! {
332        result = mcp_server.serve(rmcp::transport::stdio()) => {
333            result.map_err(|e| crate::Error::McpServer(format!("Failed to start MCP server: {e}")))?
334        }
335        () = shutdown_signal.recv() => {
336            tracing::info!("shutdown signal received during handshake, stopping stdio transport");
337            return Ok(());
338        }
339    };
340
341    if let Err(e) = peer_cell.set(service.peer().clone()) {
342        tracing::debug!("Peer cell already set ({}), ignoring", e);
343    }
344
345    tokio::select! {
346        result = service.waiting() => result
347            .map(|_| ())
348            .map_err(|e| crate::Error::McpServer(format!("MCP server error: {e}"))),
349        () = shutdown_signal.recv() => {
350            tracing::info!("shutdown signal received, stopping stdio transport");
351            Ok(())
352        }
353    }
354}
355
356/// Run the MCP server over Streamable HTTP (MCP spec 2025-11-25).
357///
358/// Binds `cfg.bind`, mounts the MCP service at `cfg.path` (and `/`), and
359/// serves until `Ctrl-C` or `SIGTERM` is received.
360///
361/// Each HTTP session receives its own `McplsServer` instance (see
362/// [`crate::mcp::McplsServer::for_new_session`]). The shared `Arc<Translator>`
363/// inside is the same across all sessions, so LSP state is still global per
364/// process.
365///
366/// # Note
367///
368/// Diagnostic push notifications (`resources/updated`) are not forwarded to
369/// HTTP sessions in this release — the single-peer pump architecture from
370/// stdio is kept as-is. Clients can still poll diagnostics via the existing
371/// MCP tools. A follow-up issue will add per-session broadcast.
372///
373/// On rmcp's stateless request path, "one instance per session" narrows to
374/// "one instance per request"; `resources/subscribe`/`unsubscribe` detect
375/// that path and return an explicit error rather than silently accepting a
376/// subscription that would never be observed -- see
377/// [`SubscriptionRegistry`](crate::bridge::SubscriptionRegistry)'s "Known
378/// limitation" section.
379///
380/// # Resource limits
381///
382/// POST bodies exceeding `cfg.max_request_body_bytes` are rejected with
383/// `413 Payload Too Large` (enforced by `rmcp`). Once `cfg.max_concurrent_sessions`
384/// sessions are active, a request that would start a new one is rejected with
385/// `429 Too Many Requests` — enforced as a hard bound at session creation by
386/// [`CappedSessionManager`] and surfaced over HTTP by [`enforce_session_cap`].
387///
388/// # Shutdown
389///
390/// On `SIGTERM`/`SIGINT`, in-flight connections get up to
391/// [`HTTP_GRACEFUL_SHUTDOWN_TIMEOUT`] to finish before this function returns
392/// regardless — bounding shutdown this way lets the caller run its own
393/// post-shutdown cleanup (e.g. closing registered LSP servers) even if a
394/// connection never observes the cancellation (a stuck SSE stream, say).
395/// `shutdown_signal` is constructed by [`crate::serve_with`] before any
396/// startup work runs (see [`ShutdownSignal`]'s docs), so its registration
397/// predates this function's own `TcpListener::bind` call — a signal between
398/// bind and the graceful-shutdown future's first poll is still caught.
399/// `shutdown_signal` is moved into (and dropped by) the
400/// `with_graceful_shutdown` closure below once it resolves — i.e. as soon as
401/// the *first* signal is received, well before this function returns. A
402/// second, freshly constructed `ShutdownSignal` then covers the
403/// connection-drain wait that follows (bounded by
404/// [`HTTP_GRACEFUL_SHUTDOWN_TIMEOUT`]): a repeat signal caught there cuts the
405/// drain short (dropping `serve` the same way the timeout branch already
406/// does) instead of making the operator wait out the full timeout.
407///
408/// Cutting the drain short is *not* an immediate process exit: this function
409/// still returns `Ok(())` normally, and its caller ([`crate::serve_with`])
410/// proceeds straight into the ordinary post-transport shutdown sequence
411/// ([`crate::shutdown`] — LSP server shutdown plus any pending background
412/// init task, bounded by its own ~15s worst case). [`crate::shutdown`]'s own
413/// registration (#329) takes over once *this* function returns, covering
414/// that cleanup window and escalating to a forced `std::process::exit(1)` on
415/// any *further* repeat signal — so an operator wanting a true immediate exit
416/// needs a third signal, not a second. This is a deliberate choice, not an
417/// oversight: calling `exit(1)` directly from this branch would skip
418/// unwinding and forfeit `kill_on_drop` cleanup of any still-running LSP
419/// child processes, which is worse than requiring one more signal.
420#[cfg(feature = "transport-http")]
421// `session_manager` and `service` are moved into `app`, which is served until
422// shutdown — clippy's drop-tightening heuristic misreads that as an
423// early-droppable temporary because both types embed `tokio::sync` lock types
424// (`CappedSessionManager`'s `Mutex`, `StreamableHttpService`'s `RwLock`s).
425#[allow(clippy::significant_drop_tightening)]
426pub(crate) async fn run_http(
427    mcp_server: crate::mcp::McplsServer,
428    cfg: HttpConfig,
429    mut shutdown_signal: ShutdownSignal,
430) -> Result<(), crate::Error> {
431    use std::sync::Arc;
432
433    use rmcp::transport::streamable_http_server::{
434        StreamableHttpServerConfig, StreamableHttpService,
435    };
436    use tokio_util::sync::CancellationToken;
437
438    let session_manager = Arc::new(CappedSessionManager::new(cfg.max_concurrent_sessions));
439    let cancel = CancellationToken::new();
440
441    // `mcp_server` is moved (not cloned): `McplsServer` is deliberately not
442    // `Clone` (#478) so this is the only value the factory below can build
443    // new sessions from, rather than a shared instance a caller could
444    // accidentally hand to multiple sessions.
445    let mcp_for_factory = mcp_server;
446    // StreamableHttpServerConfig is #[non_exhaustive]; construct via Default then mutate.
447    let mut http_cfg = StreamableHttpServerConfig::default();
448    http_cfg.cancellation_token = cancel.clone();
449    http_cfg.max_request_body_bytes = cfg.max_request_body_bytes;
450
451    // `for_new_session`, not `.clone()`: every session must get its own
452    // `ResourceSubscriptions` set (#478) rather than sharing `mcp_for_factory`'s,
453    // while still sharing its `Arc<Translator>` and the rest of the LSP-facing
454    // state via a cheap `Arc` bump. On rmcp's stateless path this factory runs
455    // once per request, not per session -- see `SubscriptionRegistry`'s
456    // "Known limitation" doc.
457    let service = StreamableHttpService::new(
458        move || Ok::<_, std::io::Error>(mcp_for_factory.for_new_session()),
459        session_manager,
460        http_cfg,
461    );
462
463    let app = axum::Router::new()
464        .nest_service(&cfg.path, service.clone())
465        .route_service("/", service)
466        .layer(axum::middleware::from_fn(enforce_session_cap));
467
468    let listener = tokio::net::TcpListener::bind(cfg.bind)
469        .await
470        .map_err(|e| crate::Error::McpServer(format!("bind {}: {e}", cfg.bind)))?;
471
472    tracing::info!(addr = %cfg.bind, path = %cfg.path, "MCP HTTP transport listening");
473    if !cfg.bind.ip().is_loopback() {
474        tracing::warn!(
475            addr = %cfg.bind,
476            "binding to a non-loopback address: mcpls performs no authentication of its own on \
477             any transport — place this endpoint behind a reverse proxy that enforces \
478             authentication. The proxy must also rewrite the Host header, since rmcp's Host \
479             validation allows only localhost/127.0.0.1/::1 by default"
480        );
481    }
482
483    // `cancel` is cancelled exactly once, when the shutdown signal fires
484    // (below). Cloned first so the force-timeout and repeat-signal branches
485    // can each observe that same moment independently of the
486    // `with_graceful_shutdown` closure, which consumes its own clone.
487    let cancel_for_force_timeout = cancel.clone();
488    let cancel_for_repeat_signal = cancel.clone();
489    let serve = axum::serve(listener, app).with_graceful_shutdown(async move {
490        shutdown_signal.recv().await;
491        cancel.cancel();
492    });
493
494    // The force-timeout only starts counting once `cancel` is actually
495    // cancelled — i.e. once a shutdown signal has been received — not from
496    // server startup. Without that ordering, `tokio::time::timeout` wrapping
497    // `serve` directly would tear down the listener after
498    // `HTTP_GRACEFUL_SHUTDOWN_TIMEOUT` of ordinary uptime, signal or not.
499    // This bounds only the "drain in-flight connections after shutdown was
500    // requested" phase, so a connection that never observes `cancel` (e.g. a
501    // stuck SSE stream) can't hang the caller's post-shutdown cleanup
502    // (draining/closing LSP servers) indefinitely.
503    tokio::select! {
504        result = serve => result.map_err(|e| crate::Error::McpServer(format!("http serve: {e}"))),
505        () = async move {
506            cancel_for_force_timeout.cancelled().await;
507            tokio::time::sleep(HTTP_GRACEFUL_SHUTDOWN_TIMEOUT).await;
508        } => {
509            tracing::warn!(
510                timeout = ?HTTP_GRACEFUL_SHUTDOWN_TIMEOUT,
511                "HTTP graceful shutdown did not complete in time, proceeding with shutdown anyway"
512            );
513            Ok(())
514        }
515        // #349: `shutdown_signal` above is consumed and dropped as soon as
516        // the first signal arrives, leaving no listener for a repeat signal
517        // during the connection-drain wait that follows. Wait for `cancel`
518        // first and only then construct a fresh `ShutdownSignal` -- rather
519        // than registering one up front, alongside `shutdown_signal` -- so
520        // it starts with no pending signal of its own: `tokio::signal`
521        // fans a delivered signal out to every live listener, so a listener
522        // already registered before the first signal arrived would
523        // independently observe that same signal and misreport it as a
524        // repeat. This leaves a much smaller, accepted gap instead: between
525        // `cancel.cancel()` firing and `ShutdownSignal::new()` actually
526        // registering on the next scheduler hop, there is no pollable
527        // listener at all, so a signal delivered in that sub-millisecond
528        // window is lost. Registering up front would only trade this for
529        // the coalescing problem above -- it is not fully closable either
530        // way, and the window is far below human reaction time to a second
531        // keypress.
532        () = async move {
533            cancel_for_repeat_signal.cancelled().await;
534            let mut repeat_signal = ShutdownSignal::new();
535            repeat_signal.recv().await;
536        } => {
537            tracing::warn!(
538                "repeat shutdown signal received during HTTP connection drain, cutting drain short"
539            );
540            Ok(())
541        }
542    }
543}
544
545/// Upper bound [`run_http`] waits, once shutdown has been signaled, for
546/// `axum`'s graceful shutdown to finish draining in-flight connections
547/// before giving up and returning anyway.
548#[cfg(feature = "transport-http")]
549const HTTP_GRACEFUL_SHUTDOWN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
550
551/// Wraps [`LocalSessionManager`], bounding concurrent HTTP sessions to a
552/// fixed capacity.
553///
554/// A [`tokio::sync::Semaphore`] permit is acquired atomically inside
555/// [`create_session`](SessionManager::create_session) — before delegating to
556/// the inner manager — and held for the session's lifetime, released in
557/// [`close_session`](SessionManager::close_session). This makes the cap a
558/// true hard bound: the check and the reservation happen as one step, so no
559/// number of concurrent requests can observe spare capacity and all proceed
560/// past it (a "check-then-create" race that a separate read of the session
561/// count could not avoid).
562///
563/// Enforcement lives here, at the `SessionManager` layer, rather than in Axum
564/// middleware sniffing request headers, because that is the only place
565/// guaranteed to run exactly when — and only when — a session is actually
566/// created. `rmcp` 3.2.0's `StreamableHttpService::handle_post` classifies
567/// every `initialize` request as legacy and always calls `create_session`,
568/// whatever protocol version it names — the handshake only exists in
569/// revisions before `2026-07-28`, so a version named in its params never
570/// routes it to the stateless path. Only *non*-`initialize` requests that
571/// carry SEP-2575 per-request `_meta` (`io.modelcontextprotocol/protocolVersion`
572/// = `2026-07-28` plus the required `clientCapabilities` key), and
573/// `server/discover` requests, take the stateless discover-lifecycle path
574/// that never calls `create_session`. A header-based middleware heuristic
575/// can't tell these apart without duplicating `rmcp`'s internal protocol
576/// classification, so it either 429s traffic that never consumed a session
577/// slot, or — in an all-stateless deployment — never fires at all.
578///
579/// `restore_session` and `event_store` deliberately use
580/// [`SessionManager`]'s trait defaults (`NotSupported` / `None`) instead of
581/// delegating to `inner`: `HttpConfig` exposes no session-store knob, so
582/// these are unreachable today, but delegating them would let a restored
583/// session skip the semaphore entirely — a cap bypass. Leave them as
584/// defaults; overriding them to delegate is not a bug fix.
585#[cfg(feature = "transport-http")]
586struct CappedSessionManager {
587    inner: LocalSessionManager,
588    semaphore: std::sync::Arc<tokio::sync::Semaphore>,
589    permits:
590        tokio::sync::Mutex<std::collections::HashMap<SessionId, tokio::sync::OwnedSemaphorePermit>>,
591}
592
593#[cfg(feature = "transport-http")]
594impl CappedSessionManager {
595    fn new(max_sessions: usize) -> Self {
596        Self {
597            inner: LocalSessionManager::default(),
598            semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(max_sessions)),
599            permits: tokio::sync::Mutex::new(std::collections::HashMap::new()),
600        }
601    }
602}
603
604/// Marker embedded in [`CappedSessionManagerError::CapReached`]'s rendered
605/// message.
606///
607/// `rmcp`'s `StreamableHttpService` always maps `create_session` failures to
608/// a generic `500 Internal Server Error` (`internal_error_response` in
609/// `server_side_http.rs` is a fixed, non-configurable mapping — `rmcp` gives
610/// callers no other hook). [`enforce_session_cap`] looks for this marker in
611/// the response body to translate a capacity rejection into
612/// `429 Too Many Requests` without misclassifying other `create_session`
613/// failures as capacity issues.
614#[cfg(feature = "transport-http")]
615const SESSION_CAP_MARKER: &str = "mcpls-http-session-cap-reached";
616
617/// Error type for [`CappedSessionManager`].
618#[cfg(feature = "transport-http")]
619#[derive(Debug, thiserror::Error)]
620enum CappedSessionManagerError {
621    /// The concurrent-session cap was already reached.
622    #[error("{SESSION_CAP_MARKER}: maximum concurrent HTTP sessions already active")]
623    CapReached,
624    /// The wrapped [`LocalSessionManager`] failed.
625    #[error(transparent)]
626    Inner(#[from] LocalSessionManagerError),
627}
628
629#[cfg(feature = "transport-http")]
630impl SessionManager for CappedSessionManager {
631    type Error = CappedSessionManagerError;
632    type Transport = <LocalSessionManager as SessionManager>::Transport;
633
634    async fn create_session(&self) -> Result<(SessionId, Self::Transport), Self::Error> {
635        let permit = self
636            .semaphore
637            .clone()
638            .try_acquire_owned()
639            .map_err(|_| CappedSessionManagerError::CapReached)?;
640        let (id, transport) = self.inner.create_session().await?;
641        self.permits.lock().await.insert(id.clone(), permit);
642        Ok((id, transport))
643    }
644
645    async fn initialize_session(
646        &self,
647        id: &SessionId,
648        message: ClientJsonRpcMessage,
649    ) -> Result<ServerJsonRpcMessage, Self::Error> {
650        Ok(self.inner.initialize_session(id, message).await?)
651    }
652
653    async fn has_session(&self, id: &SessionId) -> Result<bool, Self::Error> {
654        Ok(self.inner.has_session(id).await?)
655    }
656
657    async fn close_session(&self, id: &SessionId) -> Result<(), Self::Error> {
658        // Release the permit unconditionally, before propagating any error from
659        // `inner.close_session`: on error the inner manager has already dropped
660        // the session from its own table (see `LocalSessionManager::close_session`),
661        // so skipping the removal here would leak the permit permanently and
662        // monotonically shrink capacity.
663        self.permits.lock().await.remove(id);
664        self.inner.close_session(id).await?;
665        Ok(())
666    }
667
668    async fn create_stream(
669        &self,
670        id: &SessionId,
671        message: ClientJsonRpcMessage,
672    ) -> Result<impl futures::Stream<Item = ServerSseMessage> + Send + Sync + 'static, Self::Error>
673    {
674        Ok(self.inner.create_stream(id, message).await?)
675    }
676
677    async fn accept_message(
678        &self,
679        id: &SessionId,
680        message: ClientJsonRpcMessage,
681    ) -> Result<(), Self::Error> {
682        Ok(self.inner.accept_message(id, message).await?)
683    }
684
685    async fn create_standalone_stream(
686        &self,
687        id: &SessionId,
688    ) -> Result<impl futures::Stream<Item = ServerSseMessage> + Send + Sync + 'static, Self::Error>
689    {
690        Ok(self.inner.create_standalone_stream(id).await?)
691    }
692
693    async fn resume(
694        &self,
695        id: &SessionId,
696        last_event_id: String,
697    ) -> Result<impl futures::Stream<Item = ServerSseMessage> + Send + Sync + 'static, Self::Error>
698    {
699        Ok(self.inner.resume(id, last_event_id).await?)
700    }
701}
702
703/// Axum middleware that rewrites `rmcp`'s generic `500 Internal Server Error`
704/// into `429 Too Many Requests` when the failure was
705/// [`CappedSessionManagerError::CapReached`] (detected via
706/// [`SESSION_CAP_MARKER`] in the response body), adding a `Retry-After`
707/// header.
708///
709/// This runs as response post-processing rather than a request pre-check
710/// because only the real [`SessionManager::create_session`] call — deep
711/// inside `rmcp` — knows whether a given request actually attempts to create
712/// a session; see [`CappedSessionManager`]'s docs for why that can't be
713/// determined from the request alone.
714#[cfg(feature = "transport-http")]
715async fn enforce_session_cap(
716    request: axum::extract::Request,
717    next: axum::middleware::Next,
718) -> axum::response::Response {
719    let response = next.run(request).await;
720    if response.status() != axum::http::StatusCode::INTERNAL_SERVER_ERROR {
721        return response;
722    }
723
724    let (mut parts, body) = response.into_parts();
725    // `create_session` failures always render as a small `Full<Bytes>` body
726    // (`internal_error_response` in rmcp's `server_side_http.rs`); the large
727    // streaming SSE/JSON success bodies never carry a 500 status, so this
728    // never touches them. 64 KiB is far beyond any realistic error message.
729    let Ok(bytes) = axum::body::to_bytes(body, 64 * 1024).await else {
730        // Buffering the original error body failed (e.g. it exceeded the 64
731        // KiB cap, which should never happen per the comment above, or the
732        // body stream errored). Preserve the 500 status but substitute a
733        // minimal fallback body rather than dropping the error entirely.
734        return axum::response::Response::from_parts(
735            parts,
736            axum::body::Body::from("Internal Server Error"),
737        );
738    };
739
740    if bytes
741        .windows(SESSION_CAP_MARKER.len())
742        .any(|window| window == SESSION_CAP_MARKER.as_bytes())
743    {
744        parts.status = axum::http::StatusCode::TOO_MANY_REQUESTS;
745        parts.headers.insert(
746            axum::http::header::RETRY_AFTER,
747            axum::http::HeaderValue::from_static("1"),
748        );
749        return axum::response::Response::from_parts(
750            parts,
751            axum::body::Body::from("Too Many Requests: maximum concurrent HTTP sessions reached"),
752        );
753    }
754
755    axum::response::Response::from_parts(parts, axum::body::Body::from(bytes))
756}
757
758#[cfg(test)]
759#[allow(clippy::unwrap_used)]
760mod tests {
761    /// `Transport::Stdio` is always constructible regardless of feature flags.
762    #[test]
763    fn test_transport_stdio_variant() {
764        let t = super::Transport::Stdio;
765        assert!(matches!(t, super::Transport::Stdio));
766    }
767
768    /// #329 regression: `crate::shutdown`'s cleanup-window fix hinges on a
769    /// freshly constructed `ShutdownSignal` still receiving real OS signals
770    /// after an *earlier* `ShutdownSignal` (e.g. the one `run_stdio`/
771    /// `run_http` held) has already been dropped — proving there is no
772    /// window in which the OS handler itself gets deregistered (per
773    /// `ShutdownSignal`'s corrected struct doc: `tokio::signal` never
774    /// uninstalls it, regardless of how many instances are constructed or
775    /// dropped). Exercises this with a real self-sent `SIGTERM` via the
776    /// external `kill` binary rather than mocking `ShutdownSignal`, since
777    /// that's the exact mechanism `crate::shutdown`'s force-exit task relies
778    /// on. Safe under `cargo nextest`'s one-process-per-test model, so no
779    /// other test's signal disposition is affected.
780    ///
781    /// Deliberately does not go through `crate::shutdown` itself: a signal
782    /// caught there unconditionally calls `std::process::exit(1)`, which
783    /// would kill this test's own process for real rather than fail an
784    /// assertion — see the #329 regression-test handoff for why a test
785    /// triggering `std::process::exit(1)` isn't attempted here.
786    ///
787    /// Unix-only: `SIGTERM` and the external `kill` binary this test relies
788    /// on don't exist on Windows, where `ShutdownSignal` listens for
789    /// Ctrl-C instead (see the struct's `#[cfg(windows)]` arm above).
790    #[cfg(unix)]
791    #[tokio::test]
792    async fn test_fresh_shutdown_signal_still_receives_sigterm_after_prior_instance_dropped() {
793        let earlier = super::ShutdownSignal::new();
794        drop(earlier);
795
796        let mut cleanup_signal = super::ShutdownSignal::new();
797
798        let pid = std::process::id();
799        let signal_sender = tokio::spawn(async move {
800            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
801            let status = std::process::Command::new("kill")
802                .arg("-TERM")
803                .arg(pid.to_string())
804                .status()
805                .unwrap();
806            assert!(status.success(), "`kill -TERM {pid}` must succeed");
807        });
808
809        let result =
810            tokio::time::timeout(std::time::Duration::from_secs(5), cleanup_signal.recv()).await;
811        signal_sender.await.unwrap();
812
813        assert!(
814            result.is_ok(),
815            "a freshly constructed ShutdownSignal must still receive a real SIGTERM sent after \
816             an earlier instance was dropped — this is the exact mechanism crate::shutdown's \
817             cleanup-window force-exit task depends on"
818        );
819    }
820
821    /// #241: `run_stdio` must not hang when the transport never even
822    /// establishes — it must surface the failure promptly.
823    ///
824    /// This is the closest portable coverage of `run_stdio`'s non-signal
825    /// path achievable here: `run_stdio` is hardcoded to the process's real
826    /// stdin/stdout (no injectable transport), and this crate is
827    /// `deny(unsafe_code)`, so a test can't redirect the fd to simulate "the
828    /// MCP handshake completes, *then* stdin closes" — the specific
829    /// scenario that would drive `service.waiting()` to resolve inside the
830    /// `tokio::select!` and hit its `Ok(())` arm. What a test *can* rely on:
831    /// under `cargo nextest`, each test's stdin is already closed before the
832    /// test body runs, so `mcp_server.serve(...)` fails during the initial
833    /// `initialize` handshake — before `run_stdio` ever reaches the
834    /// `select!`. That still exercises real production code (the `.serve()`
835    /// call and its error mapping) and proves `run_stdio` returns promptly
836    /// rather than hanging, which is what a broken `select!` (e.g. one
837    /// missing a branch, or awaiting the wrong future) would look like.
838    #[tokio::test]
839    async fn test_run_stdio_returns_promptly_when_stdin_is_already_closed() {
840        use std::path::PathBuf;
841        use std::sync::Arc;
842
843        use tokio::sync::Mutex;
844
845        use crate::bridge::{NotificationCache, SubscriptionRegistry, Translator};
846        use crate::config::McpConfig;
847        use crate::mcp::McplsServer;
848
849        let translator = Arc::new(Translator::new());
850        let notification_cache = Arc::new(Mutex::new(NotificationCache::new()));
851        let workspace_roots: Arc<[PathBuf]> = Arc::from(Vec::new());
852        let subs = SubscriptionRegistry::new();
853        let server = McplsServer::new(
854            translator,
855            notification_cache,
856            workspace_roots,
857            subs,
858            false,
859            McpConfig::default(),
860        );
861        let peer_cell = tokio::sync::OnceCell::new();
862
863        let outcome = tokio::time::timeout(
864            std::time::Duration::from_secs(2),
865            super::run_stdio(server, &peer_cell, super::ShutdownSignal::new()),
866        )
867        .await;
868
869        assert!(
870            outcome.is_ok(),
871            "run_stdio must not hang when stdin is already closed"
872        );
873        let result = outcome.unwrap();
874        assert!(
875            matches!(result, Err(crate::Error::McpServer(_))),
876            "expected a McpServer error from the failed handshake, got: {result:?}"
877        );
878    }
879
880    #[cfg(feature = "transport-http")]
881    mod http_tests {
882        use std::net::SocketAddr;
883
884        use super::super::{HttpConfig, Transport};
885        use crate::test_lsp::CapturedLogs;
886
887        #[test]
888        fn test_http_config_fields() {
889            let addr: SocketAddr = "127.0.0.1:3000".parse().unwrap();
890            let cfg = HttpConfig::new(addr, "/mcp");
891            assert_eq!(cfg.bind, addr);
892            assert_eq!(cfg.path, "/mcp");
893        }
894
895        #[test]
896        fn test_http_config_clone() {
897            let cfg = HttpConfig::new("127.0.0.1:3001".parse().unwrap(), "/test");
898            let cloned = cfg.clone();
899            assert_eq!(cloned.bind, cfg.bind);
900            assert_eq!(cloned.path, cfg.path);
901        }
902
903        #[test]
904        fn test_transport_http_variant() {
905            let cfg = HttpConfig::new("127.0.0.1:3002".parse().unwrap(), "/mcp");
906            let t = Transport::Http(cfg);
907            assert!(matches!(t, Transport::Http(_)));
908        }
909
910        #[test]
911        fn test_http_config_new_uses_default_limits() {
912            let cfg = HttpConfig::new("127.0.0.1:3003".parse().unwrap(), "/mcp");
913            assert_eq!(
914                cfg.max_request_body_bytes,
915                HttpConfig::DEFAULT_MAX_REQUEST_BODY_BYTES
916            );
917            assert_eq!(
918                cfg.max_concurrent_sessions,
919                HttpConfig::DEFAULT_MAX_CONCURRENT_SESSIONS
920            );
921        }
922
923        #[test]
924        fn test_http_config_with_max_request_body_bytes_overrides_default() {
925            let cfg = HttpConfig::new("127.0.0.1:3004".parse().unwrap(), "/mcp")
926                .with_max_request_body_bytes(1024);
927            assert_eq!(cfg.max_request_body_bytes, 1024);
928            assert_eq!(
929                cfg.max_concurrent_sessions,
930                HttpConfig::DEFAULT_MAX_CONCURRENT_SESSIONS
931            );
932        }
933
934        #[test]
935        fn test_http_config_with_max_concurrent_sessions_overrides_default() {
936            let cfg = HttpConfig::new("127.0.0.1:3005".parse().unwrap(), "/mcp")
937                .with_max_concurrent_sessions(5);
938            assert_eq!(cfg.max_concurrent_sessions, 5);
939            assert_eq!(
940                cfg.max_request_body_bytes,
941                HttpConfig::DEFAULT_MAX_REQUEST_BODY_BYTES
942            );
943        }
944
945        /// Verifies `run_http` binds successfully and accepts TCP connections.
946        #[tokio::test]
947        async fn test_run_http_binds() {
948            use std::path::PathBuf;
949            use std::sync::Arc;
950
951            use tokio::sync::Mutex;
952
953            use crate::bridge::{NotificationCache, SubscriptionRegistry, Translator};
954            use crate::config::McpConfig;
955            use crate::mcp::McplsServer;
956
957            let translator = Arc::new(Translator::new());
958            let notification_cache = Arc::new(Mutex::new(NotificationCache::new()));
959            let workspace_roots: Arc<[PathBuf]> = Arc::from(Vec::new());
960            let subs = SubscriptionRegistry::new();
961            let server = McplsServer::new(
962                translator,
963                notification_cache,
964                workspace_roots,
965                subs,
966                false,
967                McpConfig::default(),
968            );
969
970            // Bind port 0 so the OS assigns a free port.
971            let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
972            let addr = probe.local_addr().unwrap();
973            drop(probe);
974
975            let cfg = HttpConfig::new(addr, "/mcp");
976
977            let server_task = tokio::spawn(super::super::run_http(
978                server,
979                cfg,
980                super::super::ShutdownSignal::new(),
981            ));
982            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
983
984            // A successful TCP connect proves the listener is up.
985            let connected = tokio::net::TcpStream::connect(addr).await;
986            assert!(
987                connected.is_ok(),
988                "HTTP listener should accept TCP connections"
989            );
990
991            server_task.abort();
992        }
993
994        /// #241 C1 regression: `run_http` must not self-terminate after
995        /// `HTTP_GRACEFUL_SHUTDOWN_TIMEOUT` of ordinary uptime when no
996        /// shutdown signal has been sent — the graceful-shutdown timeout
997        /// must only start counting once a signal actually arrives, not
998        /// from server startup.
999        ///
1000        /// Uses `#[tokio::test(start_paused = true)]` plus
1001        /// `tokio::time::advance` to fast-forward virtual time past the
1002        /// timeout instead of sleeping the real 30s. Under the bug this
1003        /// regresses against — `tokio::time::timeout(HTTP_GRACEFUL_SHUTDOWN_TIMEOUT,
1004        /// serve)` wrapping the whole `serve` future from construction —
1005        /// advancing virtual time past the timeout resolves that timer and
1006        /// finishes the task immediately, even with no signal sent. Under
1007        /// the fix, nothing inside `run_http` starts a timer until `cancel`
1008        /// is cancelled, so this advance must have no effect and the task
1009        /// must still be running.
1010        #[tokio::test(start_paused = true)]
1011        async fn test_run_http_does_not_self_terminate_without_signal() {
1012            let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1013            let addr = probe.local_addr().unwrap();
1014            drop(probe);
1015
1016            let cfg = HttpConfig::new(addr, "/mcp");
1017            let server_task = tokio::spawn(super::super::run_http(
1018                test_server(),
1019                cfg,
1020                super::super::ShutdownSignal::new(),
1021            ));
1022
1023            // Let the spawned task make initial progress (bind the
1024            // listener, enter its `select!`) without depending on any real
1025            // or virtual delay.
1026            for _ in 0..10 {
1027                tokio::task::yield_now().await;
1028            }
1029
1030            // Fast-forward well past `HTTP_GRACEFUL_SHUTDOWN_TIMEOUT` with
1031            // no shutdown signal ever sent.
1032            tokio::time::advance(
1033                super::super::HTTP_GRACEFUL_SHUTDOWN_TIMEOUT + std::time::Duration::from_secs(5),
1034            )
1035            .await;
1036            for _ in 0..10 {
1037                tokio::task::yield_now().await;
1038            }
1039
1040            assert!(
1041                !server_task.is_finished(),
1042                "run_http must still be serving after HTTP_GRACEFUL_SHUTDOWN_TIMEOUT of uptime \
1043                 with no shutdown signal sent"
1044            );
1045
1046            server_task.abort();
1047        }
1048
1049        /// Verifies `run_http` returns an error when the bind address is already in use.
1050        #[tokio::test]
1051        async fn test_run_http_bind_error() {
1052            use std::path::PathBuf;
1053            use std::sync::Arc;
1054
1055            use tokio::sync::Mutex;
1056
1057            use crate::bridge::{NotificationCache, SubscriptionRegistry, Translator};
1058            use crate::config::McpConfig;
1059            use crate::mcp::McplsServer;
1060
1061            // Hold a listener to make the port unavailable.
1062            let occupied = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1063            let addr = occupied.local_addr().unwrap();
1064
1065            let translator = Arc::new(Translator::new());
1066            let notification_cache = Arc::new(Mutex::new(NotificationCache::new()));
1067            let workspace_roots: Arc<[PathBuf]> = Arc::from(Vec::new());
1068            let subs = SubscriptionRegistry::new();
1069            let server = McplsServer::new(
1070                translator,
1071                notification_cache,
1072                workspace_roots,
1073                subs,
1074                false,
1075                McpConfig::default(),
1076            );
1077
1078            let cfg = HttpConfig::new(addr, "/mcp");
1079
1080            let result =
1081                super::super::run_http(server, cfg, super::super::ShutdownSignal::new()).await;
1082            assert!(
1083                result.is_err(),
1084                "run_http should fail when port is occupied"
1085            );
1086
1087            drop(occupied);
1088        }
1089
1090        /// Builds a `McplsServer` with default collaborators and the given
1091        /// workspace roots, matching the setup shared by every
1092        /// `run_http`-driving test in this module.
1093        fn test_server_with_roots(
1094            workspace_roots: std::sync::Arc<[std::path::PathBuf]>,
1095        ) -> crate::mcp::McplsServer {
1096            use std::sync::Arc;
1097
1098            use tokio::sync::Mutex;
1099
1100            use crate::bridge::{NotificationCache, SubscriptionRegistry, Translator};
1101            use crate::config::McpConfig;
1102            use crate::mcp::McplsServer;
1103
1104            let translator = Arc::new(Translator::new());
1105            let notification_cache = Arc::new(Mutex::new(NotificationCache::new()));
1106            let subs = SubscriptionRegistry::new();
1107            McplsServer::new(
1108                translator,
1109                notification_cache,
1110                workspace_roots,
1111                subs,
1112                false,
1113                McpConfig::default(),
1114            )
1115        }
1116
1117        /// [`test_server_with_roots`] with no workspace roots configured.
1118        fn test_server() -> crate::mcp::McplsServer {
1119            test_server_with_roots(std::sync::Arc::from(Vec::new()))
1120        }
1121
1122        /// Sends a raw HTTP/1.1 POST request over TCP and returns the raw response
1123        /// text (status line, headers, and body). Used because neither `reqwest`
1124        /// nor `tower`/`http-body-util` are available as dev-dependencies here.
1125        async fn raw_http_post(
1126            addr: SocketAddr,
1127            path: &str,
1128            extra_headers: &str,
1129            body: &[u8],
1130        ) -> String {
1131            use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
1132
1133            let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
1134            let request = format!(
1135                "POST {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n{extra_headers}Content-Length: {}\r\n\r\n",
1136                body.len()
1137            );
1138            stream.write_all(request.as_bytes()).await.unwrap();
1139            stream.write_all(body).await.unwrap();
1140
1141            let mut response = Vec::new();
1142            let mut buf = [0u8; 8192];
1143            loop {
1144                match tokio::time::timeout(std::time::Duration::from_secs(2), stream.read(&mut buf))
1145                    .await
1146                {
1147                    Ok(Ok(0)) | Err(_) => break,
1148                    Ok(Ok(n)) => response.extend_from_slice(&buf[..n]),
1149                    Ok(Err(e)) => panic!("read error: {e}"),
1150                }
1151            }
1152            String::from_utf8_lossy(&response).into_owned()
1153        }
1154
1155        /// A POST body exceeding `cfg.max_request_body_bytes` must be rejected
1156        /// with `413 Payload Too Large`, proving the config value reaches
1157        /// `StreamableHttpServerConfig::max_request_body_bytes`.
1158        #[tokio::test]
1159        async fn test_run_http_rejects_oversized_body_with_413() {
1160            let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1161            let addr = probe.local_addr().unwrap();
1162            drop(probe);
1163
1164            let cfg = HttpConfig::new(addr, "/mcp").with_max_request_body_bytes(64);
1165            let server_task = tokio::spawn(super::super::run_http(
1166                test_server(),
1167                cfg,
1168                super::super::ShutdownSignal::new(),
1169            ));
1170            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1171
1172            let oversized_body = vec![b'a'; 65];
1173            let response = raw_http_post(
1174                addr,
1175                "/mcp",
1176                "Accept: application/json, text/event-stream\r\nContent-Type: application/json\r\n",
1177                &oversized_body,
1178            )
1179            .await;
1180
1181            assert!(
1182                response.starts_with("HTTP/1.1 413"),
1183                "expected 413 Payload Too Large, got: {response}"
1184            );
1185
1186            server_task.abort();
1187        }
1188
1189        /// A POST body within `cfg.max_request_body_bytes` must not be rejected
1190        /// for size — it reaches JSON deserialization instead (the body here is
1191        /// intentionally not valid JSON-RPC, so a non-413 error distinguishes
1192        /// "passed the size check" from "was a valid request").
1193        #[tokio::test]
1194        async fn test_run_http_accepts_body_within_limit() {
1195            let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1196            let addr = probe.local_addr().unwrap();
1197            drop(probe);
1198
1199            let cfg = HttpConfig::new(addr, "/mcp").with_max_request_body_bytes(64);
1200            let server_task = tokio::spawn(super::super::run_http(
1201                test_server(),
1202                cfg,
1203                super::super::ShutdownSignal::new(),
1204            ));
1205            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1206
1207            let small_body = vec![b'a'; 32];
1208            let response = raw_http_post(
1209                addr,
1210                "/mcp",
1211                "Accept: application/json, text/event-stream\r\nContent-Type: application/json\r\n",
1212                &small_body,
1213            )
1214            .await;
1215
1216            assert!(
1217                !response.starts_with("HTTP/1.1 413"),
1218                "body within limit must not be rejected as too large, got: {response}"
1219            );
1220
1221            server_task.abort();
1222        }
1223
1224        /// `CappedSessionManager::create_session` must enforce a hard bound:
1225        /// once `max_sessions` sessions exist, the next `create_session` call
1226        /// fails with the capacity marker, and closing a session frees the
1227        /// slot back up for a subsequent `create_session` to succeed.
1228        // `manager` is used until the end of the test — clippy's drop-tightening
1229        // heuristic misreads that as an early-droppable temporary because
1230        // `CappedSessionManager` embeds a `tokio::sync::Mutex`.
1231        #[allow(clippy::significant_drop_tightening)]
1232        #[tokio::test]
1233        async fn test_capped_session_manager_enforces_hard_bound() {
1234            use rmcp::transport::streamable_http_server::session::SessionManager as _;
1235
1236            let manager = super::super::CappedSessionManager::new(1);
1237
1238            let (first_id, _transport) = manager.create_session().await.unwrap();
1239
1240            let second_err = manager.create_session().await.map(|_| ()).unwrap_err();
1241            assert!(
1242                matches!(
1243                    second_err,
1244                    super::super::CappedSessionManagerError::CapReached
1245                ),
1246                "expected CapReached once at capacity, got: {second_err:?}"
1247            );
1248
1249            manager.close_session(&first_id).await.unwrap();
1250
1251            let (third_id, _transport) = manager.create_session().await.unwrap();
1252            assert_ne!(first_id, third_id);
1253        }
1254
1255        /// Regression guard for S2: concurrent `create_session` calls must not
1256        /// overshoot `max_sessions`. Unlike the sequential test above (which
1257        /// would pass even against a racy check-then-create implementation),
1258        /// this spawns `N > max_sessions` calls at once and asserts exactly
1259        /// `max_sessions` succeed — the one test shape that actually
1260        /// distinguishes the atomic-semaphore design from a TOCTOU race.
1261        // `manager` is used until the end of the test — see the identical
1262        // drop-tightening note on `test_capped_session_manager_enforces_hard_bound`.
1263        #[allow(clippy::significant_drop_tightening)]
1264        #[tokio::test]
1265        async fn test_capped_session_manager_bounds_concurrent_create_session() {
1266            use rmcp::transport::streamable_http_server::session::SessionManager as _;
1267
1268            const MAX_SESSIONS: usize = 5;
1269            const CONCURRENT_ATTEMPTS: usize = 25;
1270
1271            let manager =
1272                std::sync::Arc::new(super::super::CappedSessionManager::new(MAX_SESSIONS));
1273
1274            let mut tasks = tokio::task::JoinSet::new();
1275            for _ in 0..CONCURRENT_ATTEMPTS {
1276                let manager = manager.clone();
1277                tasks.spawn(async move { manager.create_session().await.is_ok() });
1278            }
1279
1280            let mut succeeded = 0usize;
1281            while let Some(result) = tasks.join_next().await {
1282                if result.unwrap() {
1283                    succeeded += 1;
1284                }
1285            }
1286
1287            assert_eq!(
1288                succeeded, MAX_SESSIONS,
1289                "exactly max_sessions concurrent create_session calls must succeed"
1290            );
1291        }
1292
1293        /// Narrower unit test of the `enforce_session_cap` middleware itself
1294        /// (rather than the full `run_http` wiring): a `500` response whose
1295        /// body carries `SESSION_CAP_MARKER` must be rewritten to `429` with
1296        /// a `Retry-After` header.
1297        #[tokio::test]
1298        async fn test_enforce_session_cap_rewrites_capacity_marker_to_429() {
1299            let app = axum::Router::new()
1300                .route(
1301                    "/",
1302                    axum::routing::post(|| async {
1303                        (
1304                            axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1305                            format!(
1306                                "Encounter an error when create session: {}: maximum concurrent \
1307                                 HTTP sessions already active",
1308                                super::super::SESSION_CAP_MARKER
1309                            ),
1310                        )
1311                    }),
1312                )
1313                .layer(axum::middleware::from_fn(super::super::enforce_session_cap));
1314
1315            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1316            let addr = listener.local_addr().unwrap();
1317            let server_task = tokio::spawn(async move {
1318                axum::serve(listener, app).await.unwrap();
1319            });
1320            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1321
1322            let response = raw_http_post(addr, "/", "", b"{}").await;
1323            assert!(
1324                response.starts_with("HTTP/1.1 429"),
1325                "expected 429 for a marker-carrying 500, got: {response}"
1326            );
1327            assert!(
1328                response.to_lowercase().contains("retry-after"),
1329                "expected a Retry-After header, got: {response}"
1330            );
1331
1332            server_task.abort();
1333        }
1334
1335        /// A `500` response whose body does *not* carry `SESSION_CAP_MARKER`
1336        /// (an unrelated internal error) must pass through unchanged, proving
1337        /// the middleware doesn't misclassify every `500` as a capacity
1338        /// rejection.
1339        #[tokio::test]
1340        async fn test_enforce_session_cap_leaves_unrelated_500_untouched() {
1341            let app = axum::Router::new()
1342                .route(
1343                    "/",
1344                    axum::routing::post(|| async {
1345                        (
1346                            axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1347                            "Encounter an error when create session: some unrelated failure",
1348                        )
1349                    }),
1350                )
1351                .layer(axum::middleware::from_fn(super::super::enforce_session_cap));
1352
1353            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1354            let addr = listener.local_addr().unwrap();
1355            let server_task = tokio::spawn(async move {
1356                axum::serve(listener, app).await.unwrap();
1357            });
1358            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1359
1360            let response = raw_http_post(addr, "/", "", b"{}").await;
1361            assert!(
1362                response.starts_with("HTTP/1.1 500"),
1363                "unrelated 500s must not be rewritten to 429, got: {response}"
1364            );
1365
1366            server_task.abort();
1367        }
1368
1369        /// End-to-end: with `max_concurrent_sessions(1)`, a second concurrent
1370        /// `initialize` handshake over `run_http` must be rejected with `429`
1371        /// once the first session is established.
1372        #[tokio::test]
1373        async fn test_run_http_rejects_new_session_at_capacity_with_429() {
1374            let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1375            let addr = probe.local_addr().unwrap();
1376            drop(probe);
1377
1378            let cfg = HttpConfig::new(addr, "/mcp").with_max_concurrent_sessions(1);
1379            let server_task = tokio::spawn(super::super::run_http(
1380                test_server(),
1381                cfg,
1382                super::super::ShutdownSignal::new(),
1383            ));
1384            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1385
1386            let initialize_body = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}"#;
1387            let accept_headers =
1388                "Accept: application/json, text/event-stream\r\nContent-Type: application/json\r\n";
1389
1390            // First handshake must succeed and establish a session.
1391            let first = raw_http_post(addr, "/mcp", accept_headers, initialize_body).await;
1392            assert!(
1393                first.starts_with("HTTP/1.1 200"),
1394                "first initialize handshake should succeed, got: {first}"
1395            );
1396
1397            // Second handshake, with the sole slot still held, must be capped.
1398            let second = raw_http_post(addr, "/mcp", accept_headers, initialize_body).await;
1399            assert!(
1400                second.starts_with("HTTP/1.1 429"),
1401                "second initialize handshake should be rejected once at capacity, got: {second}"
1402            );
1403
1404            server_task.abort();
1405        }
1406
1407        /// S1 non-regression: a non-`initialize` request carrying SEP-2575
1408        /// per-request `_meta` protocol-version metadata
1409        /// (`io.modelcontextprotocol/protocolVersion` = `2026-07-28` plus the
1410        /// required `clientCapabilities` key) takes rmcp 3.2.0's stateless
1411        /// discover-lifecycle path and never calls
1412        /// `SessionManager::create_session` — `rmcp` serves it directly
1413        /// without touching the session table — so it must not be rejected
1414        /// by the cap even while `max_concurrent_sessions` legacy sessions
1415        /// are already active. (An `initialize` request is always
1416        /// classified legacy in 3.2.0 regardless of the protocol version it
1417        /// names, so it cannot be used to probe the stateless path.) This
1418        /// guards against a future refactor reintroducing request-header
1419        /// sniffing for the cap decision (the bug this design replaced).
1420        #[tokio::test]
1421        async fn test_run_http_stateless_request_bypasses_session_cap() {
1422            let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1423            let addr = probe.local_addr().unwrap();
1424            drop(probe);
1425
1426            let cfg = HttpConfig::new(addr, "/mcp").with_max_concurrent_sessions(1);
1427            let server_task = tokio::spawn(super::super::run_http(
1428                test_server(),
1429                cfg,
1430                super::super::ShutdownSignal::new(),
1431            ));
1432            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1433
1434            let accept_headers =
1435                "Accept: application/json, text/event-stream\r\nContent-Type: application/json\r\n";
1436
1437            // Fill the sole legacy-session slot.
1438            let legacy_initialize = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}"#;
1439            let legacy = raw_http_post(addr, "/mcp", accept_headers, legacy_initialize).await;
1440            assert!(
1441                legacy.starts_with("HTTP/1.1 200"),
1442                "legacy initialize should succeed and consume the sole session slot, got: {legacy}"
1443            );
1444
1445            // A non-`initialize` request carrying per-request `_meta`
1446            // protocol-version metadata takes rmcp 3.2.0's stateless
1447            // discover-lifecycle path and never creates a session, so it
1448            // must bypass the cap entirely even though the slot above is
1449            // still held. The `MCP-Protocol-Version` header must match the
1450            // `_meta` value once the latter is present, and declaring
1451            // `2026-07-28` also brings in SEP-2243's `Mcp-Method` header
1452            // requirement.
1453            let stateless_headers = "Accept: application/json, text/event-stream\r\nContent-Type: application/json\r\nMCP-Protocol-Version: 2026-07-28\r\nMcp-Method: resources/list\r\n";
1454            let stateless_request = br#"{"jsonrpc":"2.0","id":2,"method":"resources/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}"#;
1455            let stateless = raw_http_post(addr, "/mcp", stateless_headers, stateless_request).await;
1456            assert!(
1457                stateless.starts_with("HTTP/1.1 200"),
1458                "stateless requests must bypass the session cap entirely, got: {stateless}"
1459            );
1460
1461            server_task.abort();
1462        }
1463
1464        /// #478/#482 regression pin: on rmcp's stateless path (see
1465        /// `test_run_http_stateless_request_bypasses_session_cap` above), the
1466        /// service factory -- and therefore `McplsServer::for_new_session` --
1467        /// runs once per *request*, not once per session, registering a new
1468        /// `ResourceSubscriptions` set each time. Firing many stateless
1469        /// requests must not grow `SubscriptionRegistry` without bound (S2's
1470        /// prune-before-push in `register`); it must stay pinned near the
1471        /// handful of instances actually alive (the long-lived template
1472        /// `McplsServer` this test built, plus at most one not-yet-pruned
1473        /// stateless-request entry).
1474        #[tokio::test]
1475        async fn test_stateless_requests_do_not_grow_subscription_registry_unboundedly() {
1476            const REQUEST_COUNT: u32 = 20;
1477
1478            let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1479            let addr = probe.local_addr().unwrap();
1480            drop(probe);
1481
1482            let server = test_server();
1483            let registry = server.subscription_registry();
1484
1485            let cfg = HttpConfig::new(addr, "/mcp");
1486            let server_task = tokio::spawn(super::super::run_http(
1487                server,
1488                cfg,
1489                super::super::ShutdownSignal::new(),
1490            ));
1491            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1492
1493            let stateless_headers = "Accept: application/json, text/event-stream\r\nContent-Type: application/json\r\nMCP-Protocol-Version: 2026-07-28\r\nMcp-Method: resources/list\r\n";
1494
1495            for id in 0..REQUEST_COUNT {
1496                let body = format!(
1497                    r#"{{"jsonrpc":"2.0","id":{id},"method":"resources/list","params":{{"_meta":{{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{{}}}}}}}}"#
1498                );
1499                let response =
1500                    raw_http_post(addr, "/mcp", stateless_headers, body.as_bytes()).await;
1501                assert!(
1502                    response.starts_with("HTTP/1.1 200"),
1503                    "stateless request {id} should succeed, got: {response}"
1504                );
1505            }
1506
1507            // Let the background task each stateless request's handler is
1508            // spawned on (`serve_directly_with_ct`'s `waiting()` task in rmcp)
1509            // finish dropping its per-request `McplsServer`.
1510            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1511
1512            // Raw (unpruned) length: nothing in this test ever calls
1513            // `any_contains`/`is_all_empty` (no LSP server is running to
1514            // publish diagnostics), so the only thing that can keep this
1515            // bounded is `register` pruning dead entries on the way in.
1516            let raw = registry.raw_len();
1517            assert!(
1518                raw <= 2,
1519                "registry should stay bounded across {REQUEST_COUNT} stateless requests, got {raw} raw entries"
1520            );
1521
1522            server_task.abort();
1523        }
1524
1525        /// Shared setup for the `#482` regression tests below: a real
1526        /// `run_http` server with one file inside its sole workspace root, so
1527        /// `resources/subscribe` requests validate and reach the handler.
1528        struct SubscribeTestServer {
1529            addr: SocketAddr,
1530            uri: String,
1531            server_task: tokio::task::JoinHandle<Result<(), crate::Error>>,
1532            // Held so the file `subscribe` canonicalizes stays on disk.
1533            _workspace: tempfile::TempDir,
1534        }
1535
1536        async fn spawn_subscribe_test_server() -> SubscribeTestServer {
1537            let workspace = tempfile::TempDir::new().unwrap();
1538            let file_path = workspace.path().join("main.rs");
1539            std::fs::write(&file_path, "fn main() {}").unwrap();
1540            let uri = crate::bridge::resources::make_uri(&file_path).unwrap();
1541
1542            let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1543            let addr = probe.local_addr().unwrap();
1544            drop(probe);
1545
1546            let server =
1547                test_server_with_roots(std::sync::Arc::from(vec![workspace.path().to_path_buf()]));
1548
1549            let cfg = HttpConfig::new(addr, "/mcp");
1550            let server_task = tokio::spawn(super::super::run_http(
1551                server,
1552                cfg,
1553                super::super::ShutdownSignal::new(),
1554            ));
1555            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1556
1557            SubscribeTestServer {
1558                addr,
1559                uri,
1560                server_task,
1561                _workspace: workspace,
1562            }
1563        }
1564
1565        /// #482: `_meta` negotiating `2026-07-28` per request is stateless by
1566        /// both rmcp and mcpls' reckoning, so rmcp itself answers
1567        /// `-32601 method not found` before dispatch.
1568        #[tokio::test]
1569        async fn test_stateless_subscribe_negotiated_per_request_is_rejected_by_rmcp() {
1570            let srv = spawn_subscribe_test_server().await;
1571            let uri = &srv.uri;
1572
1573            let headers = format!(
1574                "Accept: application/json, text/event-stream\r\nContent-Type: application/json\r\nMCP-Protocol-Version: 2026-07-28\r\nMcp-Method: resources/subscribe\r\nMcp-Name: {uri}\r\n"
1575            );
1576            let body = format!(
1577                r#"{{"jsonrpc":"2.0","id":1,"method":"resources/subscribe","params":{{"uri":"{uri}","_meta":{{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{{}}}}}}}}"#
1578            );
1579            let response = raw_http_post(srv.addr, "/mcp", &headers, body.as_bytes()).await;
1580            assert!(
1581                response.contains("-32601"),
1582                "expected rmcp to refuse dispatching a 2026-07-28-negotiated resources/subscribe \
1583                 (method not found), got: {response}"
1584            );
1585
1586            srv.server_task.abort();
1587        }
1588
1589        /// #482 regression matrix: `_meta` naming a pre-`2026-07-28` version
1590        /// (see [`super::super::request_uses_discover_lifecycle_meta`]'s docs
1591        /// for why rmcp still serves this statelessly) must be rejected by
1592        /// mcpls' own guard for both `subscribe` and `unsubscribe`, and a
1593        /// fabricated `Mcp-Session-Id` must not bypass it.
1594        #[tokio::test]
1595        async fn test_stateless_lifecycle_mismatch_is_rejected_by_mcpls() {
1596            let srv = spawn_subscribe_test_server().await;
1597            let uri = &srv.uri;
1598
1599            for (method, extra_header) in [
1600                ("resources/subscribe", ""),
1601                (
1602                    "resources/subscribe",
1603                    "Mcp-Session-Id: not-a-real-session\r\n",
1604                ),
1605                ("resources/unsubscribe", ""),
1606            ] {
1607                let headers = format!(
1608                    "Accept: application/json, text/event-stream\r\nContent-Type: application/json\r\nMCP-Protocol-Version: 2025-06-18\r\n{extra_header}"
1609                );
1610                let body = format!(
1611                    r#"{{"jsonrpc":"2.0","id":1,"method":"{method}","params":{{"uri":"{uri}","_meta":{{"io.modelcontextprotocol/protocolVersion":"2025-06-18","io.modelcontextprotocol/clientCapabilities":{{}}}}}}}}"#
1612                );
1613                let response = raw_http_post(srv.addr, "/mcp", &headers, body.as_bytes()).await;
1614                assert!(
1615                    response.contains("-32052") && !response.contains(r#""result":{}"#),
1616                    "{method} (extra header: {extra_header:?}) must be rejected by mcpls' \
1617                     stateless-subscription guard, not silently succeed, got: {response}"
1618                );
1619            }
1620
1621            srv.server_task.abort();
1622        }
1623
1624        /// #482 non-regression: a legacy session's `resources/subscribe`,
1625        /// sent with the session's assigned `Mcp-Session-Id` echoed back and
1626        /// no per-request `_meta`, must not be rejected by either guard above.
1627        #[tokio::test]
1628        async fn test_legacy_session_subscribe_is_not_rejected_as_stateless() {
1629            let srv = spawn_subscribe_test_server().await;
1630            let uri = &srv.uri;
1631            let session_id = initialize_legacy_session(srv.addr).await;
1632
1633            let session_headers = format!(
1634                "Accept: application/json, text/event-stream\r\nContent-Type: application/json\r\nMcp-Session-Id: {session_id}\r\n"
1635            );
1636            let subscribe_body = format!(
1637                r#"{{"jsonrpc":"2.0","id":2,"method":"resources/subscribe","params":{{"uri":"{uri}"}}}}"#
1638            );
1639            let session_response = raw_http_post(
1640                srv.addr,
1641                "/mcp",
1642                &session_headers,
1643                subscribe_body.as_bytes(),
1644            )
1645            .await;
1646            assert!(
1647                !session_response.contains("-32601") && !session_response.contains("-32052"),
1648                "a legacy session's subscribe must not be rejected as stateless, got: \
1649                 {session_response}"
1650            );
1651
1652            srv.server_task.abort();
1653        }
1654
1655        /// #482: a live session's `resources/subscribe` that also carries
1656        /// per-request discover-lifecycle `_meta` is rejected too -- rmcp
1657        /// serves it statelessly regardless of the session id, so this is
1658        /// intentional, not a regression.
1659        #[tokio::test]
1660        async fn test_legacy_session_subscribe_with_discover_meta_is_rejected_as_stateless() {
1661            let srv = spawn_subscribe_test_server().await;
1662            let uri = &srv.uri;
1663            let session_id = initialize_legacy_session(srv.addr).await;
1664
1665            let headers = format!(
1666                "Accept: application/json, text/event-stream\r\nContent-Type: application/json\r\nMcp-Session-Id: {session_id}\r\nMCP-Protocol-Version: 2025-06-18\r\n"
1667            );
1668            let body = format!(
1669                r#"{{"jsonrpc":"2.0","id":2,"method":"resources/subscribe","params":{{"uri":"{uri}","_meta":{{"io.modelcontextprotocol/protocolVersion":"2025-06-18","io.modelcontextprotocol/clientCapabilities":{{}}}}}}}}"#
1670            );
1671            let response = raw_http_post(srv.addr, "/mcp", &headers, body.as_bytes()).await;
1672            assert!(
1673                response.contains("-32052"),
1674                "a live session's subscribe with per-request discover _meta must still be \
1675                 rejected -- rmcp serves it statelessly regardless of the session id, got: \
1676                 {response}"
1677            );
1678
1679            srv.server_task.abort();
1680        }
1681
1682        /// Performs the `initialize` handshake for a legacy HTTP session and
1683        /// returns its assigned `Mcp-Session-Id`.
1684        async fn initialize_legacy_session(addr: SocketAddr) -> String {
1685            let accept_headers =
1686                "Accept: application/json, text/event-stream\r\nContent-Type: application/json\r\n";
1687            let initialize_body = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}"#;
1688            let init_response = raw_http_post(addr, "/mcp", accept_headers, initialize_body).await;
1689            assert!(
1690                init_response.starts_with("HTTP/1.1 200"),
1691                "legacy initialize should succeed, got: {init_response}"
1692            );
1693            init_response
1694                .lines()
1695                .find_map(|line| {
1696                    let (name, value) = line.split_once(':')?;
1697                    name.eq_ignore_ascii_case("mcp-session-id")
1698                        .then(|| value.trim().to_string())
1699                })
1700                .unwrap()
1701        }
1702
1703        /// #233: binding to a non-loopback address must log a warning that
1704        /// tells operators to put the endpoint behind a reverse proxy that
1705        /// *enforces* authentication (not the inverted "ensure no
1706        /// authentication is required" wording it replaced).
1707        #[tokio::test]
1708        async fn test_run_http_non_loopback_bind_warns_to_use_reverse_proxy() {
1709            use tracing_subscriber::layer::SubscriberExt as _;
1710
1711            let addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
1712            let cfg = HttpConfig::new(addr, "/mcp");
1713
1714            let captured = CapturedLogs::default();
1715            let subscriber = tracing_subscriber::registry().with(captured.clone());
1716            let guard = tracing::subscriber::set_default(subscriber);
1717
1718            // The warning fires synchronously right after bind, before
1719            // `axum::serve` starts running indefinitely, so a short timeout
1720            // is enough to observe it.
1721            let _ = tokio::time::timeout(
1722                std::time::Duration::from_millis(200),
1723                super::super::run_http(test_server(), cfg, super::super::ShutdownSignal::new()),
1724            )
1725            .await;
1726
1727            drop(guard);
1728
1729            let messages = captured.messages();
1730            assert!(
1731                messages.iter().any(|m| m.contains(
1732                    "place this endpoint behind a reverse proxy that enforces authentication"
1733                )),
1734                "expected reverse-proxy warning in captured tracing events, got: {messages:?}"
1735            );
1736        }
1737    }
1738}