Skip to main content

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}