Skip to content

Commit 3befb50

Browse files
committed
feat!: validate Mcp-Param-* headers on server
1 parent 1a52592 commit 3befb50

2 files changed

Lines changed: 168 additions & 14 deletions

File tree

crates/rmcp/src/transport/streamable_http_server/tower.rs

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use crate::{
1818
model::{
1919
ClientCapabilities, ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorData,
2020
GetExtensions, Implementation, InitializeRequest, InitializeRequestParams,
21-
InitializedNotification, JsonRpcError, ProtocolVersion, RequestId,
21+
InitializedNotification, JsonObject, JsonRpcError, ProtocolVersion, RequestId,
2222
},
2323
serve_server,
2424
service::serve_directly,
@@ -274,19 +274,20 @@ fn header_mismatch_jsonrpc_response(
274274
.expect("valid response")
275275
}
276276

277-
/// Validates SEP-2243 `Mcp-Method` / `Mcp-Name` headers against the request body.
277+
/// Validates SEP-2243 `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*` headers against the body.
278278
///
279279
/// Only enforced when the request declares a protocol version `>= STANDARD_HEADERS`.
280280
/// The `initialize` handshake is exempt: clients emit these headers only after the
281-
/// version has been negotiated. `Mcp-Param-*` validation is not enforced here because
282-
/// the transport layer has no synchronous access to tool input schemas.
281+
/// version has been negotiated. `tool_schema` supplies the called tool's input schema
282+
/// so annotated `Mcp-Param-*` headers can be checked (no schema => those are skipped).
283283
#[expect(
284284
clippy::result_large_err,
285285
reason = "BoxResponse is intentionally large; matches other handlers in this file"
286286
)]
287287
fn validate_standard_headers(
288288
headers: &HeaderMap,
289289
message: &ClientJsonRpcMessage,
290+
tool_schema: impl Fn(&str) -> Option<Arc<JsonObject>>,
290291
) -> Result<(), BoxResponse> {
291292
let version_requires_headers = headers
292293
.get(HEADER_MCP_PROTOCOL_VERSION)
@@ -310,7 +311,16 @@ fn validate_standard_headers(
310311
let Ok(value) = serde_json::to_value(message) else {
311312
return Ok(());
312313
};
313-
if let Err(reason) = mcp_headers::validate_request_headers(headers, &value, None) {
314+
// For tools/call, look up the tool schema so Mcp-Param-* headers are validated.
315+
let schema = value
316+
.get("method")
317+
.and_then(|method| method.as_str())
318+
.filter(|method| *method == "tools/call")
319+
.and_then(|_| value.get("params"))
320+
.and_then(|params| params.get("name"))
321+
.and_then(|name| name.as_str())
322+
.and_then(tool_schema);
323+
if let Err(reason) = mcp_headers::validate_request_headers(headers, &value, schema.as_deref()) {
314324
return Err(header_mismatch_jsonrpc_response(request_id, reason));
315325
}
316326
Ok(())
@@ -611,6 +621,10 @@ pub struct StreamableHttpService<S, M> {
611621
pending_restores: Option<
612622
Arc<tokio::sync::RwLock<HashMap<SessionId, tokio::sync::watch::Sender<Option<bool>>>>>,
613623
>,
624+
/// Caches tool input schemas by name for SEP-2243 `Mcp-Param-*` validation.
625+
/// Populated lazily via `get_tool` so the service factory runs at most once
626+
/// per tool name. `None` value means the tool exposes no schema.
627+
tool_schemas: Arc<std::sync::RwLock<HashMap<String, Option<Arc<JsonObject>>>>>,
614628
}
615629

616630
impl<S, M> Clone for StreamableHttpService<S, M> {
@@ -620,14 +634,15 @@ impl<S, M> Clone for StreamableHttpService<S, M> {
620634
session_manager: self.session_manager.clone(),
621635
service_factory: self.service_factory.clone(),
622636
pending_restores: self.pending_restores.clone(),
637+
tool_schemas: self.tool_schemas.clone(),
623638
}
624639
}
625640
}
626641

627642
impl<RequestBody, S, M> tower_service::Service<Request<RequestBody>> for StreamableHttpService<S, M>
628643
where
629644
RequestBody: Body + Send + 'static,
630-
S: crate::Service<RoleServer> + Send + 'static,
645+
S: crate::ServerHandler + Send + 'static,
631646
M: SessionManager,
632647
RequestBody::Error: Display,
633648
RequestBody::Data: Send + 'static,
@@ -681,7 +696,7 @@ impl Drop for PendingRestoreGuard {
681696

682697
impl<S, M> StreamableHttpService<S, M>
683698
where
684-
S: crate::Service<RoleServer> + Send + 'static,
699+
S: crate::ServerHandler + Send + 'static,
685700
M: SessionManager,
686701
{
687702
pub fn new(
@@ -700,12 +715,33 @@ where
700715
session_manager,
701716
service_factory: Arc::new(service_factory),
702717
pending_restores,
718+
tool_schemas: Arc::new(std::sync::RwLock::new(HashMap::new())),
703719
}
704720
}
705721
fn get_service(&self) -> Result<S, std::io::Error> {
706722
(self.service_factory)()
707723
}
708724

725+
/// Returns the cached input schema for `name`, constructing a service once
726+
/// per name to read its `ServerHandler::get_tool` definition. Used to
727+
/// validate SEP-2243 `Mcp-Param-*` headers against the request body.
728+
fn tool_schema(&self, name: &str) -> Option<Arc<JsonObject>> {
729+
if let Ok(cache) = self.tool_schemas.read() {
730+
if let Some(schema) = cache.get(name) {
731+
return schema.clone();
732+
}
733+
}
734+
let schema = self
735+
.get_service()
736+
.ok()
737+
.and_then(|service| service.get_tool(name))
738+
.map(|tool| tool.input_schema);
739+
if let Ok(mut cache) = self.tool_schemas.write() {
740+
cache.insert(name.to_owned(), schema.clone());
741+
}
742+
schema
743+
}
744+
709745
/// Spawn a task that runs `serve_server` for the given session, waits for
710746
/// it to finish, and then calls `close_session`.
711747
///
@@ -720,7 +756,7 @@ where
720756
transport: M::Transport,
721757
init_done_tx: Option<tokio::sync::oneshot::Sender<()>>,
722758
) where
723-
S: crate::Service<RoleServer> + Send + 'static,
759+
S: crate::ServerHandler + Send + 'static,
724760
M: SessionManager,
725761
{
726762
tokio::spawn(async move {
@@ -763,7 +799,7 @@ where
763799
parts: &http::request::Parts,
764800
) -> Result<bool, std::io::Error>
765801
where
766-
S: crate::Service<RoleServer> + Send + 'static,
802+
S: crate::ServerHandler + Send + 'static,
767803
M: SessionManager,
768804
{
769805
// Both fields are Some iff a session store is configured.
@@ -1140,7 +1176,7 @@ where
11401176
// Validate MCP-Protocol-Version header (per 2025-06-18 spec)
11411177
validate_protocol_version_header(&part.headers)?;
11421178
// Validate SEP-2243 standard headers against the body
1143-
validate_standard_headers(&part.headers, &message)?;
1179+
validate_standard_headers(&part.headers, &message, |name| self.tool_schema(name))?;
11441180

11451181
// inject request part to extensions
11461182
match &mut message {
@@ -1294,7 +1330,7 @@ where
12941330
}
12951331
}
12961332
// Validate SEP-2243 standard headers against the body
1297-
validate_standard_headers(&part.headers, &message)?;
1333+
validate_standard_headers(&part.headers, &message, |name| self.tool_schema(name))?;
12981334
let service = self
12991335
.get_service()
13001336
.map_err(internal_error_response("get service"))?;

crates/rmcp/tests/test_streamable_http_standard_headers.rs

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
#![cfg(not(feature = "local"))]
2-
//! SEP-2243 server-side validation of `Mcp-Method` / `Mcp-Name` headers.
3-
use rmcp::transport::streamable_http_server::{
4-
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
2+
//! SEP-2243 server-side validation of `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*` headers.
3+
use std::sync::Arc;
4+
5+
use rmcp::{
6+
ServerHandler,
7+
model::{ServerCapabilities, ServerInfo, Tool},
8+
transport::streamable_http_server::{
9+
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
10+
},
511
};
612
use tokio_util::sync::CancellationToken;
713

@@ -145,3 +151,115 @@ async fn skips_validation_for_pre_sep_version() -> anyhow::Result<()> {
145151
ct.cancel();
146152
Ok(())
147153
}
154+
155+
/// Server exposing one tool whose `region` argument is promoted to `Mcp-Param-Region`.
156+
#[derive(Clone, Default)]
157+
struct ParamHeaderServer;
158+
159+
impl ServerHandler for ParamHeaderServer {
160+
fn get_info(&self) -> ServerInfo {
161+
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
162+
}
163+
164+
fn get_tool(&self, name: &str) -> Option<Tool> {
165+
if name != "deploy" {
166+
return None;
167+
}
168+
let schema = serde_json::json!({
169+
"type": "object",
170+
"properties": { "region": { "type": "string", "x-mcp-header": "Region" } }
171+
});
172+
let schema = schema.as_object().expect("object schema").clone();
173+
Some(Tool::new("deploy", "deploy a thing", Arc::new(schema)))
174+
}
175+
}
176+
177+
async fn spawn_param_server() -> (reqwest::Client, String, CancellationToken) {
178+
let config = StreamableHttpServerConfig::default()
179+
.with_stateful_mode(false)
180+
.with_json_response(true)
181+
.with_sse_keep_alive(None)
182+
.with_cancellation_token(CancellationToken::new());
183+
let ct = config.cancellation_token.clone();
184+
let service: StreamableHttpService<ParamHeaderServer, LocalSessionManager> =
185+
StreamableHttpService::new(|| Ok(ParamHeaderServer), Default::default(), config);
186+
187+
let router = axum::Router::new().nest_service("/mcp", service);
188+
let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
189+
let addr = tcp_listener.local_addr().unwrap();
190+
tokio::spawn({
191+
let ct = ct.clone();
192+
async move {
193+
let _ = axum::serve(tcp_listener, router)
194+
.with_graceful_shutdown(async move { ct.cancelled_owned().await })
195+
.await;
196+
}
197+
});
198+
(reqwest::Client::new(), format!("http://{addr}/mcp"), ct)
199+
}
200+
201+
/// POSTs a `deploy` tools/call with a `region` argument and optional `Mcp-Param-Region` header.
202+
async fn post_deploy(
203+
client: &reqwest::Client,
204+
url: &str,
205+
region_arg: &str,
206+
param_region: Option<&str>,
207+
) -> reqwest::Response {
208+
let body = format!(
209+
r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"deploy","arguments":{{"region":"{region_arg}"}}}}}}"#
210+
);
211+
let mut req = client
212+
.post(url)
213+
.header("Content-Type", "application/json")
214+
.header("Accept", "application/json, text/event-stream")
215+
.header("MCP-Protocol-Version", SEP_VERSION)
216+
.header("Mcp-Method", "tools/call")
217+
.header("Mcp-Name", "deploy")
218+
.body(body);
219+
if let Some(region) = param_region {
220+
req = req.header("Mcp-Param-Region", region);
221+
}
222+
req.send().await.expect("send deploy request")
223+
}
224+
225+
#[tokio::test]
226+
async fn accepts_matching_param_header() -> anyhow::Result<()> {
227+
let (client, url, ct) = spawn_param_server().await;
228+
229+
let response = post_deploy(&client, &url, "us-west1", Some("us-west1")).await;
230+
let body: serde_json::Value = response.json().await?;
231+
assert_ne!(
232+
body["error"]["code"], -32001,
233+
"matching Mcp-Param-* must not be rejected, got: {body}"
234+
);
235+
236+
ct.cancel();
237+
Ok(())
238+
}
239+
240+
#[tokio::test]
241+
async fn rejects_param_mismatch_with_32001() -> anyhow::Result<()> {
242+
let (client, url, ct) = spawn_param_server().await;
243+
244+
let response = post_deploy(&client, &url, "us-west1", Some("eu-central1")).await;
245+
assert_eq!(response.status(), 400);
246+
let body: serde_json::Value = response.json().await?;
247+
assert_eq!(body["error"]["code"], -32001);
248+
249+
ct.cancel();
250+
Ok(())
251+
}
252+
253+
#[tokio::test]
254+
async fn rejects_missing_param_header_with_32001() -> anyhow::Result<()> {
255+
let (client, url, ct) = spawn_param_server().await;
256+
257+
// `region` argument is present but the annotated `Mcp-Param-Region` header is absent.
258+
let response = post_deploy(&client, &url, "us-west1", None).await;
259+
assert_eq!(response.status(), 400);
260+
let body: serde_json::Value = response.json().await?;
261+
assert_eq!(body["error"]["code"], -32001);
262+
263+
ct.cancel();
264+
Ok(())
265+
}

0 commit comments

Comments
 (0)