Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 61 additions & 8 deletions crates/rmcp/src/transport/common/http_header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?<b64>?=`).
pub const BASE64_HEADER_PREFIX: &str = "=?base64?";
Expand All @@ -29,21 +44,23 @@ 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());
}
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")]
Expand Down Expand Up @@ -74,6 +91,9 @@ pub(crate) fn extract_scope_from_header(header: &str) -> Option<String> {

#[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::*;

Expand Down Expand Up @@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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::*,
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions crates/rmcp/src/transport/common/unix_socket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*,
Expand Down Expand Up @@ -287,7 +288,7 @@ impl StreamableHttpClient for UnixSocketHttpClient {
.and_then(|v| v.parse::<u64>().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());

Expand Down
13 changes: 7 additions & 6 deletions crates/rmcp/src/transport/streamable_http_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
},
};
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1137,8 +1139,7 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
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();
Expand Down
29 changes: 15 additions & 14 deletions crates/rmcp/src/transport/streamable_http_server/tower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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())
})
Expand Down Expand Up @@ -466,7 +467,7 @@ fn validate_header_matches_init_body(
body_version: &str,
request_id: Option<RequestId>,
) -> 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(|_| {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -676,7 +677,7 @@ fn validate_standard_headers(
tool_schema: impl Fn(&str) -> Option<Arc<JsonObject>>,
) -> 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 {
Expand Down Expand Up @@ -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());
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down