1use 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
47pub const MODERN_MCP_HTTP_METHOD: &str = "POST";
49
50const MAX_IGNORED_CONTENT_ENCODING_EMPTY_ELEMENTS: usize = 16;
53
54const MAX_ACCEPT_MEMBERS: usize = 16;
58
59const 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#[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 #[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 #[must_use]
104 pub const fn max_header_count(&self) -> usize {
105 self.max_header_count
106 }
107
108 #[must_use]
110 pub const fn max_header_block_bytes(&self) -> usize {
111 self.max_header_block_bytes
112 }
113
114 #[must_use]
116 pub const fn max_body_bytes(&self) -> usize {
117 self.max_body_bytes
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct HttpEndpointConfig {
124 path: String,
125 limits: HttpAdmissionLimits,
126}
127
128impl HttpEndpointConfig {
129 #[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 #[must_use]
147 pub fn path(&self) -> &str {
148 &self.path
149 }
150
151 #[must_use]
153 pub const fn limits(&self) -> HttpAdmissionLimits {
154 self.limits
155 }
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum ResponseRepresentation {
161 Json,
163 RequestScopedSse,
167}
168
169#[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 #[must_use]
181 pub const fn request(&self) -> &JsonRpcRequest {
182 &self.request
183 }
184
185 #[must_use]
192 pub(crate) fn raw_params(&self) -> Option<&str> {
193 self.raw_params.as_deref()
194 }
195
196 #[must_use]
198 pub const fn protocol_version(&self) -> FinalProtocolVersion {
199 self.protocol_version
200 }
201
202 #[must_use]
204 pub const fn representation(&self) -> ResponseRepresentation {
205 self.representation
206 }
207
208 #[must_use]
210 pub fn into_request(self) -> JsonRpcRequest {
211 self.request
212 }
213
214 #[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#[derive(Debug, Clone, PartialEq, Eq)]
224pub enum ModernPostRejection {
225 EndpointMismatch,
228 MethodNotAllowed,
230 TooManyHeaders {
232 limit: usize,
234 },
235 HeaderBlockTooLarge {
237 limit: usize,
239 },
240 DuplicateSingletonHeader {
242 name: &'static str,
244 },
245 UnsupportedMediaType,
248 UnsupportedContentCoding,
250 NotAcceptable,
252 Raw(RawJsonAdmissionError),
255 InvalidEnvelope,
257 NotARequest,
260 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
300pub 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
411fn 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
434fn 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
460fn 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
512fn 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
552fn 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 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(), ¬ification).map(|_| ()),
887 Err(ModernPostRejection::NotARequest)
888 );
889 }
890
891 #[test]
892 fn version_mirror_mismatch_is_refused() {
893 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 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}