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}