feat: add strict stateless protocol metadata validation (#1091)
* feat: add strict stateless protocol metadata validation * fix: clarify strict metadata compatibility boundaries --------- Co-authored-by: mrcs64 <9069178+mrcs64@users.noreply.github.com>
This commit is contained in:
parent
58b136f44f
commit
983a1374ff
3 changed files with 596 additions and 3 deletions
|
|
@ -1462,6 +1462,13 @@ let router = axum::Router::new().nest_service("/mcp", service);
|
||||||
> Because there is no per-session state, the `service_factory` runs per request.
|
> Because there is no per-session state, the `service_factory` runs per request.
|
||||||
> Keep shared state (DB pools, caches) in a `Clone` handle captured by the
|
> Keep shared state (DB pools, caches) in a `Clone` handle captured by the
|
||||||
> closure; don't rely on in-memory state surviving between requests.
|
> closure; don't rely on in-memory state surviving between requests.
|
||||||
|
>
|
||||||
|
> Modern-only servers can additionally call
|
||||||
|
> `with_stateless_protocol_metadata_required(true)` to reject the compatibility
|
||||||
|
> fallback for requests missing their per-request protocol signals. rmcp clients
|
||||||
|
> negotiated below `2026-07-28` do not attach that body metadata and will be
|
||||||
|
> rejected, so pair this option with a `supported_protocol_versions`
|
||||||
|
> implementation that advertises only `2026-07-28` and later.
|
||||||
|
|
||||||
### Client-side
|
### Client-side
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,32 @@ pub struct StreamableHttpServerConfig {
|
||||||
/// chunked transfer encoding, or HTTP version. Oversized payloads receive
|
/// chunked transfer encoding, or HTTP version. Oversized payloads receive
|
||||||
/// a `413 Payload Too Large` response.
|
/// a `413 Payload Too Large` response.
|
||||||
pub max_request_body_bytes: usize,
|
pub max_request_body_bytes: usize,
|
||||||
|
/// Require stateless JSON-RPC request POSTs to carry per-request protocol
|
||||||
|
/// signals before handler dispatch.
|
||||||
|
///
|
||||||
|
/// Non-initialize requests must carry `MCP-Protocol-Version`; ordinary
|
||||||
|
/// non-discovery requests must also carry
|
||||||
|
/// `_meta.io.modelcontextprotocol/protocolVersion`. `server/discover`
|
||||||
|
/// retains its existing request-metadata validation. For `2026-07-28`
|
||||||
|
/// requests, the server handler continues to require the remaining
|
||||||
|
/// per-request metadata, including `clientCapabilities`. Initialize,
|
||||||
|
/// notifications, and other message kinds retain their existing rules.
|
||||||
|
///
|
||||||
|
/// This option applies to requests routed statelessly. Set
|
||||||
|
/// `legacy_session_mode` to `false` to ensure every request uses that path.
|
||||||
|
/// Legacy session routing and its error precedence remain unchanged.
|
||||||
|
///
|
||||||
|
/// The validator checks metadata presence rather than applying a version
|
||||||
|
/// allowlist. However, rmcp clients negotiated below `2026-07-28` do not
|
||||||
|
/// attach per-request protocol metadata, so enabling this option rejects
|
||||||
|
/// their ordinary requests. Servers using this option should normally
|
||||||
|
/// override
|
||||||
|
/// [`ServerHandler::supported_protocol_versions`](crate::ServerHandler::supported_protocol_versions)
|
||||||
|
/// to advertise only `2026-07-28` and later.
|
||||||
|
///
|
||||||
|
/// Default is `false`, preserving today's legacy behavior where an absent
|
||||||
|
/// header is treated as protocol version `2025-03-26`.
|
||||||
|
pub stateless_protocol_metadata_required: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for dyn SessionStore {
|
impl std::fmt::Debug for dyn SessionStore {
|
||||||
|
|
@ -147,6 +173,7 @@ impl Default for StreamableHttpServerConfig {
|
||||||
allowed_origins: vec![],
|
allowed_origins: vec![],
|
||||||
session_store: None,
|
session_store: None,
|
||||||
max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES,
|
max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES,
|
||||||
|
stateless_protocol_metadata_required: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -206,6 +233,18 @@ impl StreamableHttpServerConfig {
|
||||||
self.max_request_body_bytes = bytes;
|
self.max_request_body_bytes = bytes;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Require per-request protocol signals on stateless JSON-RPC request
|
||||||
|
/// POSTs.
|
||||||
|
///
|
||||||
|
/// See [`StreamableHttpServerConfig::stateless_protocol_metadata_required`].
|
||||||
|
pub fn with_stateless_protocol_metadata_required(
|
||||||
|
mut self,
|
||||||
|
stateless_protocol_metadata_required: bool,
|
||||||
|
) -> Self {
|
||||||
|
self.stateless_protocol_metadata_required = stateless_protocol_metadata_required;
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(
|
#[expect(
|
||||||
|
|
@ -504,6 +543,77 @@ fn validate_request_protocol_version_meta(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When `stateless_protocol_metadata_required` is enabled in stateless mode,
|
||||||
|
/// every non-initialize Streamable HTTP JSON-RPC request POST must carry the
|
||||||
|
/// `MCP-Protocol-Version` HTTP header. A missing header is rejected with
|
||||||
|
/// HTTP 400 / JSON-RPC `-32020` before handler dispatch. `server/discover`
|
||||||
|
/// is included so the seam aligns with the per-POST header contract; its
|
||||||
|
/// body-metadata rule is preserved unchanged.
|
||||||
|
#[expect(
|
||||||
|
clippy::result_large_err,
|
||||||
|
reason = "BoxResponse is intentionally large; matches other handlers in this file"
|
||||||
|
)]
|
||||||
|
fn validate_required_protocol_header(
|
||||||
|
config: &StreamableHttpServerConfig,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
message: &ClientJsonRpcMessage,
|
||||||
|
) -> Result<(), BoxResponse> {
|
||||||
|
if !config.stateless_protocol_metadata_required {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let ClientJsonRpcMessage::Request(request) = message else {
|
||||||
|
// Notifications, response messages, and error messages are exempt.
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if matches!(&request.request, ClientRequest::InitializeRequest(_)) {
|
||||||
|
// Initialize keeps its own header-matching rule.
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if headers.contains_key(HEADER_MCP_PROTOCOL_VERSION) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(header_mismatch_jsonrpc_response(
|
||||||
|
Some(request.id.clone()),
|
||||||
|
"Missing MCP-Protocol-Version header for request requiring per-request protocol metadata",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When `stateless_protocol_metadata_required` is enabled in stateless mode,
|
||||||
|
/// every non-initialize, non-discover Streamable HTTP JSON-RPC request must
|
||||||
|
/// carry `io.modelcontextprotocol/protocolVersion` in `_meta`. A missing entry
|
||||||
|
/// is rejected with HTTP 400 / JSON-RPC `-32602` (invalid_params). `initialize`,
|
||||||
|
/// `server/discover` (whose body-metadata rule is already enforced by
|
||||||
|
/// `validate_request_protocol_version_meta`), notifications, and other message
|
||||||
|
/// kinds are exempt.
|
||||||
|
#[expect(
|
||||||
|
clippy::result_large_err,
|
||||||
|
reason = "BoxResponse is intentionally large; matches other handlers in this file"
|
||||||
|
)]
|
||||||
|
fn validate_required_protocol_meta(
|
||||||
|
config: &StreamableHttpServerConfig,
|
||||||
|
message: &ClientJsonRpcMessage,
|
||||||
|
) -> Result<(), BoxResponse> {
|
||||||
|
if !config.stateless_protocol_metadata_required {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let ClientJsonRpcMessage::Request(request) = message else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if matches!(
|
||||||
|
&request.request,
|
||||||
|
ClientRequest::InitializeRequest(_) | ClientRequest::DiscoverRequest(_)
|
||||||
|
) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if request.request.get_meta().protocol_version().is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(invalid_params_jsonrpc_response(
|
||||||
|
Some(request.id.clone()),
|
||||||
|
"Invalid params: request requires protocolVersion in request _meta",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn jsonrpc_http_status(message: &ServerJsonRpcMessage) -> http::StatusCode {
|
fn jsonrpc_http_status(message: &ServerJsonRpcMessage) -> http::StatusCode {
|
||||||
let ServerJsonRpcMessage::Error(error) = message else {
|
let ServerJsonRpcMessage::Error(error) = message else {
|
||||||
return http::StatusCode::OK;
|
return http::StatusCode::OK;
|
||||||
|
|
@ -1809,6 +1919,10 @@ where
|
||||||
// Stateless mode:
|
// Stateless mode:
|
||||||
// - on initialize: the header (if present) must match `params.protocolVersion`
|
// - on initialize: the header (if present) must match `params.protocolVersion`
|
||||||
// - on every other request: the header must name a known version.
|
// - on every other request: the header must name a known version.
|
||||||
|
//
|
||||||
|
// The opt-in seam applies only here so legacy session routing and
|
||||||
|
// its error precedence remain unchanged.
|
||||||
|
validate_required_protocol_header(&self.config, &part.headers, &message)?;
|
||||||
let has_per_request_version = message_has_per_request_protocol_version(&message);
|
let has_per_request_version = message_has_per_request_protocol_version(&message);
|
||||||
match &message {
|
match &message {
|
||||||
ClientJsonRpcMessage::Request(req) => {
|
ClientJsonRpcMessage::Request(req) => {
|
||||||
|
|
@ -1829,6 +1943,7 @@ where
|
||||||
// Validate SEP-2243 standard headers against the body
|
// Validate SEP-2243 standard headers against the body
|
||||||
validate_standard_headers(&part.headers, &message, |name| self.tool_schema(name))?;
|
validate_standard_headers(&part.headers, &message, |name| self.tool_schema(name))?;
|
||||||
validate_request_protocol_version_meta(&part.headers, &message)?;
|
validate_request_protocol_version_meta(&part.headers, &message)?;
|
||||||
|
validate_required_protocol_meta(&self.config, &message)?;
|
||||||
let service = self
|
let service = self
|
||||||
.get_service()
|
.get_service()
|
||||||
.map_err(internal_error_response("get service"))?;
|
.map_err(internal_error_response("get service"))?;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
#![cfg(not(feature = "local"))]
|
#![cfg(not(feature = "local"))]
|
||||||
//! Streamable HTTP protocol-version and request-metadata validation tests.
|
//! Streamable HTTP protocol-version and request-metadata validation tests.
|
||||||
use std::sync::Arc;
|
use std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicUsize, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
use rmcp::transport::streamable_http_server::{
|
use rmcp::{
|
||||||
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
|
ServerHandler,
|
||||||
|
transport::streamable_http_server::{
|
||||||
|
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
@ -380,3 +386,468 @@ async fn stateless_request_accepts_missing_optional_meta_client_info() {
|
||||||
assert_eq!(response.status(), 200);
|
assert_eq!(response.status(), 200);
|
||||||
ct.cancel();
|
ct.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Opt-in seam: `with_stateless_protocol_metadata_required(true)`
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// In stateless mode, every non-initialize Streamable HTTP JSON-RPC request
|
||||||
|
// POST must carry the `MCP-Protocol-Version` HTTP header (missing → HTTP 400 /
|
||||||
|
// JSON-RPC `-32020` HeaderMismatch before dispatch). Every non-initialize,
|
||||||
|
// non-discover request must additionally carry a per-request
|
||||||
|
// `io.modelcontextprotocol/protocolVersion` in `_meta` (missing → HTTP 400 /
|
||||||
|
// JSON-RPC `-32602`). The existing rule that requires `server/discover` to
|
||||||
|
// carry `_meta.protocolVersion` is preserved unchanged.
|
||||||
|
//
|
||||||
|
// Non-dispatch is proven with an explicit invocation counter (`AtomicUsize`).
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct CountingServer {
|
||||||
|
lists: Arc<AtomicUsize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CountingServer {
|
||||||
|
fn new() -> (Self, Arc<AtomicUsize>) {
|
||||||
|
let lists = Arc::new(AtomicUsize::new(0));
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
lists: lists.clone(),
|
||||||
|
},
|
||||||
|
lists,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerHandler for CountingServer {
|
||||||
|
fn get_info(&self) -> rmcp::model::ServerInfo {
|
||||||
|
rmcp::model::ServerInfo::new(
|
||||||
|
rmcp::model::ServerCapabilities::builder()
|
||||||
|
.enable_tools()
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_tools(
|
||||||
|
&self,
|
||||||
|
_request: Option<rmcp::model::PaginatedRequestParams>,
|
||||||
|
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
|
||||||
|
) -> impl std::future::Future<Output = Result<rmcp::model::ListToolsResult, rmcp::ErrorData>>
|
||||||
|
+ Send
|
||||||
|
+ '_ {
|
||||||
|
self.lists.fetch_add(1, Ordering::SeqCst);
|
||||||
|
std::future::ready(Ok(rmcp::model::ListToolsResult::default()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_counting(
|
||||||
|
config: StreamableHttpServerConfig,
|
||||||
|
) -> (reqwest::Client, String, CancellationToken, Arc<AtomicUsize>) {
|
||||||
|
let ct = config.cancellation_token.clone();
|
||||||
|
let (server, lists) = CountingServer::new();
|
||||||
|
let service: StreamableHttpService<CountingServer, LocalSessionManager> =
|
||||||
|
StreamableHttpService::new(move || Ok(server.clone()), Default::default(), config);
|
||||||
|
|
||||||
|
let router = axum::Router::new().nest_service("/mcp", service);
|
||||||
|
let tcp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = tcp_listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
tokio::spawn({
|
||||||
|
let ct = ct.clone();
|
||||||
|
async move {
|
||||||
|
let _ = axum::serve(tcp_listener, router)
|
||||||
|
.with_graceful_shutdown(async move { ct.cancelled_owned().await })
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
(
|
||||||
|
reqwest::Client::new(),
|
||||||
|
format!("http://{addr}/mcp"),
|
||||||
|
ct,
|
||||||
|
lists,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn modern_required_config() -> StreamableHttpServerConfig {
|
||||||
|
StreamableHttpServerConfig::default()
|
||||||
|
.with_legacy_session_mode(false)
|
||||||
|
.with_json_response(true)
|
||||||
|
.with_sse_keep_alive(None)
|
||||||
|
.with_stateless_protocol_metadata_required(true)
|
||||||
|
.with_cancellation_token(CancellationToken::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_seam(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
url: &str,
|
||||||
|
body: &str,
|
||||||
|
header_version: Option<&str>,
|
||||||
|
extra_headers: &[(&str, &str)],
|
||||||
|
) -> reqwest::Response {
|
||||||
|
let mut req = client
|
||||||
|
.post(url)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Accept", "application/json, text/event-stream")
|
||||||
|
.body(body.to_owned());
|
||||||
|
if let Some(h) = header_version {
|
||||||
|
req = req.header("MCP-Protocol-Version", h);
|
||||||
|
}
|
||||||
|
for (k, v) in extra_headers {
|
||||||
|
req = req.header(*k, *v);
|
||||||
|
}
|
||||||
|
req.send().await.expect("send request")
|
||||||
|
}
|
||||||
|
|
||||||
|
// With the seam disabled, explicit stateless mode still dispatches a request
|
||||||
|
// without protocol metadata, preserving backwards compatibility.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seam_disabled_preserves_stateless_compatibility() -> anyhow::Result<()> {
|
||||||
|
let (client, url, ct, lists) = spawn_counting(stateless_json_config()).await;
|
||||||
|
|
||||||
|
let body = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#;
|
||||||
|
let response = post_seam(&client, &url, body, None, &[]).await;
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
assert_eq!(
|
||||||
|
lists.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"seam-disabled stateless config must dispatch exactly once"
|
||||||
|
);
|
||||||
|
|
||||||
|
ct.cancel();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// With legacy compatibility enabled, the seam applies only to requests that
|
||||||
|
// are routed statelessly: an established legacy session remains unchanged,
|
||||||
|
// while a self-identifying modern request is still enforced.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seam_mixed_mode_enforces_only_stateless_routed_requests() -> anyhow::Result<()> {
|
||||||
|
let config = StreamableHttpServerConfig::default()
|
||||||
|
.with_legacy_session_mode(true)
|
||||||
|
.with_json_response(true)
|
||||||
|
.with_sse_keep_alive(None)
|
||||||
|
.with_stateless_protocol_metadata_required(true)
|
||||||
|
.with_cancellation_token(CancellationToken::new());
|
||||||
|
let (client, url, ct, lists) = spawn_counting(config).await;
|
||||||
|
|
||||||
|
let legacy_body = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#;
|
||||||
|
let unknown_response = post_seam(
|
||||||
|
&client,
|
||||||
|
&url,
|
||||||
|
legacy_body,
|
||||||
|
None,
|
||||||
|
&[("Mcp-Session-Id", "unknown-session")],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
unknown_response.status(),
|
||||||
|
reqwest::StatusCode::NOT_FOUND,
|
||||||
|
"the stateless seam must not shadow the legacy unknown-session boundary"
|
||||||
|
);
|
||||||
|
assert_eq!(lists.load(Ordering::SeqCst), 0);
|
||||||
|
|
||||||
|
let initialize = post_init(&client, &url, None, "2025-11-25").await;
|
||||||
|
assert_eq!(initialize.status(), 200);
|
||||||
|
let session_id = initialize
|
||||||
|
.headers()
|
||||||
|
.get("Mcp-Session-Id")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.expect("legacy initialize response must include a session id")
|
||||||
|
.to_owned();
|
||||||
|
|
||||||
|
let legacy_response = post_seam(
|
||||||
|
&client,
|
||||||
|
&url,
|
||||||
|
legacy_body,
|
||||||
|
None,
|
||||||
|
&[("Mcp-Session-Id", &session_id)],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(legacy_response.status(), 200);
|
||||||
|
let _ = legacy_response.bytes().await?;
|
||||||
|
assert_eq!(
|
||||||
|
lists.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"legacy session routing must remain unchanged"
|
||||||
|
);
|
||||||
|
|
||||||
|
let delete_response = client
|
||||||
|
.delete(&url)
|
||||||
|
.header("Mcp-Session-Id", &session_id)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(delete_response.status(), reqwest::StatusCode::ACCEPTED);
|
||||||
|
|
||||||
|
let terminated_response = post_seam(
|
||||||
|
&client,
|
||||||
|
&url,
|
||||||
|
legacy_body,
|
||||||
|
None,
|
||||||
|
&[("Mcp-Session-Id", &session_id)],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
terminated_response.status(),
|
||||||
|
reqwest::StatusCode::NOT_FOUND,
|
||||||
|
"the stateless seam must not shadow the legacy terminated-session boundary"
|
||||||
|
);
|
||||||
|
assert_eq!(lists.load(Ordering::SeqCst), 1);
|
||||||
|
|
||||||
|
let modern_body = r#"{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}"#;
|
||||||
|
let modern_response = post_seam(
|
||||||
|
&client,
|
||||||
|
&url,
|
||||||
|
modern_body,
|
||||||
|
Some("2026-07-28"),
|
||||||
|
&[("Mcp-Method", "tools/list")],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(modern_response.status(), 400);
|
||||||
|
let payload: serde_json::Value = modern_response.json().await?;
|
||||||
|
assert_eq!(payload["error"]["code"], -32602);
|
||||||
|
assert_eq!(
|
||||||
|
lists.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"stateless metadata rejection must happen before another handler dispatch"
|
||||||
|
);
|
||||||
|
|
||||||
|
ct.cancel();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both signals absent → missing header / -32020 before dispatch.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seam_opt_in_rejects_missing_header_before_dispatch() -> anyhow::Result<()> {
|
||||||
|
let (client, url, ct, lists) = spawn_counting(modern_required_config()).await;
|
||||||
|
|
||||||
|
let without_meta = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#;
|
||||||
|
let response = post_seam(&client, &url, without_meta, None, &[]).await;
|
||||||
|
assert_eq!(response.status(), 400, "missing header must yield HTTP 400");
|
||||||
|
let payload: serde_json::Value = response.json().await?;
|
||||||
|
assert_eq!(payload["error"]["code"], -32020);
|
||||||
|
assert_eq!(
|
||||||
|
lists.load(Ordering::SeqCst),
|
||||||
|
0,
|
||||||
|
"handler invocation counter must stay at 0 when the header is missing"
|
||||||
|
);
|
||||||
|
|
||||||
|
ct.cancel();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header present but `_meta.protocolVersion` absent → -32602 before
|
||||||
|
// dispatch. Counter must equal 0.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seam_opt_in_rejects_missing_meta_before_dispatch() -> anyhow::Result<()> {
|
||||||
|
let (client, url, ct, lists) = spawn_counting(modern_required_config()).await;
|
||||||
|
|
||||||
|
let body = r#"{"jsonrpc":"2.0","id":4,"method":"tools/list","params":{}}"#;
|
||||||
|
let response = post_seam(
|
||||||
|
&client,
|
||||||
|
&url,
|
||||||
|
body,
|
||||||
|
Some("2026-07-28"),
|
||||||
|
&[("Mcp-Method", "tools/list")],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), 400);
|
||||||
|
let payload: serde_json::Value = response.json().await?;
|
||||||
|
assert_eq!(payload["error"]["code"], -32602);
|
||||||
|
assert_eq!(
|
||||||
|
lists.load(Ordering::SeqCst),
|
||||||
|
0,
|
||||||
|
"handler invocation counter must stay at 0 when _meta.protocolVersion is missing"
|
||||||
|
);
|
||||||
|
|
||||||
|
ct.cancel();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 2026 request with protocolVersion but no clientCapabilities reaches the
|
||||||
|
// existing inline-metadata validator and is rejected before method dispatch.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seam_opt_in_rejects_missing_client_capabilities_before_dispatch() -> anyhow::Result<()> {
|
||||||
|
let (client, url, ct, lists) = spawn_counting(modern_required_config()).await;
|
||||||
|
|
||||||
|
let body = r#"{"jsonrpc":"2.0","id":5,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}"#;
|
||||||
|
let response = post_seam(
|
||||||
|
&client,
|
||||||
|
&url,
|
||||||
|
body,
|
||||||
|
Some("2026-07-28"),
|
||||||
|
&[("Mcp-Method", "tools/list")],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), 400);
|
||||||
|
let payload: serde_json::Value = response.json().await?;
|
||||||
|
assert_eq!(payload["error"]["code"], -32602);
|
||||||
|
assert_eq!(
|
||||||
|
lists.load(Ordering::SeqCst),
|
||||||
|
0,
|
||||||
|
"handler invocation counter must stay at 0 when clientCapabilities is missing"
|
||||||
|
);
|
||||||
|
|
||||||
|
ct.cancel();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both signals present at the current version, plus the routing header
|
||||||
|
// required by `validate_standard_headers` for `>= STANDARD_HEADERS`. The
|
||||||
|
// seam must dispatch and the handler must run exactly once.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seam_opt_in_dispatches_when_header_and_meta_present() -> anyhow::Result<()> {
|
||||||
|
let (client, url, ct, lists) = spawn_counting(modern_required_config()).await;
|
||||||
|
|
||||||
|
let body = r#"{"jsonrpc":"2.0","id":6,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}"#;
|
||||||
|
let response = post_seam(
|
||||||
|
&client,
|
||||||
|
&url,
|
||||||
|
body,
|
||||||
|
Some("2026-07-28"),
|
||||||
|
&[("Mcp-Method", "tools/list")],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
let payload: serde_json::Value = response.json().await?;
|
||||||
|
assert!(payload.get("result").is_some());
|
||||||
|
assert_eq!(
|
||||||
|
lists.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"handler invocation counter must equal 1 on successful dispatch"
|
||||||
|
);
|
||||||
|
|
||||||
|
ct.cancel();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// rmcp clients negotiated below 2026-07-28 send the protocol header but do
|
||||||
|
// not attach per-request protocol metadata. Requiring the metadata therefore
|
||||||
|
// rejects their real request shape even if the handler supports that version.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seam_opt_in_rejects_older_rmcp_client_request_shape() -> anyhow::Result<()> {
|
||||||
|
let (client, url, ct, lists) = spawn_counting(modern_required_config()).await;
|
||||||
|
|
||||||
|
let body = r#"{"jsonrpc":"2.0","id":7,"method":"tools/list","params":{}}"#;
|
||||||
|
let response = post_seam(&client, &url, body, Some("2025-11-25"), &[]).await;
|
||||||
|
assert_eq!(response.status(), 400);
|
||||||
|
let payload: serde_json::Value = response.json().await?;
|
||||||
|
assert_eq!(payload["error"]["code"], -32602);
|
||||||
|
assert_eq!(
|
||||||
|
lists.load(Ordering::SeqCst),
|
||||||
|
0,
|
||||||
|
"an older client request without per-request metadata must not dispatch"
|
||||||
|
);
|
||||||
|
|
||||||
|
ct.cancel();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the 2026+ header is present but `Mcp-Method` is missing, the
|
||||||
|
// existing `validate_standard_headers` rule must fire first with -32020,
|
||||||
|
// before the seam's -32602 missing-meta check.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seam_opt_in_preserves_standard_header_precedence() -> anyhow::Result<()> {
|
||||||
|
let (client, url, ct, lists) = spawn_counting(modern_required_config()).await;
|
||||||
|
|
||||||
|
let body = r#"{"jsonrpc":"2.0","id":7,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}"#;
|
||||||
|
let response = post_seam(&client, &url, body, Some("2026-07-28"), &[]).await;
|
||||||
|
assert_eq!(response.status(), 400);
|
||||||
|
let payload: serde_json::Value = response.json().await?;
|
||||||
|
assert_eq!(
|
||||||
|
payload["error"]["code"], -32020,
|
||||||
|
"standard-headers routing-header error must precede the seam's meta check"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lists.load(Ordering::SeqCst),
|
||||||
|
0,
|
||||||
|
"handler invocation counter must stay at 0 when a routing header is missing"
|
||||||
|
);
|
||||||
|
|
||||||
|
ct.cancel();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// `initialize` is exempt from the new required-header check while retaining
|
||||||
|
// its existing optional-header and header/body consistency rules.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seam_opt_in_preserves_initialize_rules() -> anyhow::Result<()> {
|
||||||
|
let (client, url, ct) = spawn_server(modern_required_config()).await;
|
||||||
|
|
||||||
|
let response = post_init(&client, &url, None, "2025-11-25").await;
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
|
let response = post_init(&client, &url, Some("2025-11-25"), "2025-11-25").await;
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
|
let response = post_init(&client, &url, Some("2025-03-26"), "2025-11-25").await;
|
||||||
|
assert_eq!(response.status(), 400);
|
||||||
|
let payload: serde_json::Value = response.json().await?;
|
||||||
|
assert_eq!(payload["error"]["code"], -32600);
|
||||||
|
|
||||||
|
ct.cancel();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// `notifications/initialized` returns HTTP 202 (Accepted) without
|
||||||
|
// requiring any protocol metadata, even under the seam.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seam_opt_in_notifications_return_202() -> anyhow::Result<()> {
|
||||||
|
let (client, url, ct) = spawn_server(modern_required_config()).await;
|
||||||
|
|
||||||
|
let body = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
|
||||||
|
let response = post_seam(&client, &url, body, None, &[]).await;
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
reqwest::StatusCode::ACCEPTED,
|
||||||
|
"notifications must surface HTTP 202 regardless of metadata"
|
||||||
|
);
|
||||||
|
|
||||||
|
ct.cancel();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// `server/discover` is still subject to the new HTTP-header precheck.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seam_opt_in_discover_rejects_missing_header() -> anyhow::Result<()> {
|
||||||
|
let (client, url, ct) = spawn_server(modern_required_config()).await;
|
||||||
|
|
||||||
|
let with_meta = r#"{"jsonrpc":"2.0","id":8,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2025-11-25","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}"#;
|
||||||
|
let without_meta = r#"{"jsonrpc":"2.0","id":9,"method":"server/discover","params":{}}"#;
|
||||||
|
for body in [with_meta, without_meta] {
|
||||||
|
let response = post_seam(
|
||||||
|
&client,
|
||||||
|
&url,
|
||||||
|
body,
|
||||||
|
None,
|
||||||
|
&[("Mcp-Method", "server/discover")],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), 400);
|
||||||
|
let payload: serde_json::Value = response.json().await?;
|
||||||
|
assert_eq!(payload["error"]["code"], -32020);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = post_seam(
|
||||||
|
&client,
|
||||||
|
&url,
|
||||||
|
without_meta,
|
||||||
|
Some("2025-11-25"),
|
||||||
|
&[("Mcp-Method", "server/discover")],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), 400);
|
||||||
|
let payload: serde_json::Value = response.json().await?;
|
||||||
|
assert_eq!(payload["error"]["code"], -32602);
|
||||||
|
|
||||||
|
let response = post_seam(
|
||||||
|
&client,
|
||||||
|
&url,
|
||||||
|
with_meta,
|
||||||
|
Some("2025-11-25"),
|
||||||
|
&[("Mcp-Method", "server/discover")],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
|
ct.cancel();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue