protocol_droid/adapters/primary/transport/http/config.rs
1//! Everything a Streamable HTTP server is told to do, in one typed value.
2//!
3//! # Why one value rather than constructor arguments
4//!
5//! Because the router, the session store and the SSE responder all need these
6//! numbers, and three structs assembled separately is three ways for them to
7//! disagree. The specific disagreement that matters: a replay capacity in the
8//! store that is smaller than the window a client was promised would turn a
9//! resumable stream into a silently truncated one, and no single component
10//! would be able to see it. One `ServerConfig`, cloned into each, makes the
11//! mismatch unrepresentable.
12//!
13//! # Why these are newtypes and not `String`/`usize` fields
14//!
15//! The project's rule is that a raw string never crosses an adapter boundary
16//! once it has been parsed, and that holds here in both directions. An
17//! [`Endpoint`] is validated when it is parsed rather than when it is mounted,
18//! so an empty path or one without a leading `/` is a `Result` at the call
19//! site instead of a panic — or a silent 404 — inside axum. [`MaxFrameSize`]
20//! is the domain's own ceiling, reused rather than restated: the stdio
21//! transport checks a frame against it and so does this one, and two limits
22//! spelled separately is one of them eventually wrong.
23
24use core::num::{NonZeroU32, NonZeroUsize};
25use core::str::FromStr;
26use core::time::Duration;
27use std::sync::Arc;
28
29use thiserror::Error;
30
31use crate::domain::MaxFrameSize;
32use crate::domain::RateLimit;
33use crate::domain::mcp::ResourceMetadata;
34use crate::domain::registry::ScopeSet;
35
36/// The path a client POSTs, GETs and DELETEs, without a scheme or host.
37///
38/// The specification calls this the "MCP endpoint" and gives `https://
39/// example.com/mcp` as its example. Only the path is kept, because the adapter
40/// is handed a `Router` and mounting is the caller's decision — a server that
41/// also serves a web UI mounts the same router under a different base and the
42/// endpoint is the part the MCP specification actually constrains.
43#[derive(Debug, Clone, PartialEq, Eq, Hash)]
44pub struct Endpoint(Arc<str>);
45
46impl Endpoint {
47 /// The default the specification's own example uses.
48 pub const DEFAULT: &'static str = "/mcp";
49
50 /// The path, for `axum::Router::route`.
51 #[must_use]
52 pub fn as_str(&self) -> &str {
53 &self.0
54 }
55}
56
57impl core::fmt::Display for Endpoint {
58 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
59 f.write_str(&self.0)
60 }
61}
62
63impl FromStr for Endpoint {
64 type Err = EndpointError;
65
66 /// # Errors
67 ///
68 /// [`EndpointError::Empty`] for a blank path,
69 /// [`EndpointError::NoLeadingSlash`] because axum matches paths that begin
70 /// with one and would otherwise register a route that can never fire,
71 /// [`EndpointError::QueryOrFragment`] because a `?` or `#` never reaches a
72 /// server — the client strips both before writing the request line — so an
73 /// endpoint containing one is a route that can never fire *and* a
74 /// configuration mistake, and
75 /// [`EndpointError::NotVisibleAscii`] because a path is spliced into a
76 /// request line.
77 ///
78 /// Surrounding whitespace is trimmed rather than refused, on the same
79 /// grounds as `McpSessionId::validate`: a path copied out of a config file
80 /// or a URL bar routinely arrives with a trailing newline, and treating
81 /// that as a hard error is a footgun with no security value.
82 fn from_str(value: &str) -> Result<Self, Self::Err> {
83 let trimmed = value.trim();
84 if trimmed.is_empty() {
85 return Err(EndpointError::Empty);
86 }
87 if !trimmed.starts_with('/') {
88 return Err(EndpointError::NoLeadingSlash);
89 }
90 if trimmed.contains(['?', '#']) {
91 return Err(EndpointError::QueryOrFragment);
92 }
93 if !trimmed
94 .bytes()
95 .all(|byte| byte.is_ascii_graphic() || byte == b'/')
96 {
97 return Err(EndpointError::NotVisibleAscii);
98 }
99 Ok(Self(Arc::from(trimmed)))
100 }
101}
102
103/// Why a path is not a usable endpoint.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
105pub enum EndpointError {
106 /// The path was blank.
107 #[error("the MCP endpoint path is empty")]
108 Empty,
109 /// The path did not start with `/`.
110 ///
111 /// Unit rather than carrying the offending path as a `&'static str`: the
112 /// path came in as a borrowed `&str` with an arbitrary lifetime, so the
113 /// only ways to carry it were to copy it into an owned allocation (for an
114 /// error variant that fires once, at startup, on a value the caller still
115 /// holds) or to name a lifetime on the error type. Neither is worth it —
116 /// [`Endpoint`] is `Copy`-cheap to re-parse if the message is not specific
117 /// enough, and the caller has the input.
118 #[error("the MCP endpoint path must start with '/'")]
119 NoLeadingSlash,
120 /// The path carried a `?` or a `#`.
121 ///
122 /// Neither survives the trip from a URL to a server: the client splits the
123 /// fragment off before the request and the query into a separate
124 /// component, so an endpoint containing either names a path that can never
125 /// be requested. Accepting it would register a route that silently never
126 /// fires, which is the same failure as a typo and is harder to spot.
127 #[error(
128 "the MCP endpoint path must be a path only: a query string or fragment never reaches the server"
129 )]
130 QueryOrFragment,
131 /// The path held a byte that cannot appear in a request line.
132 #[error("the MCP endpoint path contains a character that is not valid in a request line")]
133 NotVisibleAscii,
134}
135
136/// Whether a POST that warrants a reply is answered inline or over SSE.
137///
138/// The specification makes this the server's choice — a request "MUST either
139/// return `Content-Type: text/event-stream` ... or `Content-Type:
140/// application/json`" — and the two have genuinely different costs, which is
141/// why it is configuration rather than a heuristic.
142///
143/// `Json` is the default because it is the cheaper of the two for the common
144/// case: one request, one response, one round trip, and no connection held open
145/// afterwards. `EventStream` costs a long-lived response for every request
146/// whether or not the client ever needs a second message, and buys
147/// server-initiated notifications on the same connection and a resumable
148/// cursor. A server that wants those wants them for *every* request, because a
149/// client cannot know in advance which request will turn out to need a
150/// progress notification.///
151/// # The router does not enforce this, and cannot
152///
153/// This is the one piece of [`ServerConfig`] the router does not read, and the
154/// reason is structural rather than an oversight.
155/// [`RequestHandler::dispatch`](super::RequestHandler::dispatch) returns a
156/// fully framed [`axum::response::Response`], so by the time a `POST` reaches
157/// the protocol layer its `Content-Type` is already decided. The router has
158/// nothing left to apply the mode to.
159///
160/// The consequence is that a handler can frame its answer against the mode or
161/// ignore it, and nothing here will notice — the same shape as T11's
162/// `auth.rs` being reachable from no real path. The transport layer has no
163/// remedy: making `dispatch` return a body and letting the router frame it
164/// would move a transport concern into the protocol layer's signature, and the
165/// protocol layer is the thing that has to stay protocol-neutral.
166///
167/// So it is recorded here and pinned by
168/// `the_streaming_mode_never_changes_the_get_route` rather than enforced. T18
169/// is where the framing is decided, and that is the code that has to honour
170/// this value.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
172pub enum Streaming {
173 /// Answer with one `application/json` document. The default.
174 #[default]
175 Json,
176 /// Answer with a `text/event-stream` carrying the same document.
177 EventStream,
178}
179
180/// The three response headers the project's security rules require on every
181/// response.
182///
183/// Held as a value rather than written at each call site because the rule is
184/// "every response", and the way that rule gets broken is a fourth route added
185/// later that sets its own headers and forgets these. One value, attached by
186/// one router-level layer, has no per-route path to miss.
187///
188/// The defaults are the strictest spelling of each header rather than a
189/// permissive one. `default-src 'none'` denies the response every fetch
190/// directive it does not explicitly re-grant, and an MCP endpoint serves no
191/// scripts, styles, frames or connections at all, so there is nothing to
192/// re-grant. `frame-ancestors 'none'` is the modern form and is why `X-Frame-
193/// Options` is still sent alongside it: the older header is what a browser
194/// predating CSP consults.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct SecurityHeaders {
197 content_security_policy: Arc<str>,
198 strict_transport_security: Arc<str>,
199 x_frame_options: Arc<str>,
200}
201
202impl Default for SecurityHeaders {
203 fn default() -> Self {
204 Self {
205 content_security_policy: Arc::from(
206 "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
207 ),
208 strict_transport_security: Arc::from("max-age=31536000; includeSubDomains"),
209 x_frame_options: Arc::from("DENY"),
210 }
211 }
212}
213
214impl SecurityHeaders {
215 /// Replaces the `Content-Security-Policy` value.
216 #[must_use]
217 pub fn with_content_security_policy(mut self, value: impl Into<Arc<str>>) -> Self {
218 self.content_security_policy = value.into();
219 self
220 }
221
222 /// Replaces the `Strict-Transport-Security` value.
223 #[must_use]
224 pub fn with_strict_transport_security(mut self, value: impl Into<Arc<str>>) -> Self {
225 self.strict_transport_security = value.into();
226 self
227 }
228
229 /// Replaces the `X-Frame-Options` value.
230 #[must_use]
231 pub fn with_x_frame_options(mut self, value: impl Into<Arc<str>>) -> Self {
232 self.x_frame_options = value.into();
233 self
234 }
235
236 /// The `Content-Security-Policy` value.
237 #[must_use]
238 pub fn content_security_policy(&self) -> &str {
239 &self.content_security_policy
240 }
241
242 /// The `Strict-Transport-Security` value.
243 #[must_use]
244 pub fn strict_transport_security(&self) -> &str {
245 &self.strict_transport_security
246 }
247
248 /// The `X-Frame-Options` value.
249 #[must_use]
250 pub fn x_frame_options(&self) -> &str {
251 &self.x_frame_options
252 }
253}
254
255/// How long a server-initiated SSE stream stays open before the server closes
256/// it.
257///
258/// Not a nicety. The specification says the server "MAY close the SSE stream at
259/// any time", and a server that never does holds one connection, one task and
260/// one ring borrow per idle client for as long as the client is patient. The
261/// default is long enough that a well-behaved client is never disconnected
262/// mid-exchange and short enough that an abandoned connection is reclaimed.
263///
264/// A client that is dropped early reconnects with `Last-Event-ID` and loses
265/// nothing, because the events are in the ring rather than in the connection.
266/// That is the property that makes the timeout safe, and it is why the ring
267/// exists separately from the stream.
268const DEFAULT_STREAM_TIMEOUT: Duration = Duration::from_secs(30);
269
270/// T17. Requests in flight at once before the connection is shed.
271///
272/// Not a queue length: past this the caller gets `503`. Sized for a
273/// single-process server whose handlers are mostly I/O against one
274/// authorization server — well above what a single client can generate, and
275/// well below what exhausts the connection pool.
276const DEFAULT_MAX_CONCURRENCY: NonZeroU32 = match NonZeroU32::new(256) {
277 Some(value) => value,
278 None => unreachable!(),
279};
280
281/// T17. The whole-request deadline.
282///
283/// Thirty seconds is far longer than a healthy tool call and far shorter than
284/// the point at which a caller has given up, so a hung handler is dropped
285/// while the client is still listening.
286const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
287
288/// T17. Requests per subject per minute.
289///
290/// Applied per `Mcp-Session-Id`, or shared across anonymous callers. The
291/// window is a minute because that is the granularity an operator reasons
292/// about when asked "who is hammering this"; the fixed window's worst case is
293/// documented on [`RateLimit`].
294const DEFAULT_RATE_LIMIT: RateLimit = RateLimit::new(
295 match NonZeroU32::new(600) {
296 Some(value) => value,
297 None => unreachable!(),
298 },
299 DEFAULT_RATE_WINDOW,
300);
301
302/// The width of [`DEFAULT_RATE_LIMIT`].
303const DEFAULT_RATE_WINDOW: Duration = Duration::from_secs(60);
304
305/// How long a stream waits before emitting an SSE comment when it has nothing
306/// to say.
307///
308/// A comment line (`:`) is a keep-alive that the SSE standard defines and every
309/// client ignores. Without one, a proxy between here and the client is free to
310/// decide an idle connection is dead — commonly at 60s — and would close it
311/// while the server still believed the stream was healthy.
312const DEFAULT_KEEP_ALIVE: Duration = Duration::from_secs(15);
313
314/// The settings one Streamable HTTP server is built from.
315#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct ServerConfig {
317 endpoint: Endpoint,
318 max_body: MaxFrameSize,
319 replay_capacity: NonZeroUsize,
320 streaming: Streaming,
321 stream_timeout: Duration,
322 keep_alive: Duration,
323 security: SecurityHeaders,
324 required_scopes: ScopeSet,
325 resource_metadata: Option<Arc<ResourceMetadata>>,
326 max_concurrency: NonZeroU32,
327 request_timeout: Duration,
328 rate_limit: RateLimit,
329}
330
331impl ServerConfig {
332 /// A configuration with every default: the specification's endpoint, the
333 /// domain's default frame ceiling, a replay window of
334 /// [`RECOMMENDED_CAPACITY`](crate::domain::RECOMMENDED_CAPACITY), inline
335 /// JSON answers, and the strict security headers.
336 ///
337 /// # Errors
338 ///
339 /// [`EndpointError`], because the default endpoint is parsed rather than
340 /// trusted — a `const` that could be wrong is a bug the caller finds out
341 /// about at construction instead of at mount time.
342 pub fn new() -> Result<Self, EndpointError> {
343 Ok(Self {
344 endpoint: Endpoint::DEFAULT.parse()?,
345 max_body: MaxFrameSize::DEFAULT,
346 replay_capacity: crate::domain::RECOMMENDED_CAPACITY.into(),
347 streaming: Streaming::default(),
348 stream_timeout: DEFAULT_STREAM_TIMEOUT,
349 keep_alive: DEFAULT_KEEP_ALIVE,
350 security: SecurityHeaders::default(),
351 required_scopes: ScopeSet::empty(),
352 resource_metadata: None,
353 max_concurrency: DEFAULT_MAX_CONCURRENCY,
354 request_timeout: DEFAULT_REQUEST_TIMEOUT,
355 rate_limit: DEFAULT_RATE_LIMIT,
356 })
357 }
358
359 /// How many requests may be in flight at once.
360 ///
361 /// A ceiling rather than a queue: past this the connection is shed with
362 /// `503`, because a request that waits for a slot has already spent the
363 /// caller's timeout doing nothing. OWASP API Security Top 10:2023,
364 /// API4:2023 (Unrestricted Resource Consumption).
365 #[must_use]
366 pub const fn max_concurrency(&self) -> NonZeroU32 {
367 self.max_concurrency
368 }
369
370 /// Sets the in-flight ceiling. A `limit` of zero is rejected by
371 /// [`NonZeroU32`], because a server that accepts nothing is a server that
372 /// is up and useless.
373 #[must_use]
374 pub const fn with_max_concurrency(mut self, limit: NonZeroU32) -> Self {
375 self.max_concurrency = limit;
376 self
377 }
378
379 /// How long one request may take end to end.
380 ///
381 /// Distinct from [`stream_timeout`](Self::stream_timeout), which bounds
382 /// the idle gap *between* events on an already-open stream. This one
383 /// bounds the whole request, so a handler that never answers is dropped
384 /// rather than holding a connection open forever.
385 #[must_use]
386 pub const fn request_timeout(&self) -> Duration {
387 self.request_timeout
388 }
389
390 /// Sets the per-request deadline.
391 #[must_use]
392 pub const fn with_request_timeout(mut self, timeout: Duration) -> Self {
393 self.request_timeout = timeout;
394 self
395 }
396
397 /// The per-subject request allowance.
398 #[must_use]
399 pub const fn rate_limit(&self) -> &RateLimit {
400 &self.rate_limit
401 }
402
403 /// Sets the per-subject allowance.
404 #[must_use]
405 pub const fn with_rate_limit(mut self, limit: RateLimit) -> Self {
406 self.rate_limit = limit;
407 self
408 }
409
410 /// The MCP endpoint path.
411 #[must_use]
412 pub const fn endpoint(&self) -> &Endpoint {
413 &self.endpoint
414 }
415
416 /// The largest POST body accepted, in bytes.
417 ///
418 /// Enforced before the body is read into memory where the framework allows
419 /// it, and again on the assembled buffer, because the two limits protect
420 /// different things: the first the process, the second the framing layer.
421 #[must_use]
422 pub const fn max_body(&self) -> MaxFrameSize {
423 self.max_body
424 }
425
426 /// How many events a session retains for replay.
427 #[must_use]
428 pub const fn replay_capacity(&self) -> NonZeroUsize {
429 self.replay_capacity
430 }
431
432 /// Whether a POST is answered inline or over SSE.
433 #[must_use]
434 pub const fn streaming(&self) -> Streaming {
435 self.streaming
436 }
437
438 /// Sets the streaming policy.
439 #[must_use]
440 pub const fn with_streaming(mut self, streaming: Streaming) -> Self {
441 self.streaming = streaming;
442 self
443 }
444
445 /// How long a server-initiated stream stays open.
446 #[must_use]
447 pub const fn stream_timeout(&self) -> Duration {
448 self.stream_timeout
449 }
450
451 /// Sets the stream timeout. A zero timeout closes the stream as soon as
452 /// its replay window is drained, which is a legitimate way to run a
453 /// "deliver the backlog, then hang up" endpoint.
454 #[must_use]
455 pub const fn with_stream_timeout(mut self, timeout: Duration) -> Self {
456 self.stream_timeout = timeout;
457 self
458 }
459
460 /// How long a stream waits before emitting a keep-alive comment.
461 #[must_use]
462 pub const fn keep_alive(&self) -> Duration {
463 self.keep_alive
464 }
465
466 /// Sets the keep-alive interval.
467 #[must_use]
468 pub const fn with_keep_alive(mut self, keep_alive: Duration) -> Self {
469 self.keep_alive = keep_alive;
470 self
471 }
472
473 /// The three security headers.
474 #[must_use]
475 pub const fn security(&self) -> &SecurityHeaders {
476 &self.security
477 }
478
479 /// Replaces the security headers.
480 #[must_use]
481 pub fn with_security(mut self, security: SecurityHeaders) -> Self {
482 self.security = security;
483 self
484 }
485
486 /// The scopes a stateful request must hold.
487 ///
488 /// Empty by default, and empty is *not* the same as unauthenticated. An
489 /// empty requirement is a statement that this endpoint has nothing to
490 /// protect; a caller that disagrees names the scope it needs and every
491 /// request that arrives without it is refused. The alternative — a
492 /// default that demands a scope — would make the transport refuse every
493 /// request from every deployment that has not yet wired T16's OAuth, which
494 /// is a worse failure than serving a read-only endpoint to the public.
495 ///
496 /// What the empty default *does* guarantee is fail-secure structure rather
497 /// than fail-secure policy: an anonymous caller is
498 /// [`AuthContext::anonymous`](crate::domain::registry::AuthContext::anonymous),
499 /// which holds no scopes, so the moment a scope is named the anonymous
500 /// caller is refused. There is no path by which an unauthenticated request
501 /// reaches a scope-bearing route.
502 #[must_use]
503 pub const fn required_scopes(&self) -> ScopeSet {
504 self.required_scopes
505 }
506
507 /// Names the scopes a stateful request must hold.
508 #[must_use]
509 pub const fn with_required_scopes(mut self, scopes: ScopeSet) -> Self {
510 self.required_scopes = scopes;
511 self
512 }
513
514 /// The RFC 9728 metadata this server publishes, if it publishes one.
515 ///
516 /// `None` by default, because a server that names no authorization servers
517 /// has nothing to publish and an empty document is not the same as no
518 /// document.
519 ///
520 /// Setting this does both halves of RFC 9728 at once: the router serves the
521 /// document at [`ResourceUri::metadata_path`](crate::domain::mcp::ResourceUri::metadata_path),
522 /// and a `401` carries the [`WWW-Authenticate`](Self::challenge) challenge
523 /// naming that same URL. One value, so the two cannot name different
524 /// places.
525 #[must_use]
526 pub fn resource_metadata(&self) -> Option<&ResourceMetadata> {
527 self.resource_metadata.as_deref()
528 }
529
530 /// Publishes `metadata`, enabling both the `WWW-Authenticate` challenge and
531 /// the well-known route that serves it.
532 ///
533 /// [`McpRouter::into_router`](super::router::McpRouter::into_router)
534 /// mounts the document at the path its resource identifier implies. A
535 /// consumer does not merge anything.
536 ///
537 /// # Examples
538 ///
539 /// ```
540 /// use protocol_droid::adapters::primary::transport::http::ServerConfig;
541 /// use protocol_droid::domain::mcp::{ResourceMetadata, ResourceUri};
542 ///
543 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
544 /// let metadata = ResourceMetadata::new(ResourceUri::new("https://example.com/mcp")?)
545 /// .with_authorization_server("https://login.example.com");
546 ///
547 /// // The route is mounted at /.well-known/oauth-protected-resource/mcp.
548 /// let config = ServerConfig::new()?.with_resource_metadata(metadata);
549 /// assert!(config.challenge().is_some());
550 /// # let _ = config;
551 /// # Ok(())
552 /// # }
553 /// ```
554 #[must_use]
555 pub fn with_resource_metadata(mut self, metadata: ResourceMetadata) -> Self {
556 self.resource_metadata = Some(Arc::new(metadata));
557 self
558 }
559
560 /// The `WWW-Authenticate` challenge for a `401`, if this server publishes
561 /// metadata.
562 ///
563 /// RFC 9728 §5.1: a `401` on a protected resource carries a
564 /// `WWW-Authenticate` naming where the client can discover the
565 /// authorization server, and that challenge is the only pointer a client
566 /// gets. Without it a client holding an expired token has no URL to fetch
567 /// authorization-server metadata from, which is the situation the challenge
568 /// exists to solve.
569 ///
570 /// Returns `None` rather than a bare `Bearer` when nothing is published:
571 /// a challenge naming no resource metadata tells the client the scheme and
572 /// nothing else, and a deployment that has not configured a document is
573 /// better served by an unadorned `401` that it can recognise as
574 /// misconfigured.
575 pub fn challenge(&self) -> Option<String> {
576 self.resource_metadata.as_ref()?.challenge().ok()
577 }
578}
579
580#[cfg(test)]
581mod tests {
582 use core::time::Duration;
583
584 use super::{Endpoint, EndpointError, SecurityHeaders, ServerConfig, Streaming};
585
586 type TestError = Box<dyn std::error::Error>;
587
588 /// The default config parses, and its endpoint is the specification's own
589 /// example rather than a bare `/`.
590 #[test]
591 fn the_default_endpoint_is_the_specification_example() -> Result<(), TestError> {
592 let config = ServerConfig::new()?;
593 assert_eq!(config.endpoint().as_str(), "/mcp");
594 Ok(())
595 }
596
597 /// An endpoint is validated where it is parsed, not where it is mounted.
598 ///
599 /// Each of these would otherwise become a route that silently never fires
600 /// — the failure mode of a typo in a configuration file, which is exactly
601 /// the input this parse exists to catch.
602 #[test]
603 fn a_malformed_endpoint_is_refused_at_parse_time() -> Result<(), TestError> {
604 for case in ["", " ", "mcp", "mcp/stream", "/mcp stream", "/mcp\0mcp"] {
605 assert!(
606 case.parse::<Endpoint>().is_err(),
607 "accepted {case:?} as an endpoint"
608 );
609 }
610 // A query string and a fragment are the subtle pair, and the reason
611 // `QueryOrFragment` is a variant of its own rather than falling into
612 // the control-character arm: both bytes are perfectly printable ASCII,
613 // so a naive "is it a legal request line character" check accepts them.
614 // Neither survives the trip to a server, though — the client splits
615 // the fragment off and the query into a separate component — so
616 // `/mcp?debug=1` would register a route that can never be requested.
617 assert_eq!(
618 "/mcp?debug=1".parse::<Endpoint>(),
619 Err(EndpointError::QueryOrFragment)
620 );
621 assert_eq!(
622 "/mcp#frag".parse::<Endpoint>(),
623 Err(EndpointError::QueryOrFragment)
624 );
625 assert_eq!(
626 "mcp".parse::<Endpoint>(),
627 Err(EndpointError::NoLeadingSlash)
628 );
629 assert_eq!("/a/b".parse::<Endpoint>()?.as_str(), "/a/b");
630 // Surrounding whitespace is transport framing a caller may have copied
631 // in, not an error; the same leniency `McpSessionId::validate` applies.
632 // A trailing newline is whitespace like any other, which is why it is
633 // asserted to be *accepted* here rather than refused: a config file
634 // whose last line ends in a newline must not fail to start.
635 assert_eq!(" /mcp ".parse::<Endpoint>()?.as_str(), "/mcp");
636 assert_eq!("/mcp\n".parse::<Endpoint>()?.as_str(), "/mcp");
637 Ok(())
638 }
639
640 /// A well-formed endpoint survives the round trip, byte for byte.
641 ///
642 /// The parse trims and the parse is a lossy-looking operation, so the
643 /// property worth pinning is that a path with no framing around it comes
644 /// out exactly as it went in — a mount that silently dropped or doubled a
645 /// segment would be a 404 nobody could explain.
646 #[test]
647 fn a_well_formed_endpoint_round_trips() -> Result<(), TestError> {
648 for case in ["/mcp", "/", "/a/b", "/v1/mcp", "/mcp_v2", "/a-b_c.d~e"] {
649 assert_eq!(case.parse::<Endpoint>()?.as_str(), case);
650 // `Display` and `as_str` must agree, because one is what a mount
651 // uses and the other is what an operator sees in a log.
652 assert_eq!(case.parse::<Endpoint>()?.to_string(), case);
653 }
654 Ok(())
655 }
656
657 /// Every security header has a specific, enforced value by default.
658 ///
659 /// A header that is present but empty is worse than absent: a browser
660 /// reads `X-Frame-Options: ` as no policy at all, and an empty CSP as no
661 /// policy at all. So this asserts the *values*, not merely that the
662 /// accessors return something — "the field exists" is exactly the test
663 /// that passes against a default of `""`.
664 #[test]
665 fn the_security_headers_default_to_something_enforced() {
666 let headers = SecurityHeaders::default();
667
668 let csp = headers.content_security_policy();
669 assert!(!csp.trim().is_empty(), "an empty CSP is no policy at all");
670 // The endpoint serves no scripts, styles, frames or connections, so
671 // `default-src 'none'` denies everything without re-granting anything.
672 assert!(csp.contains("default-src 'none'"), "csp was {csp:?}");
673 assert!(csp.contains("frame-ancestors 'none'"), "csp was {csp:?}");
674
675 // HSTS is worthless without a max-age, and the includeSubDomains flag
676 // is what stops a sibling host serving the same name over plain HTTP.
677 let hsts = headers.strict_transport_security();
678 assert!(hsts.contains("max-age="), "hsts was {hsts:?}");
679 assert!(hsts.contains("includeSubDomains"), "hsts was {hsts:?}");
680
681 // `DENY` is the strictest legal value. `SAMEORIGIN` would be a
682 // weakening of the default and must have to be asked for.
683 assert_eq!(headers.x_frame_options(), "DENY");
684 }
685
686 /// Each builder override reaches the value the layer will write, and only
687 /// the one it names.
688 ///
689 /// Written as one test per header rather than a loop, because the failure
690 /// this guards against is a setter that stores into the wrong field — and
691 /// a loop reports that as "one of three failed", which is not a message
692 /// anyone can act on at three in the morning.
693 #[test]
694 fn each_security_header_builder_takes_effect() {
695 let csp = SecurityHeaders::default().with_content_security_policy("default-src 'self'");
696 assert_eq!(csp.content_security_policy(), "default-src 'self'");
697 // ...and the other two are untouched by the setter that did not name
698 // them, which is the half of "takes effect" that is easy to get wrong.
699 assert_eq!(csp.x_frame_options(), "DENY");
700 assert!(csp.strict_transport_security().contains("max-age="));
701
702 let hsts = SecurityHeaders::default().with_strict_transport_security("max-age=60");
703 assert_eq!(hsts.strict_transport_security(), "max-age=60");
704 assert_eq!(hsts.x_frame_options(), "DENY");
705
706 let frame = SecurityHeaders::default().with_x_frame_options("SAMEORIGIN");
707 assert_eq!(frame.x_frame_options(), "SAMEORIGIN");
708 assert!(
709 frame
710 .content_security_policy()
711 .contains("default-src 'none'")
712 );
713
714 // A builder must not leak back into the value it was copied from.
715 let base = SecurityHeaders::default();
716 let _widened = base.clone().with_x_frame_options("SAMEORIGIN");
717 assert_eq!(base.x_frame_options(), "DENY");
718 }
719
720 /// The two setters reach the values the layer will write.
721 #[test]
722 fn the_config_carries_each_setting_it_accepts() -> Result<(), TestError> {
723 let config = ServerConfig::new()?
724 .with_streaming(Streaming::EventStream)
725 .with_stream_timeout(Duration::from_secs(1))
726 .with_keep_alive(Duration::from_millis(250))
727 .with_security(SecurityHeaders::default().with_x_frame_options("SAMEORIGIN"));
728 assert_eq!(config.streaming(), Streaming::EventStream);
729 assert_eq!(config.stream_timeout(), Duration::from_secs(1));
730 assert_eq!(config.keep_alive(), Duration::from_millis(250));
731 assert_eq!(config.security().x_frame_options(), "SAMEORIGIN");
732 // A builder must not leak into the value it was copied from: the
733 // defaults are still the defaults on a freshly built config.
734 assert_eq!(ServerConfig::new()?.streaming(), Streaming::Json);
735 Ok(())
736 }
737
738 /// The replay window is a real capacity, and never zero.
739 ///
740 /// A zero-capacity ring accepts a push and drops it immediately, which is
741 /// a livelock dressed as a buffer — so the field is a `NonZeroUsize` and
742 /// this test is here to say that is deliberate rather than incidental.
743 #[test]
744 fn the_replay_capacity_cannot_be_zero() -> Result<(), TestError> {
745 assert!(ServerConfig::new()?.replay_capacity().get() > 0);
746 Ok(())
747 }
748}