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}