feat: send and validate MCP-Protocol-Version header (#675)

This commit is contained in:
Dale Seo 2026-02-24 11:08:49 -05:00 • committed by GitHub
commit 5fa012d163
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -190,22 +190,23 @@ async fn test_post_message_rejects_mcp_session_id() {
}
}
/// Unit test: post_message should reject reserved header "mcp-protocol-version"
/// Unit test: post_message should allow the mcp-protocol-version header through
/// (it is injected by the worker after initialization, not a user-settable custom header)
#[tokio::test]
#[cfg(feature = "transport-streamable-http-client-reqwest")]
async fn test_post_message_rejects_mcp_protocol_version() {
async fn test_post_message_allows_mcp_protocol_version() {
use std::sync::Arc;
use rmcp::{
model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId},
transport::streamable_http_client::{StreamableHttpClient, StreamableHttpError},
transport::streamable_http_client::StreamableHttpClient,
};
let client = reqwest::Client::new();
let mut custom_headers = HashMap::new();
custom_headers.insert(
HeaderName::from_static("mcp-protocol-version"),
HeaderValue::from_static("1.0"),
HeaderValue::from_static("2025-03-26"),
);
let message = ClientJsonRpcMessage::request(
@ -223,19 +224,20 @@ async fn test_post_message_rejects_mcp_protocol_version() {
)
.await;
// The header should be allowed through (not rejected as reserved).
// The error should be a connection error (no server at localhost:9999),
// not a ReservedHeaderConflict.
assert!(result.is_err(), "Should fail due to connection error");
assert!(
result.is_err(),
"Should reject 'mcp-protocol-version' header"
!matches!(
&result,
Err(rmcp::transport::streamable_http_client::StreamableHttpError::ReservedHeaderConflict(
_
))
),
"MCP-Protocol-Version should not be rejected as reserved, got: {:?}",
result
);
match result {
Err(StreamableHttpError::ReservedHeaderConflict(header_name)) => {
assert_eq!(
header_name, "mcp-protocol-version",
"Error should indicate 'mcp-protocol-version' header"
);
}
other => panic!("Expected ReservedHeaderConflict error, got: {:?}", other),
}
}
/// Unit test: post_message should reject reserved header "last-event-id"
@ -529,3 +531,344 @@ async fn test_mcp_custom_headers_sent_to_server() -> anyhow::Result<()> {
Ok(())
}
/// Integration test: Verify that MCP-Protocol-Version header is sent on post-init requests
#[tokio::test]
#[cfg(all(
feature = "transport-streamable-http-client",
feature = "transport-streamable-http-client-reqwest"
))]
async fn test_mcp_protocol_version_header_sent_after_init() -> anyhow::Result<()> {
use std::{net::SocketAddr, sync::Arc};
use axum::{
Router, body::Bytes, extract::State, http::StatusCode, response::IntoResponse,
routing::post,
};
use rmcp::{
ServiceExt,
transport::{
StreamableHttpClientTransport,
streamable_http_client::StreamableHttpClientTransportConfig,
},
};
use serde_json::json;
use tokio::sync::Mutex;
type CapturedRequests = Vec<(String, Option<String>)>;
#[derive(Clone)]
struct ServerState {
/// Captures the MCP-Protocol-Version header value for each request method
protocol_version_by_method: Arc<Mutex<CapturedRequests>>,
initialized_called: Arc<tokio::sync::Notify>,
}
async fn mcp_handler(
State(state): State<ServerState>,
headers: http::HeaderMap,
body: Bytes,
) -> impl IntoResponse {
let protocol_version = headers
.get("mcp-protocol-version")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
if let Ok(json_body) = serde_json::from_slice::<serde_json::Value>(&body) {
let method = json_body
.get("method")
.and_then(|m| m.as_str())
.unwrap_or("unknown")
.to_string();
state
.protocol_version_by_method
.lock()
.await
.push((method.clone(), protocol_version));
if method == "initialize" {
let response = json!({
"jsonrpc": "2.0",
"id": json_body.get("id"),
"result": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"serverInfo": {
"name": "test-server",
"version": "1.0.0"
}
}
});
return (
StatusCode::OK,
[
(http::header::CONTENT_TYPE, "application/json"),
(
http::HeaderName::from_static("mcp-session-id"),
"test-session-456",
),
],
response.to_string(),
);
} else if method == "notifications/initialized" {
state.initialized_called.notify_one();
return (
StatusCode::ACCEPTED,
[
(http::header::CONTENT_TYPE, "application/json"),
(
http::HeaderName::from_static("mcp-session-id"),
"test-session-456",
),
],
String::new(),
);
}
}
let response = json!({
"jsonrpc": "2.0",
"id": 1,
"result": {}
});
(
StatusCode::OK,
[
(http::header::CONTENT_TYPE, "application/json"),
(
http::HeaderName::from_static("mcp-session-id"),
"test-session-456",
),
],
response.to_string(),
)
}
let state = ServerState {
protocol_version_by_method: Arc::new(Mutex::new(Vec::new())),
initialized_called: Arc::new(tokio::sync::Notify::new()),
};
let app = Router::new()
.route("/mcp", post(mcp_handler))
.with_state(state.clone());
let addr = SocketAddr::from(([127, 0, 0, 1], 0));
let listener = tokio::net::TcpListener::bind(addr).await?;
let port = listener.local_addr()?.port();
let server_handle = tokio::spawn(async move { axum::serve(listener, app).await });
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let config =
StreamableHttpClientTransportConfig::with_uri(format!("http://127.0.0.1:{}/mcp", port));
let transport = StreamableHttpClientTransport::from_config(config);
let client = ().serve(transport).await.expect("Failed to start client");
tokio::time::timeout(
std::time::Duration::from_secs(5),
state.initialized_called.notified(),
)
.await
.expect("Initialized notification should be received");
// Give time for the initialized notification to be fully processed
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let captured = state.protocol_version_by_method.lock().await;
// The initialize request should NOT have MCP-Protocol-Version
// (the version isn't known yet)
let init_entry = captured
.iter()
.find(|(m, _)| m == "initialize")
.expect("Should have captured initialize request");
assert_eq!(
init_entry.1, None,
"Initialize request should not have MCP-Protocol-Version header"
);
// The initialized notification should HAVE MCP-Protocol-Version
let initialized_entry = captured
.iter()
.find(|(m, _)| m == "notifications/initialized")
.expect("Should have captured initialized notification");
assert_eq!(
initialized_entry.1,
Some("2025-03-26".to_string()),
"Initialized notification should include MCP-Protocol-Version: 2025-03-26"
);
drop(client);
server_handle.abort();
Ok(())
}
/// Integration test: Verify server rejects unsupported MCP-Protocol-Version with 400
#[tokio::test]
#[cfg(all(feature = "transport-streamable-http-server", feature = "server",))]
async fn test_server_rejects_unsupported_protocol_version() {
use std::sync::Arc;
use bytes::Bytes;
use http::{Method, Request, header::CONTENT_TYPE};
use http_body_util::Full;
use rmcp::{
handler::server::ServerHandler,
model::{ServerCapabilities, ServerInfo},
transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
},
};
use serde_json::json;
#[derive(Clone)]
struct TestHandler;
impl ServerHandler for TestHandler {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder().build(),
..Default::default()
}
}
}
let session_manager = Arc::new(LocalSessionManager::default());
let service = StreamableHttpService::new(
|| Ok(TestHandler),
session_manager,
StreamableHttpServerConfig::default(),
);
// First, send an initialize request to create a session
let init_body = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
}
});
let init_request = Request::builder()
.method(Method::POST)
.header("Accept", "application/json, text/event-stream")
.header(CONTENT_TYPE, "application/json")
.body(Full::new(Bytes::from(init_body.to_string())))
.unwrap();
let response = service.handle(init_request).await;
assert_eq!(response.status(), http::StatusCode::OK);
// Extract session id from response
let session_id = response
.headers()
.get("mcp-session-id")
.expect("Should have session id")
.to_str()
.unwrap()
.to_string();
// Send initialized notification to complete handshake
let initialized_body = json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
});
let initialized_request = Request::builder()
.method(Method::POST)
.header("Accept", "application/json, text/event-stream")
.header(CONTENT_TYPE, "application/json")
.header("mcp-session-id", &session_id)
.header("mcp-protocol-version", "2025-03-26")
.body(Full::new(Bytes::from(initialized_body.to_string())))
.unwrap();
let response = service.handle(initialized_request).await;
assert_eq!(response.status(), http::StatusCode::ACCEPTED);
// Test 1: Valid protocol version should succeed
let valid_body = json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
});
let valid_request = Request::builder()
.method(Method::POST)
.header("Accept", "application/json, text/event-stream")
.header(CONTENT_TYPE, "application/json")
.header("mcp-session-id", &session_id)
.header("mcp-protocol-version", "2025-03-26")
.body(Full::new(Bytes::from(valid_body.to_string())))
.unwrap();
let response = service.handle(valid_request).await;
assert_eq!(
response.status(),
http::StatusCode::ACCEPTED,
"Valid MCP-Protocol-Version should be accepted"
);
// Test 2: Unsupported protocol version should return 400
let invalid_body = json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
});
let invalid_request = Request::builder()
.method(Method::POST)
.header("Accept", "application/json, text/event-stream")
.header(CONTENT_TYPE, "application/json")
.header("mcp-session-id", &session_id)
.header("mcp-protocol-version", "9999-01-01")
.body(Full::new(Bytes::from(invalid_body.to_string())))
.unwrap();
let response = service.handle(invalid_request).await;
assert_eq!(
response.status(),
http::StatusCode::BAD_REQUEST,
"Unsupported MCP-Protocol-Version should return 400"
);
// Test 3: Missing protocol version should succeed (backwards compat)
let no_version_body = json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
});
let no_version_request = Request::builder()
.method(Method::POST)
.header("Accept", "application/json, text/event-stream")
.header(CONTENT_TYPE, "application/json")
.header("mcp-session-id", &session_id)
.body(Full::new(Bytes::from(no_version_body.to_string())))
.unwrap();
let response = service.handle(no_version_request).await;
assert_eq!(
response.status(),
http::StatusCode::ACCEPTED,
"Missing MCP-Protocol-Version should be accepted (backwards compat)"
);
}
/// Unit test: ProtocolVersion::as_str and KNOWN_VERSIONS
#[test]
fn test_protocol_version_utilities() {
use rmcp::model::ProtocolVersion;
assert_eq!(ProtocolVersion::V_2025_06_18.as_str(), "2025-06-18");
assert_eq!(ProtocolVersion::V_2025_03_26.as_str(), "2025-03-26");
assert_eq!(ProtocolVersion::V_2024_11_05.as_str(), "2024-11-05");
assert_eq!(ProtocolVersion::KNOWN_VERSIONS.len(), 3);
assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2024_11_05));
assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_03_26));
assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_06_18));
}