Skip to main content

mcpls_core/transport/
config.rs

1//! HTTP transport configuration: `HttpConfig`, its limit newtypes and
2//! stream-liveness policy.
3
4use super::allowlist::{AllowedHost, AllowedOrigin, HttpPath};
5use super::lease::{LeaseWindow, ListenLease};
6use super::session_manager::IdleTimeout;
7
8/// Configuration for the HTTP transport.
9///
10/// Passed inside [`Transport::Http`](crate::Transport::Http) to control the TCP bind address and the
11/// URL path the MCP service is mounted at.
12///
13/// # Note on DNS rebinding
14///
15/// `rmcp`'s `StreamableHttpService` validates the `Host` header against an
16/// allow-list. mcpls builds it from the loopback names (`localhost`,
17/// `127.0.0.1`, `::1`, any port), the bound address when it is a specific
18/// non-loopback IP literal (any port), and
19/// [`HttpConfig::allowed_hosts`]. There is no wildcard: a bind to `0.0.0.0`
20/// or `[::]` allows only the loopback names and the configured hosts, so
21/// clients must send a `Host` that matches one of them, or use a reverse
22/// proxy that rewrites the `Host` header.
23///
24/// A request carrying an `Origin` header is accepted only when it names a
25/// loopback origin (`localhost`, `127.0.0.1`, `[::1]`) on the bound port or
26/// one of [`HttpConfig::allowed_origins`]; anything else, including
27/// `Origin: null`, is answered with `403`. A request without `Origin` (every
28/// non-browser client) is unaffected.
29///
30/// [`HttpConfig::allowed_origins`] serves browser pages whose requests reach
31/// mcpls with an allowed `Host`, through a tunnel or a reverse proxy. It does
32/// not make a `Host` acceptable: the `Host` check runs first, so a deployment
33/// behind a non-loopback name needs [`HttpConfig::allowed_hosts`] as well.
34///
35/// # Examples
36///
37/// ```rust,ignore
38/// use std::net::SocketAddr;
39/// use mcpls_core::{HttpConfig, Transport};
40///
41/// let cfg = HttpConfig::new("127.0.0.1:3000".parse().unwrap());
42/// let transport = Transport::Http(cfg);
43/// ```
44#[derive(Debug, Clone)]
45#[non_exhaustive]
46pub struct HttpConfig {
47    /// TCP address to bind (e.g. `127.0.0.1:3000`).
48    pub bind: std::net::SocketAddr,
49    /// URL path prefix the MCP service is mounted at (e.g. `"/mcp"`).
50    ///
51    /// The same service also answers at the root path `/`, regardless of
52    /// this value, so a reverse proxy must not rely on `path` alone to
53    /// restrict which URLs reach mcpls.
54    pub path: HttpPath,
55    /// Maximum size of a single POST request body.
56    ///
57    /// Enforced by `rmcp`'s `StreamableHttpService` while streaming the body,
58    /// independent of `Content-Length` or chunked transfer encoding. Requests
59    /// exceeding this limit receive `413 Payload Too Large`. Defaults to
60    /// [`RequestBodyLimit::DEFAULT`] (4 MiB), which comfortably covers MCP
61    /// tool-call request bodies (large results, e.g. from `workspace/symbol`
62    /// or bulk edits, are returned in the response, which this limit does not
63    /// constrain). Larger values are clamped by [`RequestBodyLimit::new`].
64    pub max_request_body: RequestBodyLimit,
65    /// Maximum number of concurrent HTTP sessions.
66    ///
67    /// This is a hard bound, enforced atomically at session creation via a
68    /// semaphore — never more than this many sessions can be active at once,
69    /// regardless of request concurrency.
70    /// Requests that would start a new session beyond this limit receive
71    /// `429 Too Many Requests`. Defaults to [`SessionLimit::DEFAULT`].
72    pub max_concurrent_sessions: SessionLimit,
73    /// How long a session may go without client activity before it is closed.
74    pub session_idle_timeout: IdleTimeout,
75    /// Longest a client may take to send a complete request header, and
76    /// the longest it may pause between request-body chunks. It is also how
77    /// long an idle keep-alive connection stays open.
78    ///
79    /// Bounds slow-header and slow-body ("slowloris") clients: a stalled
80    /// header closes the connection, a stalled body is answered with
81    /// `408 Request Timeout`. A whole request body must also arrive within
82    /// four times this timeout, and within at least two minutes, so a client
83    /// trickling one chunk per window is cut too. It never interrupts a
84    /// response, so long-lived SSE streams are unaffected, and it does not
85    /// bound a client that stops *reading* a response
86    /// ([`HttpConfig::write_stall_timeout`] does). Defaults to
87    /// [`HeaderReadTimeout::DEFAULT`].
88    pub header_read_timeout: HeaderReadTimeout,
89    /// Maximum number of concurrently open TCP connections.
90    ///
91    /// Further connections wait in the kernel accept queue until one
92    /// closes. Defaults to [`ConnectionLimit::DEFAULT`]. Values above what
93    /// the underlying semaphore supports are clamped by
94    /// [`ConnectionLimit::new`].
95    pub max_concurrent_connections: ConnectionLimit,
96    /// Liveness probing of each session's standalone GET (SSE) stream.
97    ///
98    /// Defaults to [`StreamLiveness::DEFAULT`].
99    pub stream_liveness: StreamLiveness,
100    /// Lifetime of stateless `subscriptions/listen` streams.
101    ///
102    /// `None` (the default) follows [`HttpConfig::stream_liveness`]: the
103    /// default lease while probing, none when it is
104    /// [`StreamLiveness::Disabled`].
105    pub listen_lease: Option<ListenLease>,
106    /// Browser origins accepted in addition to the loopback origins on the
107    /// bound port. Empty by default.
108    pub allowed_origins: Box<[AllowedOrigin]>,
109    /// `Host` header values accepted in addition to the loopback names and
110    /// the bound IP literal. Empty by default.
111    pub allowed_hosts: Box<[AllowedHost]>,
112    /// Longest a POST or request-wise resume response stream stays open,
113    /// counted from when it opens.
114    ///
115    /// A total lifetime bound, not an idle timeout: the stream is cut when it
116    /// elapses however much it is still sending, which releases its hold on
117    /// the session so the idle reaper can expire it. While the connection's
118    /// write to a peer that stopped reading is stuck the body is not polled,
119    /// so the cut cannot fire; [`HttpConfig::write_stall_timeout`] closes the
120    /// connection and frees its [`HttpConfig::max_concurrent_connections`]
121    /// permit, and the session slot is freed after the idle timeout.
122    /// Defaults to [`ResponseStreamDeadline::DEFAULT`].
123    pub response_stream_deadline: ResponseStreamDeadline,
124    /// Longest a write to the peer may stay stalled before the connection is
125    /// closed.
126    ///
127    /// Frees the connection permit of a peer that stops reading a response or
128    /// reads it too slowly: a write that goes pending opens a window of this
129    /// length, which closes when a flush completes or the peer has taken at
130    /// least 2 KiB per second of the window (between 4 KiB and 1 MiB); a
131    /// window that expires first closes the connection. A streamed (SSE)
132    /// response is flushed after every frame, so each flush closes the window
133    /// and the floor there is one frame per window, the same a quiet
134    /// legitimate subscriber gives; the rate floor binds responses written in
135    /// large buffers. Defaults to [`WriteStallTimeout::DEFAULT`].
136    pub write_stall_timeout: WriteStallTimeout,
137}
138
139impl HttpConfig {
140    /// Create an [`HttpConfig`] mounted at [`HttpPath::default`] with default
141    /// body-size, session, timeout and connection caps.
142    ///
143    /// # Examples
144    ///
145    /// ```
146    /// use mcpls_core::HttpConfig;
147    ///
148    /// let cfg = HttpConfig::new("127.0.0.1:3000".parse().unwrap());
149    /// assert_eq!(cfg.path.as_str(), "/mcp");
150    /// ```
151    #[must_use]
152    pub fn new(bind: std::net::SocketAddr) -> Self {
153        Self {
154            bind,
155            path: HttpPath::default(),
156            max_request_body: RequestBodyLimit::DEFAULT,
157            max_concurrent_sessions: SessionLimit::DEFAULT,
158            session_idle_timeout: IdleTimeout::DEFAULT,
159            header_read_timeout: HeaderReadTimeout::DEFAULT,
160            max_concurrent_connections: ConnectionLimit::DEFAULT,
161            stream_liveness: StreamLiveness::DEFAULT,
162            listen_lease: None,
163            allowed_origins: Box::default(),
164            allowed_hosts: Box::default(),
165            response_stream_deadline: ResponseStreamDeadline::DEFAULT,
166            write_stall_timeout: WriteStallTimeout::DEFAULT,
167        }
168    }
169
170    /// Override the URL path the MCP service is mounted at.
171    ///
172    /// # Examples
173    ///
174    /// ```
175    /// use mcpls_core::{HttpConfig, HttpPath};
176    ///
177    /// let cfg = HttpConfig::new("127.0.0.1:3000".parse().unwrap())
178    ///     .with_path("/api/mcp".parse::<HttpPath>().unwrap());
179    /// assert_eq!(cfg.path.as_str(), "/api/mcp");
180    /// ```
181    #[must_use]
182    pub fn with_path(mut self, path: HttpPath) -> Self {
183        self.path = path;
184        self
185    }
186
187    /// Override the maximum POST request body size.
188    ///
189    /// # Examples
190    ///
191    /// ```
192    /// use mcpls_core::{HttpConfig, RequestBodyLimit};
193    ///
194    /// let cfg = HttpConfig::new("127.0.0.1:3000".parse().unwrap())
195    ///     .with_max_request_body(RequestBodyLimit::new(1024).unwrap());
196    /// assert_eq!(cfg.max_request_body.get(), 1024);
197    /// ```
198    #[must_use]
199    pub const fn with_max_request_body(mut self, limit: RequestBodyLimit) -> Self {
200        self.max_request_body = limit;
201        self
202    }
203
204    /// Override the maximum number of concurrent HTTP sessions.
205    #[must_use]
206    pub const fn with_max_concurrent_sessions(mut self, max: SessionLimit) -> Self {
207        self.max_concurrent_sessions = max;
208        self
209    }
210
211    /// Override the request read (header, body pause, idle keep-alive) timeout.
212    #[must_use]
213    pub const fn with_header_read_timeout(mut self, timeout: HeaderReadTimeout) -> Self {
214        self.header_read_timeout = timeout;
215        self
216    }
217
218    /// Override the maximum number of concurrently open connections.
219    #[must_use]
220    pub const fn with_max_concurrent_connections(mut self, max: ConnectionLimit) -> Self {
221        self.max_concurrent_connections = max;
222        self
223    }
224
225    /// Accept browser requests from `origins` in addition to the loopback
226    /// origins on the bound port.
227    ///
228    /// # Examples
229    ///
230    /// ```
231    /// use mcpls_core::{AllowedOrigin, HttpConfig};
232    ///
233    /// let cfg = HttpConfig::new("127.0.0.1:3000".parse().unwrap())
234    ///     .with_allowed_origins(["https://app.example.com".parse::<AllowedOrigin>().unwrap()]);
235    /// assert_eq!(cfg.allowed_origins.len(), 1);
236    /// ```
237    #[must_use]
238    pub fn with_allowed_origins(
239        mut self,
240        origins: impl IntoIterator<Item = AllowedOrigin>,
241    ) -> Self {
242        self.allowed_origins = origins.into_iter().collect();
243        self
244    }
245
246    /// Accept requests whose `Host` is one of `hosts` in addition to the
247    /// loopback names and the bound IP literal.
248    ///
249    /// Clients and proxies omit the default port from `Host`, so a pin to
250    /// `:80` or `:443` is rejected when the [`AllowedHost`] is parsed: list the
251    /// host without a port, which matches any port.
252    ///
253    /// # Examples
254    ///
255    /// ```
256    /// use mcpls_core::{AllowedHost, HttpConfig};
257    ///
258    /// let cfg = HttpConfig::new("0.0.0.0:3000".parse().unwrap())
259    ///     .with_allowed_hosts(["mcp.example.com".parse::<AllowedHost>().unwrap()]);
260    /// assert_eq!(cfg.allowed_hosts.len(), 1);
261    /// ```
262    #[must_use]
263    pub fn with_allowed_hosts(mut self, hosts: impl IntoIterator<Item = AllowedHost>) -> Self {
264        self.allowed_hosts = hosts.into_iter().collect();
265        self
266    }
267
268    /// Override how long a write may stall before the connection is closed.
269    ///
270    /// # Examples
271    ///
272    /// ```
273    /// use std::time::Duration;
274    /// use mcpls_core::{HttpConfig, WriteStallTimeout};
275    ///
276    /// let cfg = HttpConfig::new("127.0.0.1:3000".parse().unwrap())
277    ///     .with_write_stall_timeout(WriteStallTimeout::new(Duration::from_secs(10)).unwrap());
278    /// assert_eq!(cfg.write_stall_timeout.get(), Duration::from_secs(10));
279    /// ```
280    #[must_use]
281    pub const fn with_write_stall_timeout(mut self, timeout: WriteStallTimeout) -> Self {
282        self.write_stall_timeout = timeout;
283        self
284    }
285
286    /// Override how long a POST or resume response stream may stay open.
287    ///
288    /// # Examples
289    ///
290    /// ```
291    /// use std::time::Duration;
292    /// use mcpls_core::{HttpConfig, ResponseStreamDeadline};
293    ///
294    /// let cfg = HttpConfig::new("127.0.0.1:3000".parse().unwrap())
295    ///     .with_response_stream_deadline(ResponseStreamDeadline::new(Duration::from_mins(30)).unwrap());
296    /// assert_eq!(cfg.response_stream_deadline.get(), Duration::from_mins(30));
297    /// ```
298    #[must_use]
299    pub const fn with_response_stream_deadline(mut self, deadline: ResponseStreamDeadline) -> Self {
300        self.response_stream_deadline = deadline;
301        self
302    }
303
304    /// Override how long a session may go without client activity before it
305    /// is closed.
306    ///
307    /// # Examples
308    ///
309    /// ```
310    /// use std::time::Duration;
311    /// use mcpls_core::HttpConfig;
312    /// use mcpls_core::transport::IdleTimeout;
313    ///
314    /// let cfg = HttpConfig::new("127.0.0.1:3000".parse().unwrap())
315    ///     .with_session_idle_timeout(IdleTimeout::new(Duration::from_mins(10)).unwrap());
316    /// assert_eq!(cfg.session_idle_timeout.get(), Duration::from_mins(10));
317    /// ```
318    #[must_use]
319    pub const fn with_session_idle_timeout(mut self, timeout: IdleTimeout) -> Self {
320        self.session_idle_timeout = timeout;
321        self
322    }
323
324    /// Override how standalone GET streams are probed for liveness.
325    ///
326    /// # Examples
327    ///
328    /// ```
329    /// use mcpls_core::{HttpConfig, StreamLiveness};
330    ///
331    /// let cfg = HttpConfig::new("127.0.0.1:3000".parse().unwrap())
332    ///     .with_stream_liveness(StreamLiveness::Disabled);
333    /// assert_eq!(cfg.stream_liveness, StreamLiveness::Disabled);
334    /// ```
335    #[must_use]
336    pub const fn with_stream_liveness(mut self, liveness: StreamLiveness) -> Self {
337        self.stream_liveness = liveness;
338        self
339    }
340
341    /// Override the lifetime of stateless `subscriptions/listen` streams
342    /// instead of deriving it from [`HttpConfig::stream_liveness`].
343    ///
344    /// # Examples
345    ///
346    /// ```
347    /// use mcpls_core::HttpConfig;
348    /// use mcpls_core::transport::ListenLease;
349    ///
350    /// let cfg = HttpConfig::new("127.0.0.1:3000".parse().unwrap())
351    ///     .with_listen_lease(ListenLease::Unbounded);
352    /// assert_eq!(cfg.effective_listen_lease(), ListenLease::Unbounded);
353    /// ```
354    #[must_use]
355    pub const fn with_listen_lease(mut self, lease: ListenLease) -> Self {
356        self.listen_lease = Some(lease);
357        self
358    }
359
360    /// The listen lease in force: the explicit override, else the default
361    /// lease while stream liveness probing is on and none when it is off.
362    ///
363    /// # Examples
364    ///
365    /// ```
366    /// use mcpls_core::{HttpConfig, StreamLiveness};
367    /// use mcpls_core::transport::ListenLease;
368    ///
369    /// let cfg = HttpConfig::new("127.0.0.1:3000".parse().unwrap());
370    /// assert_eq!(cfg.effective_listen_lease(), ListenLease::default());
371    /// let off = cfg.with_stream_liveness(StreamLiveness::Disabled);
372    /// assert_eq!(off.effective_listen_lease(), ListenLease::Unbounded);
373    /// ```
374    #[must_use]
375    pub const fn effective_listen_lease(&self) -> ListenLease {
376        match (self.listen_lease, self.stream_liveness) {
377            (Some(lease), _) => lease,
378            (None, StreamLiveness::Probe { .. }) => ListenLease::Renew(LeaseWindow::DEFAULT),
379            (None, StreamLiveness::Disabled) => ListenLease::Unbounded,
380        }
381    }
382}
383
384macro_rules! non_zero_duration {
385    ($(#[$meta:meta])* $vis:vis $name:ident, $default:expr) => {
386        $(#[$meta])*
387        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
388        $vis struct $name(std::time::Duration);
389
390        impl $name {
391            /// The default duration.
392            $vis const DEFAULT: Self = match Self::new($default) {
393                Some(duration) => duration,
394                None => panic!("the default duration must be non-zero"),
395            };
396
397            /// `None` for a zero duration.
398            #[must_use]
399            $vis const fn new(duration: std::time::Duration) -> Option<Self> {
400                if duration.is_zero() {
401                    None
402                } else {
403                    Some(Self(duration))
404                }
405            }
406
407            /// The wrapped duration, never zero.
408            #[must_use]
409            $vis const fn get(self) -> std::time::Duration {
410                self.0
411            }
412        }
413    };
414}
415
416pub(super) use non_zero_duration;
417
418macro_rules! non_zero_limit {
419    (
420        $(#[$meta:meta])* $name:ident,
421        $default:expr, $default_doc:literal,
422        $max:expr, $max_doc:literal
423    ) => {
424        $(#[$meta])*
425        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
426        pub struct $name(usize);
427
428        const _: () = assert!($default <= $max, "the default limit must not exceed the maximum");
429
430        impl $name {
431            #[doc = $default_doc]
432            pub const DEFAULT: Self = Self($default);
433
434            #[doc = concat!("`None` for zero; larger values are clamped to ", $max_doc, ".")]
435            #[must_use]
436            pub fn new(max: usize) -> Option<Self> {
437                (max > 0).then(|| Self(max.min($max)))
438            }
439
440            #[doc = concat!("The wrapped limit, between 1 and ", $max_doc, ".")]
441            #[must_use]
442            pub const fn get(self) -> usize {
443                self.0
444            }
445        }
446    };
447}
448
449non_zero_duration! {
450    /// Non-zero delay between liveness probes on a GET stream.
451    ///
452    /// # Examples
453    ///
454    /// ```
455    /// use std::time::Duration;
456    /// use mcpls_core::ProbeInterval;
457    ///
458    /// assert!(ProbeInterval::new(Duration::ZERO).is_none());
459    /// let interval = ProbeInterval::new(Duration::from_mins(1)).unwrap();
460    /// assert_eq!(interval.get(), Duration::from_mins(1));
461    /// ```
462    pub ProbeInterval, std::time::Duration::from_mins(1)
463}
464
465non_zero_duration! {
466    /// Non-zero time a client has to answer a liveness probe before its GET
467    /// stream is closed.
468    ///
469    /// # Examples
470    ///
471    /// ```
472    /// use std::time::Duration;
473    /// use mcpls_core::ProbeDeadline;
474    ///
475    /// assert!(ProbeDeadline::new(Duration::ZERO).is_none());
476    /// let deadline = ProbeDeadline::new(Duration::from_secs(30)).unwrap();
477    /// assert_eq!(deadline.get(), Duration::from_secs(30));
478    /// ```
479    pub ProbeDeadline, std::time::Duration::from_secs(30)
480}
481
482non_zero_duration! {
483    /// Non-zero time a POST (or request-wise resume) response stream may stay
484    /// open before it is cut, for [`HttpConfig::response_stream_deadline`].
485    ///
486    /// A zero deadline would cut every stream at once, so it is
487    /// unrepresentable. The default covers the longest single tool call: two
488    /// maximum-length respawn or request waits, an indexing wait and margin.
489    ///
490    /// # Examples
491    ///
492    /// ```
493    /// use std::time::Duration;
494    /// use mcpls_core::ResponseStreamDeadline;
495    ///
496    /// assert!(ResponseStreamDeadline::new(Duration::ZERO).is_none());
497    /// let deadline = ResponseStreamDeadline::new(Duration::from_mins(5)).unwrap();
498    /// assert_eq!(deadline.get(), Duration::from_mins(5));
499    /// assert_eq!(ResponseStreamDeadline::DEFAULT.get(), Duration::from_hours(1));
500    /// ```
501    pub ResponseStreamDeadline, std::time::Duration::from_hours(1)
502}
503
504const _: () = assert!(
505    ResponseStreamDeadline::DEFAULT.get().as_secs()
506        >= 2 * crate::config::MAX_TIMEOUT_SECONDS
507            + crate::bridge::INDEXING_STALENESS_BOUND.as_secs()
508            + 300,
509    "the default response stream deadline must outlast the longest single tool call"
510);
511
512/// How a session's standalone GET (SSE) stream is checked for a dead peer.
513///
514/// With [`StreamLiveness::Probe`], mcpls sends an MCP `ping` request on the
515/// stream every `interval`. A client that does not answer it (by sending the
516/// JSON-RPC response in a POST) within `deadline` has its stream closed and the
517/// resources behind it released. This is portable, needs no socket options,
518/// and also detects a peer that vanished behind a reverse proxy, which TCP
519/// keepalive cannot.
520///
521/// A client that ignores server `ping` requests is closed and reconnects
522/// every `interval + deadline`; use [`StreamLiveness::Disabled`] for such
523/// clients.
524///
525/// # Examples
526///
527/// ```
528/// use std::assert_matches;
529///
530/// use mcpls_core::StreamLiveness;
531///
532/// assert_matches!(StreamLiveness::default(), StreamLiveness::Probe { .. });
533/// ```
534#[derive(Debug, Clone, Copy, PartialEq, Eq)]
535pub enum StreamLiveness {
536    /// Never probe; a vanished peer is noticed only when the OS gives up on
537    /// the connection. A POST or resume stream is still cut once it has been
538    /// open for [`ResponseStreamDeadline`]; a peer that stopped reading a
539    /// response loses its connection after [`HttpConfig::write_stall_timeout`]
540    /// and its session slot after the idle timeout, which no probe could
541    /// free sooner either. Without proof of life an
542    /// open GET stream does not keep its session alive: the session expires
543    /// after the idle timeout (5 minutes) without inbound requests, even while
544    /// the stream receives notifications.
545    Disabled,
546    /// Probe every `interval`; close the stream when unanswered for
547    /// `deadline`.
548    Probe {
549        /// Delay between the end of one probe and the start of the next.
550        interval: ProbeInterval,
551        /// Time a client has to answer a probe.
552        deadline: ProbeDeadline,
553    },
554}
555
556impl StreamLiveness {
557    /// Probe every 60 seconds with a 30 second answer deadline.
558    pub const DEFAULT: Self = Self::Probe {
559        interval: ProbeInterval::DEFAULT,
560        deadline: ProbeDeadline::DEFAULT,
561    };
562}
563
564impl Default for StreamLiveness {
565    fn default() -> Self {
566        Self::DEFAULT
567    }
568}
569
570non_zero_duration! {
571    /// Non-zero request read timeout for [`HttpConfig::header_read_timeout`].
572    ///
573    /// A zero duration would drop every connection, so it is unrepresentable.
574    ///
575    /// # Examples
576    ///
577    /// ```
578    /// use std::time::Duration;
579    /// use mcpls_core::HeaderReadTimeout;
580    ///
581    /// assert!(HeaderReadTimeout::new(Duration::ZERO).is_none());
582    /// let timeout = HeaderReadTimeout::new(Duration::from_secs(10)).unwrap();
583    /// assert_eq!(timeout.get(), Duration::from_secs(10));
584    /// ```
585    pub HeaderReadTimeout, std::time::Duration::from_secs(30)
586}
587
588non_zero_duration! {
589    /// Non-zero time a write to the peer may make no progress before the
590    /// connection is closed, for [`HttpConfig::write_stall_timeout`].
591    ///
592    /// A zero timeout would close every connection that cannot flush at once,
593    /// so it is unrepresentable.
594    ///
595    /// # Examples
596    ///
597    /// ```
598    /// use std::time::Duration;
599    /// use mcpls_core::WriteStallTimeout;
600    ///
601    /// assert!(WriteStallTimeout::new(Duration::ZERO).is_none());
602    /// assert_eq!(WriteStallTimeout::DEFAULT.get(), Duration::from_secs(30));
603    /// ```
604    pub WriteStallTimeout, std::time::Duration::from_secs(30)
605}
606
607non_zero_limit! {
608    /// Non-zero open-connection cap for [`HttpConfig::max_concurrent_connections`],
609    /// clamped to what [`tokio::sync::Semaphore`] supports.
610    ///
611    /// # Examples
612    ///
613    /// ```
614    /// use mcpls_core::ConnectionLimit;
615    ///
616    /// assert!(ConnectionLimit::new(0).is_none());
617    /// assert_eq!(ConnectionLimit::new(8).unwrap().get(), 8);
618    /// assert!(ConnectionLimit::new(usize::MAX).is_some());
619    /// ```
620    ConnectionLimit,
621    512,
622    "512: room for 100 sessions each holding a GET stream, a `subscriptions/listen` stream and one in-flight POST.",
623    tokio::sync::Semaphore::MAX_PERMITS,
624    "the most a [`tokio::sync::Semaphore`] supports"
625}
626
627non_zero_limit! {
628    /// Non-zero cap on concurrent HTTP sessions for
629    /// [`HttpConfig::max_concurrent_sessions`], clamped to what
630    /// [`tokio::sync::Semaphore`] supports.
631    ///
632    /// A zero cap would reject every session, so it is unrepresentable.
633    ///
634    /// # Examples
635    ///
636    /// ```
637    /// use mcpls_core::SessionLimit;
638    ///
639    /// assert!(SessionLimit::new(0).is_none());
640    /// assert_eq!(SessionLimit::new(5).unwrap().get(), 5);
641    /// assert_eq!(SessionLimit::DEFAULT.get(), 100);
642    /// ```
643    SessionLimit,
644    100,
645    "100 concurrent sessions.",
646    tokio::sync::Semaphore::MAX_PERMITS,
647    "the most a [`tokio::sync::Semaphore`] supports"
648}
649
650non_zero_limit! {
651    /// Non-zero cap, in bytes, on a single POST request body for
652    /// [`HttpConfig::max_request_body`], clamped to 64 MiB.
653    ///
654    /// `rmcp` buffers a body in memory while enforcing the cap, so the
655    /// maximum bounds that buffering. A zero cap would reject every POST
656    /// body, so it is unrepresentable.
657    ///
658    /// # Examples
659    ///
660    /// ```
661    /// use mcpls_core::RequestBodyLimit;
662    ///
663    /// assert!(RequestBodyLimit::new(0).is_none());
664    /// assert_eq!(RequestBodyLimit::new(1024).unwrap().get(), 1024);
665    /// assert_eq!(RequestBodyLimit::DEFAULT.get(), 4 * 1024 * 1024);
666    /// assert_eq!(RequestBodyLimit::new(usize::MAX).unwrap().get(), 64 * 1024 * 1024);
667    /// ```
668    RequestBodyLimit,
669    4 * 1024 * 1024,
670    "4 MiB, matching `rmcp`'s own default.",
671    64 * 1024 * 1024,
672    "64 MiB"
673}
674
675#[cfg(test)]
676mod tests {
677    use std::net::SocketAddr;
678
679    use super::*;
680    use crate::transport::test_support::short_lease;
681
682    #[test]
683    fn test_http_config_fields() {
684        let addr: SocketAddr = "127.0.0.1:3000".parse().unwrap();
685        let cfg = HttpConfig::new(addr);
686        assert_eq!(cfg.bind, addr);
687        assert_eq!(cfg.path, crate::HttpPath::default());
688    }
689
690    #[test]
691    fn test_http_config_clone() {
692        let cfg =
693            HttpConfig::new("127.0.0.1:3001".parse().unwrap()).with_path("/test".parse().unwrap());
694        let cloned = cfg.clone();
695        assert_eq!(cloned.bind, cfg.bind);
696        assert_eq!(cloned.path, cfg.path);
697    }
698
699    #[test]
700    fn test_http_config_new_uses_default_limits() {
701        let cfg = HttpConfig::new("127.0.0.1:3003".parse().unwrap());
702        assert_eq!(cfg.max_request_body, crate::RequestBodyLimit::DEFAULT);
703        assert_eq!(cfg.max_concurrent_sessions, crate::SessionLimit::DEFAULT);
704    }
705
706    #[test]
707    fn test_http_config_with_max_request_body_overrides_default() {
708        let cfg = HttpConfig::new("127.0.0.1:3004".parse().unwrap())
709            .with_max_request_body(crate::RequestBodyLimit::new(1024).unwrap());
710        assert_eq!(cfg.max_request_body.get(), 1024);
711        assert_eq!(cfg.max_concurrent_sessions, crate::SessionLimit::DEFAULT);
712    }
713
714    #[test]
715    fn test_http_config_with_max_concurrent_sessions_overrides_default() {
716        let cfg = HttpConfig::new("127.0.0.1:3005".parse().unwrap())
717            .with_max_concurrent_sessions(crate::SessionLimit::new(5).unwrap());
718        assert_eq!(cfg.max_concurrent_sessions.get(), 5);
719        assert_eq!(cfg.max_request_body, crate::RequestBodyLimit::DEFAULT);
720    }
721
722    #[test]
723    fn test_connection_settings_reject_zero_and_clamp_to_the_semaphore_maximum() {
724        assert_eq!(HeaderReadTimeout::new(std::time::Duration::ZERO), None);
725        assert_eq!(ConnectionLimit::new(0), None);
726        assert_eq!(
727            ConnectionLimit::new(usize::MAX).unwrap().get(),
728            tokio::sync::Semaphore::MAX_PERMITS
729        );
730        let semaphore =
731            tokio::sync::Semaphore::new(ConnectionLimit::new(usize::MAX).unwrap().get());
732        assert_eq!(
733            semaphore.available_permits(),
734            tokio::sync::Semaphore::MAX_PERMITS
735        );
736    }
737
738    #[test]
739    fn test_probe_settings_reject_zero() {
740        assert_eq!(ProbeInterval::new(std::time::Duration::ZERO), None);
741        assert_eq!(ProbeDeadline::new(std::time::Duration::ZERO), None);
742        assert!(ProbeInterval::new(std::time::Duration::from_nanos(1)).is_some());
743        assert!(ProbeDeadline::new(std::time::Duration::from_nanos(1)).is_some());
744    }
745
746    #[test]
747    fn test_http_config_stream_liveness_defaults_to_probe_and_is_overridable() {
748        let cfg = HttpConfig::new("127.0.0.1:3003".parse().unwrap());
749        assert_eq!(cfg.stream_liveness, StreamLiveness::DEFAULT);
750        let cfg = cfg.with_stream_liveness(StreamLiveness::Disabled);
751        assert_eq!(cfg.stream_liveness, StreamLiveness::Disabled);
752    }
753
754    #[test]
755    fn test_listen_lease_follows_stream_liveness_unless_overridden() {
756        use crate::transport::{LeaseWindow, ListenLease};
757
758        let cfg = HttpConfig::new("127.0.0.1:3000".parse().unwrap());
759        assert_eq!(
760            cfg.effective_listen_lease(),
761            ListenLease::Renew(LeaseWindow::DEFAULT)
762        );
763        let off = cfg.with_stream_liveness(StreamLiveness::Disabled);
764        assert_eq!(off.effective_listen_lease(), ListenLease::Unbounded);
765        let forced = off.with_listen_lease(short_lease(5));
766        assert_eq!(forced.effective_listen_lease(), short_lease(5));
767    }
768}