Skip to content
Merged
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
22 changes: 15 additions & 7 deletions crates/rmcp/src/transport/streamable_http_server/tower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,19 +460,27 @@ fn validate_request_protocol_version_meta(
return Ok(());
}
let is_discover = matches!(&request.request, ClientRequest::DiscoverRequest(_));
let Some(meta_version) = request.request.get_meta().protocol_version() else {
if is_discover {
let meta = request.request.get_meta();
let header_version = headers
.get(HEADER_MCP_PROTOCOL_VERSION)
.and_then(|value| value.to_str().ok());
let Some(meta_version) = meta.protocol_version() else {
let requires_request_metadata = is_discover
|| header_version
.is_some_and(|version| version >= ProtocolVersion::V_2026_07_28.as_str());
if requires_request_metadata {
let missing = meta.missing_required_keys(&ProtocolVersion::V_2026_07_28);
return Err(invalid_params_jsonrpc_response(
Some(request.id.clone()),
"Invalid params: server/discover requires protocolVersion in request _meta",
format!(
"Invalid params: request _meta is missing or has malformed required fields: {}",
missing.join(", ")
),
));
}
return Ok(());
};
let Some(header_version) = headers
.get(HEADER_MCP_PROTOCOL_VERSION)
.and_then(|value| value.to_str().ok())
else {
let Some(header_version) = header_version else {
return Err(header_mismatch_jsonrpc_response(
Some(request.id.clone()),
"request _meta protocolVersion requires MCP-Protocol-Version header",
Expand Down
165 changes: 164 additions & 1 deletion crates/rmcp/tests/test_streamable_http_protocol_version.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
#![cfg(not(feature = "local"))]
//! Regression tests for the `MCP-Protocol-Version` header / initialize body consistency check.
//! Streamable HTTP protocol-version and request-metadata validation tests.
use std::sync::Arc;

use rmcp::transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
};
use serde_json::{Value, json};
use tokio_util::sync::CancellationToken;

mod common;
Expand Down Expand Up @@ -91,6 +92,31 @@ async fn post_non_initialize(client: &reqwest::Client, url: &str) -> reqwest::Re
.expect("send non-initialize request")
}

async fn post_modern_request(
client: &reqwest::Client,
url: &str,
method: &str,
name: Option<&str>,
params: Value,
) -> reqwest::Response {
let mut request = client
.post(url)
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("MCP-Protocol-Version", "2026-07-28")
.header("Mcp-Method", method)
.json(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
}));
if let Some(name) = name {
request = request.header("Mcp-Name", name);
}
request.send().await.expect("send modern request")
}

#[tokio::test]
async fn stateless_init_rejects_when_header_older_than_body() -> anyhow::Result<()> {
let (client, url, ct) = spawn_server(stateless_json_config()).await;
Expand Down Expand Up @@ -217,3 +243,140 @@ async fn stateless_missing_protocol_header_returns_header_mismatch() -> anyhow::
ct.cancel();
Ok(())
}

#[tokio::test]
async fn stateless_tools_list_rejects_missing_request_meta() {
let (client, url, ct) = spawn_server(stateless_json_config()).await;

let response = post_modern_request(&client, &url, "tools/list", None, json!({})).await;

assert_eq!(response.status(), 400);
let body: Value = response.json().await.expect("response should be JSON");
assert_eq!(body["error"]["code"], -32602);
assert!(
body["error"]["message"]
.as_str()
.is_some_and(|message| message.contains("io.modelcontextprotocol/protocolVersion")),
"expected error message to mention protocolVersion, got: {body}"
);
ct.cancel();
}

#[tokio::test]
async fn stateless_tools_call_rejects_missing_request_meta() {
let (client, url, ct) = spawn_server(stateless_json_config()).await;

let response = post_modern_request(
&client,
&url,
"tools/call",
Some("sum"),
json!({
"name": "sum",
"arguments": {
"a": 1,
"b": 2
}
}),
)
.await;

assert_eq!(response.status(), 400);
let body: Value = response.json().await.expect("response should be JSON");
assert_eq!(body["error"]["code"], -32602);
assert!(
body["error"]["message"]
.as_str()
.is_some_and(|message| message.contains("io.modelcontextprotocol/protocolVersion")),
"expected error message to mention protocolVersion, got: {body}"
);
ct.cancel();
}

#[tokio::test]
async fn stateless_request_rejects_missing_meta_protocol_version() {
let (client, url, ct) = spawn_server(stateless_json_config()).await;

let response = post_modern_request(
&client,
&url,
"tools/list",
None,
json!({
"_meta": {
"io.modelcontextprotocol/clientInfo": {
"name": "test",
"version": "1.0"
},
"io.modelcontextprotocol/clientCapabilities": {}
}
}),
)
.await;

assert_eq!(response.status(), 400);
let body: Value = response.json().await.expect("response should be JSON");
assert_eq!(body["error"]["code"], -32602);
assert!(
body["error"]["message"]
.as_str()
.is_some_and(|message| message.contains("io.modelcontextprotocol/protocolVersion")),
"expected error message to mention protocolVersion, got: {body}"
);
ct.cancel();
}

#[tokio::test]
async fn stateless_request_rejects_missing_meta_client_capabilities() {
let (client, url, ct) = spawn_server(stateless_json_config()).await;

let response = post_modern_request(
&client,
&url,
"tools/list",
None,
json!({
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "test",
"version": "1.0"
}
}
}),
)
.await;

assert_eq!(response.status(), 400);
let body: Value = response.json().await.expect("response should be JSON");
assert_eq!(body["error"]["code"], -32602);
assert!(
body["error"]["message"]
.as_str()
.is_some_and(|message| message.contains("io.modelcontextprotocol/clientCapabilities")),
"expected error message to mention clientCapabilities, got: {body}"
);
ct.cancel();
}

#[tokio::test]
async fn stateless_request_accepts_missing_optional_meta_client_info() {
let (client, url, ct) = spawn_server(stateless_json_config()).await;

let response = post_modern_request(
&client,
&url,
"tools/list",
None,
json!({
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}),
)
.await;

assert_eq!(response.status(), 200);
ct.cancel();
}