diff --git a/crates/rmcp/src/transport/common/http_header.rs b/crates/rmcp/src/transport/common/http_header.rs index 6c2abbc8d..3b24ec038 100644 --- a/crates/rmcp/src/transport/common/http_header.rs +++ b/crates/rmcp/src/transport/common/http_header.rs @@ -8,6 +8,21 @@ pub const JSON_MIME_TYPE: &str = "application/json"; pub const HEADER_MCP_METHOD: &str = "Mcp-Method"; pub const HEADER_MCP_NAME: &str = "Mcp-Name"; pub const HEADER_MCP_PARAM_PREFIX: &str = "Mcp-Param-"; +pub const HEADER_MCP_PARAM_PREFIX_LOWER: &str = "mcp-param-"; + +#[cfg(any(feature = "client-side-sse", feature = "server-side-http"))] +pub const HEADER_NAME_SESSION_ID: http::HeaderName = + http::HeaderName::from_static("mcp-session-id"); +#[cfg(any(feature = "client-side-sse", feature = "server-side-http"))] +pub const HEADER_NAME_LAST_EVENT_ID: http::HeaderName = + http::HeaderName::from_static("last-event-id"); +#[cfg(any(feature = "client-side-sse", feature = "server-side-http"))] +pub const HEADER_NAME_MCP_PROTOCOL_VERSION: http::HeaderName = + http::HeaderName::from_static("mcp-protocol-version"); +#[cfg(any(feature = "client-side-sse", feature = "server-side-http"))] +pub const HEADER_NAME_MCP_METHOD: http::HeaderName = http::HeaderName::from_static("mcp-method"); +#[cfg(any(feature = "client-side-sse", feature = "server-side-http"))] +pub const HEADER_NAME_MCP_NAME: http::HeaderName = http::HeaderName::from_static("mcp-name"); /// Sentinel wrapping a Base64-encoded SEP-2243 header value (`=?base64??=`). pub const BASE64_HEADER_PREFIX: &str = "=?base64?"; @@ -29,14 +44,8 @@ pub(crate) const RESERVED_HEADERS: &[&str] = &[ /// `MCP-Protocol-Version` is reserved but allowed through (the worker injects it post-init). #[cfg(feature = "client-side-sse")] pub(crate) fn validate_custom_header(name: &http::HeaderName) -> Result<(), String> { - if RESERVED_HEADERS - .iter() - .any(|&r| name.as_str().eq_ignore_ascii_case(r)) - { - if name - .as_str() - .eq_ignore_ascii_case(HEADER_MCP_PROTOCOL_VERSION) - { + if is_reserved_header_name(name) { + if name == HEADER_NAME_MCP_PROTOCOL_VERSION { return Ok(()); } return Err(name.to_string()); @@ -44,6 +53,14 @@ pub(crate) fn validate_custom_header(name: &http::HeaderName) -> Result<(), Stri Ok(()) } +#[cfg(feature = "client-side-sse")] +fn is_reserved_header_name(name: &http::HeaderName) -> bool { + name == http::header::ACCEPT + || name == HEADER_NAME_SESSION_ID + || name == HEADER_NAME_MCP_PROTOCOL_VERSION + || name == HEADER_NAME_LAST_EVENT_ID +} + /// Extracts the `scope=` parameter from a `WWW-Authenticate` header value. /// Handles both quoted (`scope="files:read files:write"`) and unquoted (`scope=read:data`) forms. #[cfg(feature = "client-side-sse")] @@ -74,6 +91,9 @@ pub(crate) fn extract_scope_from_header(header: &str) -> Option { #[cfg(test)] mod tests { + #[cfg(any(feature = "client-side-sse", feature = "server-side-http"))] + use http::{HeaderMap, HeaderName, HeaderValue}; + #[cfg(feature = "client-side-sse")] use super::*; @@ -137,4 +157,37 @@ mod tests { let name = http::HeaderName::from_static("x-custom"); assert!(validate_custom_header(&name).is_ok()); } + + #[cfg(any(feature = "client-side-sse", feature = "server-side-http"))] + #[test] + fn header_name_constants_match_case_insensitively() { + let cases = [ + (HEADER_NAME_SESSION_ID, "McP-SeSsIoN-Id"), + (HEADER_NAME_LAST_EVENT_ID, "LaSt-EvEnT-Id"), + (HEADER_NAME_MCP_PROTOCOL_VERSION, "McP-PrOtOcOl-VeRsIoN"), + (HEADER_NAME_MCP_METHOD, "McP-MeThOd"), + (HEADER_NAME_MCP_NAME, "McP-NaMe"), + ]; + + for (constant, mixed_case) in cases { + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_bytes(mixed_case.as_bytes()).expect("valid header name"), + HeaderValue::from_static("value"), + ); + + assert_eq!( + headers.get(constant), + Some(&HeaderValue::from_static("value")) + ); + } + } + + #[cfg(any(feature = "client-side-sse", feature = "server-side-http"))] + #[test] + fn mcp_param_lower_prefix_matches_header_names() { + let name = HeaderName::from_static("mcp-param-user"); + + assert!(name.as_str().starts_with(HEADER_MCP_PARAM_PREFIX_LOWER)); + } } diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index 7f08b6c25..c1cda1dbe 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -11,8 +11,9 @@ use crate::{ common::{ client_side_sse::{DEFAULT_MAX_SSE_EVENT_SIZE, bounded_sse_stream}, http_header::{ - EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_SESSION_ID, JSON_MIME_TYPE, - extract_scope_from_header, validate_custom_header, + EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_NAME_SESSION_ID, + HEADER_SESSION_ID, JSON_MIME_TYPE, extract_scope_from_header, + validate_custom_header, }, }, streamable_http_client::*, @@ -257,7 +258,7 @@ impl StreamableHttpClient for reqwest::Client { let content_length = response.content_length(); let session_id = response .headers() - .get(HEADER_SESSION_ID) + .get(HEADER_NAME_SESSION_ID) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); // Spec requires 202 Accepted for these, but some servers return an empty 200. diff --git a/crates/rmcp/src/transport/common/unix_socket.rs b/crates/rmcp/src/transport/common/unix_socket.rs index 5f995db2b..2816d807f 100644 --- a/crates/rmcp/src/transport/common/unix_socket.rs +++ b/crates/rmcp/src/transport/common/unix_socket.rs @@ -15,8 +15,9 @@ use crate::{ common::{ client_side_sse::{DEFAULT_MAX_SSE_EVENT_SIZE, bounded_sse_stream}, http_header::{ - EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_SESSION_ID, JSON_MIME_TYPE, - extract_scope_from_header, validate_custom_header, + EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_NAME_SESSION_ID, + HEADER_SESSION_ID, JSON_MIME_TYPE, extract_scope_from_header, + validate_custom_header, }, }, streamable_http_client::*, @@ -287,7 +288,7 @@ impl StreamableHttpClient for UnixSocketHttpClient { .and_then(|v| v.parse::().ok()); let session_id = response .headers() - .get(HEADER_SESSION_ID) + .get(HEADER_NAME_SESSION_ID) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index fe563ee3f..aef154484 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -25,7 +25,10 @@ use crate::{ }, service::InboundStreamOrigin, transport::{ - common::{client_side_sse::SseAutoReconnectStream, mcp_headers}, + common::{ + client_side_sse::SseAutoReconnectStream, http_header::HEADER_NAME_MCP_PROTOCOL_VERSION, + mcp_headers, + }, worker::{Worker, WorkerQuitReason, WorkerSendRequest, WorkerTransport}, }, }; @@ -78,7 +81,7 @@ fn request_version_headers( }; let mut headers = build_request_headers(base, message, tool_cache, &version); if let Ok(value) = HeaderValue::from_str(version.as_str()) { - headers.insert(HeaderName::from_static("mcp-protocol-version"), value); + headers.insert(HEADER_NAME_MCP_PROTOCOL_VERSION, value); } (version, headers) } @@ -117,9 +120,8 @@ fn negotiate_version_headers( && let ServerResult::InitializeResult(init_result) = &response.result { version = init_result.protocol_version.clone(); - // HeaderName::from_static requires lowercase if let Ok(hv) = HeaderValue::from_str(init_result.protocol_version.as_str()) { - headers.insert(HeaderName::from_static("mcp-protocol-version"), hv); + headers.insert(HEADER_NAME_MCP_PROTOCOL_VERSION, hv); } } (version, headers) @@ -1137,8 +1139,7 @@ impl Worker for StreamableHttpClientWorker { if inline_version.is_some() { negotiated_version = request_version.clone(); if let Ok(value) = HeaderValue::from_str(request_version.as_str()) { - protocol_headers - .insert(HeaderName::from_static("mcp-protocol-version"), value); + protocol_headers.insert(HEADER_NAME_MCP_PROTOCOL_VERSION, value); } if let Some(cleanup) = &mut session_cleanup_info { cleanup.protocol_headers = protocol_headers.clone(); diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index f1fef585f..33689b433 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -39,8 +39,9 @@ use crate::{ OneshotTransport, TransportAdapterIdentity, common::{ http_header::{ - EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_MCP_PROTOCOL_VERSION, - HEADER_SESSION_ID, JSON_MIME_TYPE, + EVENT_STREAM_MIME_TYPE, HEADER_NAME_LAST_EVENT_ID, + HEADER_NAME_MCP_PROTOCOL_VERSION, HEADER_NAME_SESSION_ID, HEADER_SESSION_ID, + JSON_MIME_TYPE, }, mcp_headers, server_side_http::{ @@ -260,7 +261,7 @@ fn validate_protocol_version_header( headers: &http::HeaderMap, allow_unknown: bool, ) -> Result<(), BoxResponse> { - if let Some(value) = headers.get(HEADER_MCP_PROTOCOL_VERSION) { + if let Some(value) = headers.get(HEADER_NAME_MCP_PROTOCOL_VERSION) { let version_str = value.to_str().map_err(|_| { Response::builder() .status(http::StatusCode::BAD_REQUEST) @@ -396,7 +397,7 @@ fn is_legacy_request( let version = from_body .or_else(|| { headers - .get(HEADER_MCP_PROTOCOL_VERSION) + .get(HEADER_NAME_MCP_PROTOCOL_VERSION) .and_then(|value| value.to_str().ok()) .and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_owned())).ok()) }) @@ -466,7 +467,7 @@ fn validate_header_matches_init_body( body_version: &str, request_id: Option, ) -> Result<(), BoxResponse> { - let Some(header_value) = headers.get(HEADER_MCP_PROTOCOL_VERSION) else { + let Some(header_value) = headers.get(HEADER_NAME_MCP_PROTOCOL_VERSION) else { return Ok(()); }; let header_str = header_value.to_str().map_err(|_| { @@ -508,7 +509,7 @@ fn validate_request_protocol_version_meta( let is_discover = matches!(&request.request, ClientRequest::DiscoverRequest(_)); let meta = request.request.get_meta(); let header_version = headers - .get(HEADER_MCP_PROTOCOL_VERSION) + .get(HEADER_NAME_MCP_PROTOCOL_VERSION) .and_then(|value| value.to_str().ok()); let Some(meta_version) = meta.protocol_version() else { let requires_request_metadata = is_discover @@ -569,7 +570,7 @@ fn validate_required_protocol_header( // Initialize keeps its own header-matching rule. return Ok(()); } - if headers.contains_key(HEADER_MCP_PROTOCOL_VERSION) { + if headers.contains_key(HEADER_NAME_MCP_PROTOCOL_VERSION) { return Ok(()); } Err(header_mismatch_jsonrpc_response( @@ -676,7 +677,7 @@ fn validate_standard_headers( tool_schema: impl Fn(&str) -> Option>, ) -> Result<(), BoxResponse> { let version_requires_headers = headers - .get(HEADER_MCP_PROTOCOL_VERSION) + .get(HEADER_NAME_MCP_PROTOCOL_VERSION) .and_then(|value| value.to_str().ok()) .is_some_and(|version| version >= ProtocolVersion::STANDARD_HEADERS.as_str()); if !version_requires_headers { @@ -1562,7 +1563,7 @@ where if !legacy_request { let Some(last_event_id) = request .headers() - .get(HEADER_LAST_EVENT_ID) + .get(HEADER_NAME_LAST_EVENT_ID) .and_then(|value| value.to_str().ok()) else { return Ok(method_not_allowed_response()); @@ -1586,7 +1587,7 @@ where // check session id let session_id = request .headers() - .get(HEADER_SESSION_ID) + .get(HEADER_NAME_SESSION_ID) .and_then(|v| v.to_str().ok()) .map(|s| s.to_owned().into()); let Some(session_id) = session_id else { @@ -1622,7 +1623,7 @@ where // check if last event id is provided let last_event_id = parts .headers - .get(HEADER_LAST_EVENT_ID) + .get(HEADER_NAME_LAST_EVENT_ID) .and_then(|v| v.to_str().ok()) .map(|s| s.to_owned()); if let Some(last_event_id) = last_event_id { @@ -1730,7 +1731,7 @@ where // do we have a session id? let session_id = part .headers - .get(HEADER_SESSION_ID) + .get(HEADER_NAME_SESSION_ID) .and_then(|v| v.to_str().ok()); if let Some(session_id) = session_id { let session_id = session_id.to_owned().into(); @@ -2048,7 +2049,7 @@ where // check session id let session_id = request .headers() - .get(HEADER_SESSION_ID) + .get(HEADER_NAME_SESSION_ID) .and_then(|v| v.to_str().ok()) .map(|s| s.to_owned().into()); let Some(session_id) = session_id else { @@ -2092,7 +2093,7 @@ where init.params.protocol_version.clone() } else { headers - .get(HEADER_MCP_PROTOCOL_VERSION) + .get(HEADER_NAME_MCP_PROTOCOL_VERSION) .and_then(|v| v.to_str().ok()) .and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_owned())).ok()) .unwrap_or(ProtocolVersion::V_2025_03_26)