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    /// Whether a POST is answered inline or over SSE.
439    ///
440    /// Inline unless this method says otherwise.
441    #[must_use]
442    pub const fn with_streaming(mut self, streaming: Streaming) -> Self {
443        self.streaming = streaming;
444        self
445    }
446
447    /// How long a server-initiated stream stays open.
448    #[must_use]
449    pub const fn stream_timeout(&self) -> Duration {
450        self.stream_timeout
451    }
452
453    /// Sets the stream timeout. A zero timeout closes the stream as soon as
454    /// its replay window is drained, which is a legitimate way to run a
455    /// "deliver the backlog, then hang up" endpoint.
456    #[must_use]
457    pub const fn with_stream_timeout(mut self, timeout: Duration) -> Self {
458        self.stream_timeout = timeout;
459        self
460    }
461
462    /// How long a stream waits before emitting a keep-alive comment.
463    #[must_use]
464    pub const fn keep_alive(&self) -> Duration {
465        self.keep_alive
466    }
467
468    /// Sets the keep-alive interval.
469    #[must_use]
470    pub const fn with_keep_alive(mut self, keep_alive: Duration) -> Self {
471        self.keep_alive = keep_alive;
472        self
473    }
474
475    /// The three security headers.
476    #[must_use]
477    pub const fn security(&self) -> &SecurityHeaders {
478        &self.security
479    }
480
481    /// Replaces the security headers.
482    #[must_use]
483    pub fn with_security(mut self, security: SecurityHeaders) -> Self {
484        self.security = security;
485        self
486    }
487
488    /// The scopes a stateful request must hold.
489    ///
490    /// Empty by default, and empty is *not* the same as unauthenticated. An
491    /// empty requirement is a statement that this endpoint has nothing to
492    /// protect; a caller that disagrees names the scope it needs and every
493    /// request that arrives without it is refused. The alternative — a
494    /// default that demands a scope — would make the transport refuse every
495    /// request from every deployment that has not yet wired T16's OAuth, which
496    /// is a worse failure than serving a read-only endpoint to the public.
497    ///
498    /// What the empty default *does* guarantee is fail-secure structure rather
499    /// than fail-secure policy: an anonymous caller is
500    /// [`AuthContext::anonymous`](crate::domain::registry::AuthContext::anonymous),
501    /// which holds no scopes, so the moment a scope is named the anonymous
502    /// caller is refused. There is no path by which an unauthenticated request
503    /// reaches a scope-bearing route.
504    #[must_use]
505    pub const fn required_scopes(&self) -> ScopeSet {
506        self.required_scopes
507    }
508
509    /// Names the scopes a stateful request must hold.
510    #[must_use]
511    pub const fn with_required_scopes(mut self, scopes: ScopeSet) -> Self {
512        self.required_scopes = scopes;
513        self
514    }
515
516    /// The RFC 9728 metadata this server publishes, if it publishes one.
517    ///
518    /// `None` by default, because a server that names no authorization servers
519    /// has nothing to publish and an empty document is not the same as no
520    /// document.
521    ///
522    /// Setting this does both halves of RFC 9728 at once: the router serves the
523    /// document at [`ResourceUri::metadata_path`](crate::domain::mcp::ResourceUri::metadata_path),
524    /// and a `401` carries the [`WWW-Authenticate`](Self::challenge) challenge
525    /// naming that same URL. One value, so the two cannot name different
526    /// places.
527    #[must_use]
528    pub fn resource_metadata(&self) -> Option<&ResourceMetadata> {
529        self.resource_metadata.as_deref()
530    }
531
532    /// Publishes `metadata`, enabling both the `WWW-Authenticate` challenge and
533    /// the well-known route that serves it.
534    ///
535    /// [`McpRouter::into_router`](super::router::McpRouter::into_router)
536    /// mounts the document at the path its resource identifier implies. A
537    /// consumer does not merge anything.
538    ///
539    /// # Examples
540    ///
541    /// ```
542    /// use protocol_droid::adapters::primary::transport::http::ServerConfig;
543    /// use protocol_droid::domain::mcp::{ResourceMetadata, ResourceUri};
544    ///
545    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
546    /// let metadata = ResourceMetadata::new(ResourceUri::new("https://example.com/mcp")?)
547    ///     .with_authorization_server("https://login.example.com");
548    ///
549    /// // The route is mounted at /.well-known/oauth-protected-resource/mcp.
550    /// let config = ServerConfig::new()?.with_resource_metadata(metadata);
551    /// assert!(config.challenge().is_some());
552    /// # let _ = config;
553    /// # Ok(())
554    /// # }
555    /// ```
556    #[must_use]
557    pub fn with_resource_metadata(mut self, metadata: ResourceMetadata) -> Self {
558        self.resource_metadata = Some(Arc::new(metadata));
559        self
560    }
561
562    /// The `WWW-Authenticate` challenge for a `401`, if this server publishes
563    /// metadata.
564    ///
565    /// RFC 9728 §5.1: a `401` on a protected resource carries a
566    /// `WWW-Authenticate` naming where the client can discover the
567    /// authorization server, and that challenge is the only pointer a client
568    /// gets. Without it a client holding an expired token has no URL to fetch
569    /// authorization-server metadata from, which is the situation the challenge
570    /// exists to solve.
571    ///
572    /// Returns `None` rather than a bare `Bearer` when nothing is published:
573    /// a challenge naming no resource metadata tells the client the scheme and
574    /// nothing else, and a deployment that has not configured a document is
575    /// better served by an unadorned `401` that it can recognise as
576    /// misconfigured.
577    pub fn challenge(&self) -> Option<String> {
578        self.resource_metadata.as_ref()?.challenge().ok()
579    }
580}
581
582#[cfg(test)]
583mod tests {
584    use core::time::Duration;
585
586    use super::{Endpoint, EndpointError, SecurityHeaders, ServerConfig, Streaming};
587
588    type TestError = Box<dyn std::error::Error>;
589
590    /// The default config parses, and its endpoint is the specification's own
591    /// example rather than a bare `/`.
592    #[test]
593    fn the_default_endpoint_is_the_specification_example() -> Result<(), TestError> {
594        let config = ServerConfig::new()?;
595        assert_eq!(config.endpoint().as_str(), "/mcp");
596        Ok(())
597    }
598
599    /// An endpoint is validated where it is parsed, not where it is mounted.
600    ///
601    /// Each of these would otherwise become a route that silently never fires
602    /// — the failure mode of a typo in a configuration file, which is exactly
603    /// the input this parse exists to catch.
604    #[test]
605    fn a_malformed_endpoint_is_refused_at_parse_time() -> Result<(), TestError> {
606        for case in ["", "   ", "mcp", "mcp/stream", "/mcp stream", "/mcp\0mcp"] {
607            assert!(
608                case.parse::<Endpoint>().is_err(),
609                "accepted {case:?} as an endpoint"
610            );
611        }
612        // A query string and a fragment are the subtle pair, and the reason
613        // `QueryOrFragment` is a variant of its own rather than falling into
614        // the control-character arm: both bytes are perfectly printable ASCII,
615        // so a naive "is it a legal request line character" check accepts them.
616        // Neither survives the trip to a server, though — the client splits
617        // the fragment off and the query into a separate component — so
618        // `/mcp?debug=1` would register a route that can never be requested.
619        assert_eq!(
620            "/mcp?debug=1".parse::<Endpoint>(),
621            Err(EndpointError::QueryOrFragment)
622        );
623        assert_eq!(
624            "/mcp#frag".parse::<Endpoint>(),
625            Err(EndpointError::QueryOrFragment)
626        );
627        assert_eq!(
628            "mcp".parse::<Endpoint>(),
629            Err(EndpointError::NoLeadingSlash)
630        );
631        assert_eq!("/a/b".parse::<Endpoint>()?.as_str(), "/a/b");
632        // Surrounding whitespace is transport framing a caller may have copied
633        // in, not an error; the same leniency `McpSessionId::validate` applies.
634        // A trailing newline is whitespace like any other, which is why it is
635        // asserted to be *accepted* here rather than refused: a config file
636        // whose last line ends in a newline must not fail to start.
637        assert_eq!("  /mcp  ".parse::<Endpoint>()?.as_str(), "/mcp");
638        assert_eq!("/mcp\n".parse::<Endpoint>()?.as_str(), "/mcp");
639        Ok(())
640    }
641
642    /// A well-formed endpoint survives the round trip, byte for byte.
643    ///
644    /// The parse trims and the parse is a lossy-looking operation, so the
645    /// property worth pinning is that a path with no framing around it comes
646    /// out exactly as it went in — a mount that silently dropped or doubled a
647    /// segment would be a 404 nobody could explain.
648    #[test]
649    fn a_well_formed_endpoint_round_trips() -> Result<(), TestError> {
650        for case in ["/mcp", "/", "/a/b", "/v1/mcp", "/mcp_v2", "/a-b_c.d~e"] {
651            assert_eq!(case.parse::<Endpoint>()?.as_str(), case);
652            // `Display` and `as_str` must agree, because one is what a mount
653            // uses and the other is what an operator sees in a log.
654            assert_eq!(case.parse::<Endpoint>()?.to_string(), case);
655        }
656        Ok(())
657    }
658
659    /// Every security header has a specific, enforced value by default.
660    ///
661    /// A header that is present but empty is worse than absent: a browser
662    /// reads `X-Frame-Options: ` as no policy at all, and an empty CSP as no
663    /// policy at all. So this asserts the *values*, not merely that the
664    /// accessors return something — "the field exists" is exactly the test
665    /// that passes against a default of `""`.
666    #[test]
667    fn the_security_headers_default_to_something_enforced() {
668        let headers = SecurityHeaders::default();
669
670        let csp = headers.content_security_policy();
671        assert!(!csp.trim().is_empty(), "an empty CSP is no policy at all");
672        // The endpoint serves no scripts, styles, frames or connections, so
673        // `default-src 'none'` denies everything without re-granting anything.
674        assert!(csp.contains("default-src 'none'"), "csp was {csp:?}");
675        assert!(csp.contains("frame-ancestors 'none'"), "csp was {csp:?}");
676
677        // HSTS is worthless without a max-age, and the includeSubDomains flag
678        // is what stops a sibling host serving the same name over plain HTTP.
679        let hsts = headers.strict_transport_security();
680        assert!(hsts.contains("max-age="), "hsts was {hsts:?}");
681        assert!(hsts.contains("includeSubDomains"), "hsts was {hsts:?}");
682
683        // `DENY` is the strictest legal value. `SAMEORIGIN` would be a
684        // weakening of the default and must have to be asked for.
685        assert_eq!(headers.x_frame_options(), "DENY");
686    }
687
688    /// Each builder override reaches the value the layer will write, and only
689    /// the one it names.
690    ///
691    /// Written as one test per header rather than a loop, because the failure
692    /// this guards against is a setter that stores into the wrong field — and
693    /// a loop reports that as "one of three failed", which is not a message
694    /// anyone can act on at three in the morning.
695    #[test]
696    fn each_security_header_builder_takes_effect() {
697        let csp = SecurityHeaders::default().with_content_security_policy("default-src 'self'");
698        assert_eq!(csp.content_security_policy(), "default-src 'self'");
699        // ...and the other two are untouched by the setter that did not name
700        // them, which is the half of "takes effect" that is easy to get wrong.
701        assert_eq!(csp.x_frame_options(), "DENY");
702        assert!(csp.strict_transport_security().contains("max-age="));
703
704        let hsts = SecurityHeaders::default().with_strict_transport_security("max-age=60");
705        assert_eq!(hsts.strict_transport_security(), "max-age=60");
706        assert_eq!(hsts.x_frame_options(), "DENY");
707
708        let frame = SecurityHeaders::default().with_x_frame_options("SAMEORIGIN");
709        assert_eq!(frame.x_frame_options(), "SAMEORIGIN");
710        assert!(
711            frame
712                .content_security_policy()
713                .contains("default-src 'none'")
714        );
715
716        // A builder must not leak back into the value it was copied from.
717        let base = SecurityHeaders::default();
718        let _widened = base.clone().with_x_frame_options("SAMEORIGIN");
719        assert_eq!(base.x_frame_options(), "DENY");
720    }
721
722    /// The two setters reach the values the layer will write.
723    #[test]
724    fn the_config_carries_each_setting_it_accepts() -> Result<(), TestError> {
725        let config = ServerConfig::new()?
726            .with_streaming(Streaming::EventStream)
727            .with_stream_timeout(Duration::from_secs(1))
728            .with_keep_alive(Duration::from_millis(250))
729            .with_security(SecurityHeaders::default().with_x_frame_options("SAMEORIGIN"));
730        assert_eq!(config.streaming(), Streaming::EventStream);
731        assert_eq!(config.stream_timeout(), Duration::from_secs(1));
732        assert_eq!(config.keep_alive(), Duration::from_millis(250));
733        assert_eq!(config.security().x_frame_options(), "SAMEORIGIN");
734        // A builder must not leak into the value it was copied from: the
735        // defaults are still the defaults on a freshly built config.
736        assert_eq!(ServerConfig::new()?.streaming(), Streaming::Json);
737        Ok(())
738    }
739
740    /// The replay window is a real capacity, and never zero.
741    ///
742    /// A zero-capacity ring accepts a push and drops it immediately, which is
743    /// a livelock dressed as a buffer — so the field is a `NonZeroUsize` and
744    /// this test is here to say that is deliberate rather than incidental.
745    #[test]
746    fn the_replay_capacity_cannot_be_zero() -> Result<(), TestError> {
747        assert!(ServerConfig::new()?.replay_capacity().get() > 0);
748        Ok(())
749    }
750}