Skip to main content

fastmcp_server/
http_admission.rs

1//! HTTP-02 A: modern server HTTP admission and validation pipeline.
2//!
3//! One small, auditable authority boundary in front of authentication and
4//! application dispatch. The pipeline admits exactly one HTTP shape — a
5//! byte-exact `POST` to the one configured MCP endpoint path carrying a
6//! strict MCP 2026-07-28 JSON-RPC request — and, for each admitted request,
7//! selects exactly one response representation (immediate JSON or a
8//! request-scoped SSE body) from the request's `Accept` fields.
9//!
10//! Deterministic rejection precedence, checked in this order and always
11//! before body parsing can influence anything downstream:
12//!
13//! 1. endpoint path (byte-exact), 2. method (byte-exact `POST`),
14//! 3. header count/byte bounds, 4. singleton-header duplication,
15//! 5. request media type, 6. request content coding,
16//! 7. `Accept` representation negotiation,
17//! 8. bounded raw JSON-RPC admission (UTF-8/BOM/nesting, and top-level
18//!    arrays rejected — a batch is never iterated or partially dispatched,
19//!    even an array of one valid request),
20//! 9. strict envelope decode, 10. request-shape (a response or notification
21//!    is not admissible here), 11. final protocol-version and
22//!    `Mcp-Method`/`Mcp-Name` header-body mirror admission.
23//!
24//! Every rejection is side-effect free: no authentication, no dispatch, no
25//! response-writer allocation, no state mutation. The pipeline is a pure
26//! function over `(method, path, headers, body)`; downstream ownership —
27//! authentication, authorization, catalog resolution, execution, and the
28//! actual response writer — remains with the dispatcher and later HTTP-02
29//! slices. Header handling here is deliberately minimal and local; the
30//! shared HDR-01 routing-header contract replaces it at integration when
31//! that package lands.
32//!
33//! Bounds are caller-supplied with no ambient defaults: the frozen central
34//! ceilings must be wired explicitly by the integration layer.
35
36use core::fmt;
37use std::sync::Arc;
38
39use fastmcp_protocol::{
40    FINAL_PROTOCOL_VERSION_META_KEY, FinalHttpRequestMetadata, FinalProtocolVersion,
41    JsonRpcAdmissionError, JsonRpcRequest, MCP_METHOD_HEADER, MCP_NAME_HEADER,
42    MCP_PROTOCOL_VERSION_HEADER, RawJsonAdmissionError, RequestAdmissionError,
43    RequestVersionMetadata, admit_final_http_request,
44};
45use serde_json::Value;
46
47/// The one admitted HTTP method, compared byte-exactly.
48pub const MODERN_MCP_HTTP_METHOD: &str = "POST";
49
50/// Maximum ignored empty RFC 9110 list elements in one `Content-Encoding`
51/// value, mirroring the client-side bound: framing noise stays finite.
52const MAX_IGNORED_CONTENT_ENCODING_EMPTY_ELEMENTS: usize = 16;
53
54/// Maximum parsed `Accept` media-range members across all `Accept` field
55/// lines. `Accept` is a list field, so multiple lines merge; the bound keeps
56/// negotiation work finite regardless.
57const MAX_ACCEPT_MEMBERS: usize = 16;
58
59/// Singleton request fields this boundary refuses to see twice.
60const SINGLETON_HEADERS: [&str; 6] = [
61    "content-type",
62    "content-length",
63    "content-encoding",
64    MCP_PROTOCOL_VERSION_HEADER,
65    MCP_METHOD_HEADER,
66    MCP_NAME_HEADER,
67];
68
69/// Explicit, caller-supplied bounds for one admission evaluation.
70///
71/// There is deliberately no `Default`: the frozen numeric ceilings belong to
72/// the central bounds package and are wired in explicitly at integration.
73#[allow(
74    clippy::struct_field_names,
75    reason = "the private fields intentionally mirror the public constructor's distinct admission ceilings"
76)]
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct HttpAdmissionLimits {
79    max_header_count: usize,
80    max_header_block_bytes: usize,
81    max_body_bytes: usize,
82}
83
84impl HttpAdmissionLimits {
85    /// Constructs bounds; every ceiling must be nonzero.
86    #[must_use]
87    pub const fn new(
88        max_header_count: usize,
89        max_header_block_bytes: usize,
90        max_body_bytes: usize,
91    ) -> Option<Self> {
92        if max_header_count == 0 || max_header_block_bytes == 0 || max_body_bytes == 0 {
93            return None;
94        }
95        Some(Self {
96            max_header_count,
97            max_header_block_bytes,
98            max_body_bytes,
99        })
100    }
101
102    /// Maximum number of request header fields.
103    #[must_use]
104    pub const fn max_header_count(&self) -> usize {
105        self.max_header_count
106    }
107
108    /// Maximum total bytes across all header names and values.
109    #[must_use]
110    pub const fn max_header_block_bytes(&self) -> usize {
111        self.max_header_block_bytes
112    }
113
114    /// Maximum request body bytes, also enforced by raw JSON admission.
115    #[must_use]
116    pub const fn max_body_bytes(&self) -> usize {
117        self.max_body_bytes
118    }
119}
120
121/// The one immutable configured MCP endpoint this boundary serves.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct HttpEndpointConfig {
124    path: String,
125    limits: HttpAdmissionLimits,
126}
127
128impl HttpEndpointConfig {
129    /// Binds the configured MCP path at construction. The path must begin
130    /// with `/` and contain no whitespace or control bytes; anything else is
131    /// a configuration error, not a runtime branch.
132    #[must_use]
133    pub fn new(path: impl Into<String>, limits: HttpAdmissionLimits) -> Option<Self> {
134        let path = path.into();
135        if !path.starts_with('/')
136            || path
137                .bytes()
138                .any(|byte| byte.is_ascii_control() || byte == b' ')
139        {
140            return None;
141        }
142        Some(Self { path, limits })
143    }
144
145    /// Returns the immutable configured endpoint path.
146    #[must_use]
147    pub fn path(&self) -> &str {
148        &self.path
149    }
150
151    /// Returns the configured admission bounds.
152    #[must_use]
153    pub const fn limits(&self) -> HttpAdmissionLimits {
154        self.limits
155    }
156}
157
158/// The exactly-one response representation selected for an admitted request.
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum ResponseRepresentation {
161    /// One immediate `application/json` response body.
162    Json,
163    /// One request-scoped `text/event-stream` response body that closes
164    /// after this request's terminal outcome and creates no state beyond
165    /// the request's response writer.
166    RequestScopedSse,
167}
168
169/// A fully admitted modern POST, ready for authentication and dispatch.
170#[derive(Debug, Clone)]
171pub struct AdmittedModernPost {
172    request: JsonRpcRequest,
173    raw_params: Option<Arc<str>>,
174    protocol_version: FinalProtocolVersion,
175    representation: ResponseRepresentation,
176}
177
178impl AdmittedModernPost {
179    /// Returns the validated JSON-RPC request.
180    #[must_use]
181    pub const fn request(&self) -> &JsonRpcRequest {
182        &self.request
183    }
184
185    /// Returns the exact `params` member source admitted with this request.
186    ///
187    /// This crate-private sidecar is intentionally distinct from the public
188    /// [`JsonRpcRequest`] shape. It is consumed only by modern dispatch,
189    /// which verifies it materializes to the retained request parameters
190    /// before it is used for ordered MRTR response decoding.
191    #[must_use]
192    pub(crate) fn raw_params(&self) -> Option<&str> {
193        self.raw_params.as_deref()
194    }
195
196    /// Returns the admitted final protocol version.
197    #[must_use]
198    pub const fn protocol_version(&self) -> FinalProtocolVersion {
199        self.protocol_version
200    }
201
202    /// Returns the representation selected from the request's `Accept`.
203    #[must_use]
204    pub const fn representation(&self) -> ResponseRepresentation {
205        self.representation
206    }
207
208    /// Consumes the admission and yields the request for dispatch.
209    #[must_use]
210    pub fn into_request(self) -> JsonRpcRequest {
211        self.request
212    }
213
214    /// Consumes admission into the typed request and its exact parameter
215    /// source for request-owned modern dispatch.
216    #[must_use]
217    pub(crate) fn into_request_and_raw_params(self) -> (JsonRpcRequest, Option<Arc<str>>) {
218        (self.request, self.raw_params)
219    }
220}
221
222/// Typed, side-effect-free rejections in deterministic precedence order.
223#[derive(Debug, Clone, PartialEq, Eq)]
224pub enum ModernPostRejection {
225    /// The request targeted a different path than the one configured MCP
226    /// endpoint. The route's fixed empty-body rejection applies.
227    EndpointMismatch,
228    /// The request used a method other than byte-exact `POST`.
229    MethodNotAllowed,
230    /// The request exceeded the header-count ceiling.
231    TooManyHeaders {
232        /// The configured header-count ceiling.
233        limit: usize,
234    },
235    /// The request exceeded the total header-byte ceiling.
236    HeaderBlockTooLarge {
237        /// The configured header-block ceiling in bytes.
238        limit: usize,
239    },
240    /// A singleton request field appeared more than once.
241    DuplicateSingletonHeader {
242        /// The lowercase singleton field name.
243        name: &'static str,
244    },
245    /// The request media type is not `application/json` (with at most one
246    /// `charset=utf-8` parameter).
247    UnsupportedMediaType,
248    /// The request content coding is neither absent nor singleton identity.
249    UnsupportedContentCoding,
250    /// Neither JSON nor SSE is acceptable to the request's `Accept` fields.
251    NotAcceptable,
252    /// Bounded raw JSON-RPC admission refused the body before parsing,
253    /// including every top-level array.
254    Raw(RawJsonAdmissionError),
255    /// The admitted raw document is not a strict JSON-RPC envelope.
256    InvalidEnvelope,
257    /// The strict envelope is a response or notification, which this
258    /// request-admission boundary does not accept.
259    NotARequest,
260    /// Final protocol-version or header/body mirror admission refused the
261    /// request.
262    FinalAdmission(RequestAdmissionError),
263}
264
265impl fmt::Display for ModernPostRejection {
266    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
267        match self {
268            Self::EndpointMismatch => formatter.write_str("request path is not the MCP endpoint"),
269            Self::MethodNotAllowed => formatter.write_str("only byte-exact POST is admitted"),
270            Self::TooManyHeaders { limit } => {
271                write!(formatter, "request exceeds {limit} header fields")
272            }
273            Self::HeaderBlockTooLarge { limit } => {
274                write!(formatter, "request headers exceed {limit} bytes")
275            }
276            Self::DuplicateSingletonHeader { name } => {
277                write!(formatter, "singleton header {name} repeats")
278            }
279            Self::UnsupportedMediaType => {
280                formatter.write_str("request media type is not application/json")
281            }
282            Self::UnsupportedContentCoding => {
283                formatter.write_str("request content coding is not identity")
284            }
285            Self::NotAcceptable => {
286                formatter.write_str("neither JSON nor SSE is acceptable to this request")
287            }
288            Self::Raw(error) => write!(formatter, "raw JSON-RPC admission refused: {error:?}"),
289            Self::InvalidEnvelope => formatter.write_str("body is not a strict JSON-RPC envelope"),
290            Self::NotARequest => formatter.write_str("body is not a JSON-RPC request with an id"),
291            Self::FinalAdmission(error) => {
292                write!(formatter, "final request admission refused: {error:?}")
293            }
294        }
295    }
296}
297
298impl std::error::Error for ModernPostRejection {}
299
300/// Admits one modern MCP POST or returns the first typed rejection in
301/// precedence order.
302///
303/// The pipeline is pure: it allocates no response state, performs no
304/// authentication, and dispatches nothing. On success the caller hands the
305/// admitted request and its request-local representation to authentication
306/// and the modern dispatcher.
307///
308/// # Errors
309///
310/// Returns the first failing [`ModernPostRejection`] in the documented
311/// precedence order; every rejection is side-effect free.
312pub fn admit_modern_post(
313    config: &HttpEndpointConfig,
314    method: &str,
315    path: &str,
316    headers: &[(String, String)],
317    body: &[u8],
318) -> Result<AdmittedModernPost, ModernPostRejection> {
319    if path != config.path() {
320        return Err(ModernPostRejection::EndpointMismatch);
321    }
322    if method != MODERN_MCP_HTTP_METHOD {
323        return Err(ModernPostRejection::MethodNotAllowed);
324    }
325
326    let limits = config.limits();
327    if headers.len() > limits.max_header_count() {
328        return Err(ModernPostRejection::TooManyHeaders {
329            limit: limits.max_header_count(),
330        });
331    }
332    let header_block_bytes: usize = headers
333        .iter()
334        .map(|(name, value)| name.len().saturating_add(value.len()))
335        .fold(0_usize, usize::saturating_add);
336    if header_block_bytes > limits.max_header_block_bytes() {
337        return Err(ModernPostRejection::HeaderBlockTooLarge {
338            limit: limits.max_header_block_bytes(),
339        });
340    }
341    for name in SINGLETON_HEADERS {
342        let occurrences = headers
343            .iter()
344            .filter(|(header, _)| header.eq_ignore_ascii_case(name))
345            .count();
346        if occurrences > 1 {
347            return Err(ModernPostRejection::DuplicateSingletonHeader { name });
348        }
349    }
350
351    let content_type = singleton_value(headers, "content-type");
352    if !content_type.is_some_and(is_admitted_json_media_type) {
353        return Err(ModernPostRejection::UnsupportedMediaType);
354    }
355    if let Some(coding) = singleton_value(headers, "content-encoding")
356        && !is_singleton_identity_coding(coding)
357    {
358        return Err(ModernPostRejection::UnsupportedContentCoding);
359    }
360
361    let representation = negotiate_representation(headers)?;
362
363    JsonRpcRequest::decode_strict_with_raw_params(body, limits.max_body_bytes())
364        .map_err(|error| match error {
365            JsonRpcAdmissionError::Raw(raw) => ModernPostRejection::Raw(raw),
366            _ => ModernPostRejection::InvalidEnvelope,
367        })
368        .and_then(|(request, raw_params)| {
369            if request.id.is_some() {
370                Ok((request, raw_params))
371            } else {
372                Err(ModernPostRejection::NotARequest)
373            }
374        })
375        .and_then(|(request, raw_params)| {
376            let protocol_version = {
377                let metadata = FinalHttpRequestMetadata {
378                    version: RequestVersionMetadata {
379                        header_version: singleton_value(headers, MCP_PROTOCOL_VERSION_HEADER),
380                        body_version: body_protocol_version(&request),
381                    },
382                    header_method: singleton_value(headers, MCP_METHOD_HEADER),
383                    body_method: Some(request.method.as_str()),
384                    header_name: singleton_value(headers, MCP_NAME_HEADER),
385                    body_name: body_mirror_name(&request),
386                };
387                admit_final_http_request(metadata)
388                    .map_err(ModernPostRejection::FinalAdmission)?
389                    .protocol_version()
390            };
391            Ok(AdmittedModernPost {
392                request,
393                raw_params: raw_params.map(Arc::<str>::from),
394                protocol_version,
395                representation,
396            })
397        })
398}
399
400fn singleton_value<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
401    headers
402        .iter()
403        .find(|(header, _)| header.eq_ignore_ascii_case(name))
404        .map(|(_, value)| value.as_str())
405}
406
407fn trim_http_ows(value: &str) -> &str {
408    value.trim_matches([' ', '\t'])
409}
410
411/// Accepts exactly `application/json`, optionally with one
412/// `charset=utf-8` parameter, ASCII-case-insensitively.
413fn is_admitted_json_media_type(value: &str) -> bool {
414    let mut parts = value.split(';');
415    let Some(essence) = parts.next().map(trim_http_ows) else {
416        return false;
417    };
418    if !essence.eq_ignore_ascii_case("application/json") {
419        return false;
420    }
421    let Some(parameter) = parts.next() else {
422        return true;
423    };
424    if parts.next().is_some() {
425        return false;
426    }
427    let Some((name, charset)) = trim_http_ows(parameter).split_once('=') else {
428        return false;
429    };
430    trim_http_ows(name).eq_ignore_ascii_case("charset")
431        && trim_http_ows(charset).eq_ignore_ascii_case("utf-8")
432}
433
434/// Accepts an absent header at the caller; a present value must reduce to
435/// exactly one semantic `identity` token after ignoring a bounded number of
436/// empty RFC 9110 list elements.
437fn is_singleton_identity_coding(value: &str) -> bool {
438    let mut ignored_empty_elements = 0_usize;
439    let mut semantic_codings = 0_usize;
440    for element in value.split(',') {
441        let element = trim_http_ows(element);
442        if element.is_empty() {
443            ignored_empty_elements += 1;
444            if ignored_empty_elements > MAX_IGNORED_CONTENT_ENCODING_EMPTY_ELEMENTS {
445                return false;
446            }
447            continue;
448        }
449        if !element.eq_ignore_ascii_case("identity") {
450            return false;
451        }
452        semantic_codings += 1;
453        if semantic_codings > 1 {
454            return false;
455        }
456    }
457    semantic_codings == 1
458}
459
460/// Selects JSON when JSON is acceptable, SSE only when SSE is acceptable,
461/// and rejects when neither representation is. A request with no `Accept`
462/// field accepts every representation and selects JSON. Media ranges that
463/// cannot be parsed grant no acceptance.
464fn negotiate_representation(
465    headers: &[(String, String)],
466) -> Result<ResponseRepresentation, ModernPostRejection> {
467    let mut members = 0_usize;
468    let mut saw_accept_header = false;
469    let mut json_acceptable = false;
470    let mut sse_acceptable = false;
471    for (name, value) in headers {
472        if !name.eq_ignore_ascii_case("accept") {
473            continue;
474        }
475        saw_accept_header = true;
476        for member in value.split(',') {
477            let member = trim_http_ows(member);
478            if member.is_empty() {
479                continue;
480            }
481            members += 1;
482            if members > MAX_ACCEPT_MEMBERS {
483                return Err(ModernPostRejection::NotAcceptable);
484            }
485            let mut parameters = member.split(';');
486            let Some(essence) = parameters.next().map(trim_http_ows) else {
487                continue;
488            };
489            if media_range_weight_is_zero(parameters) {
490                continue;
491            }
492            if matches_media_range(essence, "application", "json") {
493                json_acceptable = true;
494            }
495            if matches_media_range(essence, "text", "event-stream") {
496                sse_acceptable = true;
497            }
498        }
499    }
500    if !saw_accept_header {
501        return Ok(ResponseRepresentation::Json);
502    }
503    if json_acceptable {
504        return Ok(ResponseRepresentation::Json);
505    }
506    if sse_acceptable {
507        return Ok(ResponseRepresentation::RequestScopedSse);
508    }
509    Err(ModernPostRejection::NotAcceptable)
510}
511
512/// `true` when a `q` parameter is present and denotes zero weight.
513fn media_range_weight_is_zero<'a>(parameters: impl Iterator<Item = &'a str>) -> bool {
514    for parameter in parameters {
515        let Some((name, value)) = trim_http_ows(parameter).split_once('=') else {
516            continue;
517        };
518        if !trim_http_ows(name).eq_ignore_ascii_case("q") {
519            continue;
520        }
521        let value = trim_http_ows(value);
522        let mut chars = value.chars();
523        if chars.next() != Some('0') {
524            return false;
525        }
526        let rest = chars.as_str();
527        let fraction = rest.strip_prefix('.').unwrap_or(rest);
528        return fraction.len() <= 3 && fraction.chars().all(|digit| digit == '0');
529    }
530    false
531}
532
533fn matches_media_range(essence: &str, wanted_type: &str, wanted_subtype: &str) -> bool {
534    let Some((range_type, range_subtype)) = essence.split_once('/') else {
535        return false;
536    };
537    let range_type = trim_http_ows(range_type);
538    let range_subtype = trim_http_ows(range_subtype);
539    (range_type == "*" || range_type.eq_ignore_ascii_case(wanted_type))
540        && (range_subtype == "*" || range_subtype.eq_ignore_ascii_case(wanted_subtype))
541}
542
543fn body_protocol_version(request: &JsonRpcRequest) -> Option<&str> {
544    request
545        .params
546        .as_ref()
547        .and_then(|params| params.get("_meta"))
548        .and_then(|meta| meta.get(FINAL_PROTOCOL_VERSION_META_KEY))
549        .and_then(Value::as_str)
550}
551
552/// Returns the body value mirrored by `Mcp-Name` for the methods that
553/// require it; the final admission itself decides whether a mirror is
554/// mandatory for the request's method.
555fn body_mirror_name(request: &JsonRpcRequest) -> Option<&str> {
556    let key = match request.method.as_str() {
557        "tools/call" | "prompts/get" => "name",
558        "resources/read" => "uri",
559        "tasks/get" | "tasks/update" | "tasks/cancel" => "taskId",
560        _ => return None,
561    };
562    request
563        .params
564        .as_ref()
565        .and_then(|params| params.get(key))
566        .and_then(Value::as_str)
567}
568
569#[cfg(test)]
570mod tests {
571    use fastmcp_protocol::FINAL_PROTOCOL_VERSION;
572    use serde_json::json;
573
574    use super::{
575        AdmittedModernPost, HttpAdmissionLimits, HttpEndpointConfig, ModernPostRejection,
576        ResponseRepresentation, admit_modern_post,
577    };
578
579    fn config() -> HttpEndpointConfig {
580        HttpEndpointConfig::new(
581            "/mcp",
582            HttpAdmissionLimits::new(32, 8_192, 65_536).expect("nonzero limits"),
583        )
584        .expect("valid endpoint path")
585    }
586
587    fn canonical_headers() -> Vec<(String, String)> {
588        vec![
589            ("Content-Type".to_owned(), "application/json".to_owned()),
590            (
591                "Accept".to_owned(),
592                "application/json, text/event-stream".to_owned(),
593            ),
594            (
595                "MCP-Protocol-Version".to_owned(),
596                FINAL_PROTOCOL_VERSION.to_owned(),
597            ),
598            ("Mcp-Method".to_owned(), "server/discover".to_owned()),
599        ]
600    }
601
602    fn canonical_body() -> Vec<u8> {
603        serde_json::to_vec(&json!({
604            "jsonrpc": "2.0",
605            "id": 1,
606            "method": "server/discover",
607            "params": {
608                "_meta": {
609                    "io.modelcontextprotocol/protocolVersion": FINAL_PROTOCOL_VERSION,
610                    "io.modelcontextprotocol/clientCapabilities": {}
611                }
612            }
613        }))
614        .expect("canonical body serializes")
615    }
616
617    fn admit(
618        headers: &[(String, String)],
619        body: &[u8],
620    ) -> Result<AdmittedModernPost, ModernPostRejection> {
621        admit_modern_post(&config(), "POST", "/mcp", headers, body)
622    }
623
624    #[test]
625    fn admits_canonical_modern_post_with_json_representation() {
626        let admitted = admit(&canonical_headers(), &canonical_body())
627            .expect("canonical modern POST is admitted");
628        assert_eq!(admitted.representation(), ResponseRepresentation::Json);
629        assert_eq!(admitted.protocol_version().as_str(), FINAL_PROTOCOL_VERSION);
630        assert_eq!(admitted.request().method, "server/discover");
631    }
632
633    #[test]
634    fn admission_retains_exact_params_source_beside_the_typed_request() {
635        let body = br#"{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}},"ordered":{"second":2,"first":1}}}"#;
636        let admitted = admit(&canonical_headers(), body).expect("admission succeeds");
637        assert_eq!(
638            admitted.raw_params(),
639            Some(
640                r#"{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}},"ordered":{"second":2,"first":1}}"#,
641            ),
642            "the sidecar is the admitted source, not a serialization of the typed request"
643        );
644        let (request, raw_params) = admitted.into_request_and_raw_params();
645        assert_eq!(
646            request
647                .params
648                .as_ref()
649                .and_then(|params| params.get("ordered"))
650                .and_then(|ordered| ordered.get("second")),
651            Some(&serde_json::json!(2))
652        );
653        assert_eq!(
654            raw_params.as_deref(),
655            Some(
656                r#"{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}},"ordered":{"second":2,"first":1}}"#
657            )
658        );
659    }
660
661    #[test]
662    fn selects_sse_only_when_json_is_not_acceptable() {
663        let mut headers = canonical_headers();
664        headers[1].1 = "text/event-stream".to_owned();
665        let admitted = admit(&headers, &canonical_body()).expect("SSE-only accept admits");
666        assert_eq!(
667            admitted.representation(),
668            ResponseRepresentation::RequestScopedSse
669        );
670    }
671
672    #[test]
673    fn zero_quality_json_yields_sse() {
674        let mut headers = canonical_headers();
675        headers[1].1 = "application/json;q=0, text/event-stream".to_owned();
676        let admitted = admit(&headers, &canonical_body()).expect("q=0 excludes JSON only");
677        assert_eq!(
678            admitted.representation(),
679            ResponseRepresentation::RequestScopedSse
680        );
681    }
682
683    #[test]
684    fn wildcard_and_absent_accept_select_json() {
685        let mut headers = canonical_headers();
686        headers[1].1 = "*/*".to_owned();
687        let admitted = admit(&headers, &canonical_body()).expect("wildcard admits");
688        assert_eq!(admitted.representation(), ResponseRepresentation::Json);
689
690        let headers: Vec<_> = canonical_headers()
691            .into_iter()
692            .filter(|(name, _)| name != "Accept")
693            .collect();
694        let admitted = admit(&headers, &canonical_body()).expect("absent Accept admits");
695        assert_eq!(admitted.representation(), ResponseRepresentation::Json);
696    }
697
698    #[test]
699    fn unusable_accept_is_not_acceptable() {
700        let mut headers = canonical_headers();
701        headers[1].1 = "text/plain, application/xml".to_owned();
702        assert_eq!(
703            admit(&headers, &canonical_body()).map(|_| ()),
704            Err(ModernPostRejection::NotAcceptable)
705        );
706
707        let mut headers = canonical_headers();
708        headers[1].1 = "garbage-without-slash".to_owned();
709        assert_eq!(
710            admit(&headers, &canonical_body()).map(|_| ()),
711            Err(ModernPostRejection::NotAcceptable),
712            "unparseable media ranges grant no acceptance"
713        );
714    }
715
716    #[test]
717    fn wrong_path_and_method_reject_before_everything_else() {
718        let result = admit_modern_post(
719            &config(),
720            "POST",
721            "/other",
722            &canonical_headers(),
723            &canonical_body(),
724        );
725        assert_eq!(
726            result.map(|_| ()),
727            Err(ModernPostRejection::EndpointMismatch)
728        );
729
730        for method in ["GET", "post", "PUT", "DELETE", "OPTIONS"] {
731            let result = admit_modern_post(
732                &config(),
733                method,
734                "/mcp",
735                &canonical_headers(),
736                &canonical_body(),
737            );
738            assert_eq!(
739                result.map(|_| ()),
740                Err(ModernPostRejection::MethodNotAllowed),
741                "method {method:?} must be refused byte-exactly"
742            );
743        }
744    }
745
746    #[test]
747    fn header_bounds_are_exact() {
748        let limits = HttpAdmissionLimits::new(4, 8_192, 65_536).expect("limits");
749        let config = HttpEndpointConfig::new("/mcp", limits).expect("config");
750        let headers = canonical_headers();
751        assert_eq!(headers.len(), 4);
752        assert!(admit_modern_post(&config, "POST", "/mcp", &headers, &canonical_body()).is_ok());
753
754        let mut extra = headers.clone();
755        extra.push(("X-Extra".to_owned(), "y".to_owned()));
756        assert_eq!(
757            admit_modern_post(&config, "POST", "/mcp", &extra, &canonical_body()).map(|_| ()),
758            Err(ModernPostRejection::TooManyHeaders { limit: 4 })
759        );
760
761        let tight = HttpEndpointConfig::new(
762            "/mcp",
763            HttpAdmissionLimits::new(32, 16, 65_536).expect("limits"),
764        )
765        .expect("config");
766        assert_eq!(
767            admit_modern_post(&tight, "POST", "/mcp", &headers, &canonical_body()).map(|_| ()),
768            Err(ModernPostRejection::HeaderBlockTooLarge { limit: 16 })
769        );
770    }
771
772    #[test]
773    fn duplicate_singleton_headers_reject() {
774        let mut headers = canonical_headers();
775        headers.push(("content-type".to_owned(), "application/json".to_owned()));
776        assert_eq!(
777            admit(&headers, &canonical_body()).map(|_| ()),
778            Err(ModernPostRejection::DuplicateSingletonHeader {
779                name: "content-type"
780            })
781        );
782    }
783
784    #[test]
785    fn media_type_admission_is_exact() {
786        for (value, admitted) in [
787            ("application/json", true),
788            ("Application/JSON", true),
789            ("application/json; charset=utf-8", true),
790            ("application/json; charset=UTF-8", true),
791            ("application/json; charset=utf-16", false),
792            ("application/json; charset=utf-8; boundary=x", false),
793            ("text/plain", false),
794            ("application/json-seq", false),
795        ] {
796            let mut headers = canonical_headers();
797            headers[0].1 = value.to_owned();
798            let result = admit(&headers, &canonical_body());
799            assert_eq!(
800                result.is_ok(),
801                admitted,
802                "content type {value:?} admission mismatch"
803            );
804            if !admitted {
805                assert_eq!(
806                    result.map(|_| ()),
807                    Err(ModernPostRejection::UnsupportedMediaType)
808                );
809            }
810        }
811
812        let headers: Vec<_> = canonical_headers()
813            .into_iter()
814            .filter(|(name, _)| name != "Content-Type")
815            .collect();
816        assert!(
817            matches!(
818                admit(&headers, &canonical_body()),
819                Err(ModernPostRejection::UnsupportedMediaType)
820            ),
821            "a missing request content type is fail-closed"
822        );
823    }
824
825    #[test]
826    fn content_coding_admission_is_identity_only() {
827        for (value, admitted) in [
828            ("identity", true),
829            ("Identity", true),
830            (", identity", true),
831            ("gzip", false),
832            ("identity, identity", false),
833            ("", false),
834            (",,,", false),
835        ] {
836            let mut headers = canonical_headers();
837            headers.push(("Content-Encoding".to_owned(), value.to_owned()));
838            let result = admit(&headers, &canonical_body());
839            assert_eq!(
840                result.is_ok(),
841                admitted,
842                "content coding {value:?} admission mismatch"
843            );
844            if !admitted {
845                assert_eq!(
846                    result.map(|_| ()),
847                    Err(ModernPostRejection::UnsupportedContentCoding)
848                );
849            }
850        }
851    }
852
853    #[test]
854    fn top_level_arrays_are_rejected_before_any_dispatch() {
855        use fastmcp_protocol::RawJsonAdmissionError;
856        // Even an array of one valid request is a refused batch.
857        let body = serde_json::to_vec(&json!([{
858            "jsonrpc": "2.0",
859            "id": 1,
860            "method": "server/discover",
861            "params": {
862                "_meta": {
863                    "io.modelcontextprotocol/protocolVersion": FINAL_PROTOCOL_VERSION,
864                    "io.modelcontextprotocol/clientCapabilities": {}
865                }
866            }
867        }]))
868        .expect("array body serializes");
869        assert_eq!(
870            admit(&canonical_headers(), &body).map(|_| ()),
871            Err(ModernPostRejection::Raw(
872                RawJsonAdmissionError::TopLevelBatch
873            ))
874        );
875    }
876
877    #[test]
878    fn notifications_and_responses_are_not_admitted_here() {
879        let notification = serde_json::to_vec(&json!({
880            "jsonrpc": "2.0",
881            "method": "notifications/whatever",
882            "params": {}
883        }))
884        .expect("notification serializes");
885        assert_eq!(
886            admit(&canonical_headers(), &notification).map(|_| ()),
887            Err(ModernPostRejection::NotARequest)
888        );
889    }
890
891    #[test]
892    fn version_mirror_mismatch_is_refused() {
893        // Change only the header version: the body still says 2026-07-28.
894        let mut headers = canonical_headers();
895        headers[2].1 = "2025-11-25".to_owned();
896        let result = admit(&headers, &canonical_body());
897        assert!(
898            matches!(result, Err(ModernPostRejection::FinalAdmission(_))),
899            "a mismatched version mirror must be refused, got {result:?}"
900        );
901    }
902
903    #[test]
904    fn name_mirror_is_required_for_tools_call() {
905        let body = serde_json::to_vec(&json!({
906            "jsonrpc": "2.0",
907            "id": 2,
908            "method": "tools/call",
909            "params": {
910                "name": "echo",
911                "arguments": {},
912                "_meta": {
913                    "io.modelcontextprotocol/protocolVersion": FINAL_PROTOCOL_VERSION,
914                    "io.modelcontextprotocol/clientCapabilities": {}
915                }
916            }
917        }))
918        .expect("tools/call body serializes");
919
920        let mut headers = canonical_headers();
921        headers[3].1 = "tools/call".to_owned();
922        assert!(
923            matches!(
924                admit(&headers, &body),
925                Err(ModernPostRejection::FinalAdmission(_))
926            ),
927            "tools/call without Mcp-Name must be refused"
928        );
929
930        headers.push(("Mcp-Name".to_owned(), "echo".to_owned()));
931        let admitted = admit(&headers, &body).expect("mirrored tools/call admits");
932        assert_eq!(admitted.request().method, "tools/call");
933    }
934
935    #[test]
936    fn task_lifecycle_methods_mirror_task_id_through_mcp_name() {
937        for method in ["tasks/get", "tasks/update", "tasks/cancel"] {
938            let body = serde_json::to_vec(&json!({
939                "jsonrpc": "2.0",
940                "id": 2,
941                "method": method,
942                "params": {
943                    "taskId": "task-73",
944                    "_meta": {
945                        "io.modelcontextprotocol/protocolVersion": FINAL_PROTOCOL_VERSION,
946                        "io.modelcontextprotocol/clientCapabilities": {
947                            "extensions": {"io.modelcontextprotocol/tasks": {}}
948                        }
949                    }
950                }
951            }))
952            .expect("Tasks lifecycle body serializes");
953            let mut headers = canonical_headers();
954            headers[3].1 = method.to_owned();
955            headers.push(("Mcp-Name".to_owned(), "task-73".to_owned()));
956
957            let admitted =
958                admit(&headers, &body).expect("a matching taskId/Mcp-Name mirror must be admitted");
959            assert_eq!(admitted.request().method, method);
960        }
961    }
962
963    #[test]
964    fn task_get_rejects_only_a_mismatched_task_id_mcp_name_before_dispatch() {
965        let body = serde_json::to_vec(&json!({
966            "jsonrpc": "2.0",
967            "id": 2,
968            "method": "tasks/get",
969            "params": {
970                "taskId": "task-73",
971                "_meta": {
972                    "io.modelcontextprotocol/protocolVersion": FINAL_PROTOCOL_VERSION,
973                    "io.modelcontextprotocol/clientCapabilities": {
974                        "extensions": {"io.modelcontextprotocol/tasks": {}}
975                    }
976                }
977            }
978        }))
979        .expect("Tasks lifecycle body serializes");
980        let mut headers = canonical_headers();
981        headers[3].1 = "tasks/get".to_owned();
982        headers.push(("Mcp-Name".to_owned(), "task-other".to_owned()));
983
984        assert!(
985            matches!(
986                admit(&headers, &body),
987                Err(ModernPostRejection::FinalAdmission(_))
988            ),
989            "changing only Mcp-Name must reject before dispatch"
990        );
991    }
992
993    #[test]
994    fn rejections_precede_body_parsing_for_transport_failures() {
995        // A hopeless body is never parsed when the media type already fails:
996        // the typed rejection proves precedence, and a pure pipeline over
997        // borrowed inputs has no state to mutate.
998        let mut headers = canonical_headers();
999        headers[0].1 = "text/plain".to_owned();
1000        assert_eq!(
1001            admit(&headers, b"this is not json").map(|_| ()),
1002            Err(ModernPostRejection::UnsupportedMediaType)
1003        );
1004    }
1005
1006    #[test]
1007    fn invalid_configurations_are_refused_at_construction() {
1008        assert!(HttpAdmissionLimits::new(0, 1, 1).is_none());
1009        assert!(HttpAdmissionLimits::new(1, 0, 1).is_none());
1010        assert!(HttpAdmissionLimits::new(1, 1, 0).is_none());
1011        let limits = HttpAdmissionLimits::new(1, 1, 1).expect("limits");
1012        assert!(HttpEndpointConfig::new("mcp", limits).is_none());
1013        assert!(HttpEndpointConfig::new("/m cp", limits).is_none());
1014        assert!(HttpEndpointConfig::new("/mcp\r", limits).is_none());
1015    }
1016}