* fix(model): deprecate ServerInfo and ClientInfo aliases Those names collide with the protocol's serverInfo/clientInfo fields, which are only Implementation identity. Prefer InitializeResult and InitializeRequestParams. Handler trait signatures, the tool_handler macro output, tests, examples and docs now use the canonical types; the aliases remain as deprecated shims for the rest of 3.x. Fixes #1082 * fix(model): keep public signatures on the deprecated aliases The public API check compares rendered signatures, and rustdoc records a type alias by the name it was written with. Respelling `ServerHandler::get_info`, `ClientHandler::get_info`, `DiscoverResult::from_server_info` and `impl ClientHandler for ClientInfo` onto `InitializeResult`/`InitializeRequestParams` therefore reads as changed public items even though the aliases resolve to those exact types and nothing downstream breaks. Put those four signatures back on the aliases so the published API is byte-identical, and note why at the alias definitions. The deprecation still steers new code, and the macro output, tests, examples and docs keep using the canonical names. * fix(model): migrate leftover ClientInfo test sites Two tests landed on main after the alias deprecation and still named ClientInfo. Clippy -D warnings and the no-local feature test job both failed on them.
916 lines
30 KiB
Rust
916 lines
30 KiB
Rust
#![cfg(not(feature = "local"))]
|
|
//! Streamable HTTP protocol-version and request-metadata validation tests.
|
|
use std::sync::{
|
|
Arc,
|
|
atomic::{AtomicUsize, Ordering},
|
|
};
|
|
|
|
use rmcp::{
|
|
ServerHandler,
|
|
transport::streamable_http_server::{
|
|
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
|
|
},
|
|
};
|
|
use serde_json::{Value, json};
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
mod common;
|
|
use common::calculator::Calculator;
|
|
|
|
fn init_body(body_version: &str) -> String {
|
|
format!(
|
|
r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"{body_version}","capabilities":{{}},"clientInfo":{{"name":"test","version":"1.0"}}}}}}"#
|
|
)
|
|
}
|
|
|
|
async fn spawn_server(
|
|
config: StreamableHttpServerConfig,
|
|
) -> (reqwest::Client, String, CancellationToken) {
|
|
spawn_server_with_manager(config, Arc::new(LocalSessionManager::default())).await
|
|
}
|
|
|
|
async fn spawn_server_with_manager(
|
|
config: StreamableHttpServerConfig,
|
|
session_manager: Arc<LocalSessionManager>,
|
|
) -> (reqwest::Client, String, CancellationToken) {
|
|
let ct = config.cancellation_token.clone();
|
|
let service: StreamableHttpService<Calculator, LocalSessionManager> =
|
|
StreamableHttpService::new(|| Ok(Calculator::new()), session_manager, 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;
|
|
}
|
|
});
|
|
|
|
let client = reqwest::Client::new();
|
|
let base_url = format!("http://{addr}/mcp");
|
|
(client, base_url, ct)
|
|
}
|
|
|
|
fn stateless_json_config() -> StreamableHttpServerConfig {
|
|
StreamableHttpServerConfig::default()
|
|
.with_legacy_session_mode(false)
|
|
.with_json_response(true)
|
|
.with_sse_keep_alive(None)
|
|
.with_cancellation_token(CancellationToken::new())
|
|
}
|
|
|
|
fn stateful_config() -> StreamableHttpServerConfig {
|
|
StreamableHttpServerConfig::default()
|
|
.with_legacy_session_mode(true)
|
|
.with_sse_keep_alive(None)
|
|
.with_cancellation_token(CancellationToken::new())
|
|
}
|
|
|
|
async fn post_init(
|
|
client: &reqwest::Client,
|
|
url: &str,
|
|
header: Option<&str>,
|
|
body_version: &str,
|
|
) -> reqwest::Response {
|
|
let mut req = client
|
|
.post(url)
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json, text/event-stream")
|
|
.body(init_body(body_version));
|
|
if let Some(h) = header {
|
|
req = req.header("MCP-Protocol-Version", h);
|
|
}
|
|
req.send().await.expect("send initialize request")
|
|
}
|
|
|
|
/// First JSON-RPC message of an SSE response, skipping the empty priming event.
|
|
async fn sse_payload(response: reqwest::Response) -> anyhow::Result<Value> {
|
|
let body = response.text().await?;
|
|
let data = body
|
|
.lines()
|
|
.filter_map(|line| line.strip_prefix("data: "))
|
|
.find(|data| !data.is_empty())
|
|
.expect("response must carry a JSON-RPC payload");
|
|
Ok(serde_json::from_str(data)?)
|
|
}
|
|
|
|
async fn post_non_initialize(client: &reqwest::Client, url: &str) -> reqwest::Response {
|
|
client
|
|
.post(url)
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json, text/event-stream")
|
|
.body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#)
|
|
.send()
|
|
.await
|
|
.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;
|
|
|
|
let response = post_init(&client, &url, Some("2025-03-26"), "2025-11-25").await;
|
|
assert_eq!(response.status(), 400);
|
|
|
|
let body: serde_json::Value = response.json().await?;
|
|
assert_eq!(body["error"]["code"], -32600);
|
|
assert!(
|
|
body["error"]["message"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.contains("MCP-Protocol-Version"),
|
|
"expected error message to mention the header, got: {body}"
|
|
);
|
|
|
|
ct.cancel();
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stateless_init_rejects_when_header_newer_than_body() -> anyhow::Result<()> {
|
|
let (client, url, ct) = spawn_server(stateless_json_config()).await;
|
|
|
|
let response = post_init(&client, &url, Some("2025-11-25"), "2025-03-26").await;
|
|
assert_eq!(response.status(), 400);
|
|
|
|
let body: serde_json::Value = response.json().await?;
|
|
assert_eq!(body["error"]["code"], -32600);
|
|
|
|
ct.cancel();
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stateless_init_accepts_when_header_matches_body() -> anyhow::Result<()> {
|
|
let (client, url, ct) = spawn_server(stateless_json_config()).await;
|
|
|
|
let response = post_init(&client, &url, Some("2025-11-25"), "2025-11-25").await;
|
|
assert_eq!(response.status(), 200);
|
|
|
|
let body: serde_json::Value = response.json().await?;
|
|
assert!(
|
|
body["result"].is_object(),
|
|
"expected an InitializeResult, got: {body}"
|
|
);
|
|
|
|
ct.cancel();
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stateless_init_accepts_when_header_absent() -> anyhow::Result<()> {
|
|
let (client, url, ct) = spawn_server(stateless_json_config()).await;
|
|
|
|
let response = post_init(&client, &url, None, "2025-11-25").await;
|
|
assert_eq!(response.status(), 200);
|
|
|
|
ct.cancel();
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stateless_init_naming_a_post_handshake_version_negotiates_down() -> anyhow::Result<()> {
|
|
let (client, url, ct) = spawn_server(stateless_json_config()).await;
|
|
|
|
let response = post_init(&client, &url, None, "2026-07-28").await;
|
|
assert_eq!(
|
|
response.status(),
|
|
200,
|
|
"initialize selects legacy semantics whatever version it names"
|
|
);
|
|
|
|
let body: serde_json::Value = response.json().await?;
|
|
assert_eq!(
|
|
body["result"]["protocolVersion"], "2025-11-25",
|
|
"initialize must settle on a version that still has the handshake"
|
|
);
|
|
|
|
ct.cancel();
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stateful_init_rejects_when_header_mismatches_body() -> anyhow::Result<()> {
|
|
let (client, url, ct) = spawn_server(stateful_config()).await;
|
|
|
|
let response = post_init(&client, &url, Some("2024-11-05"), "2025-11-25").await;
|
|
assert_eq!(response.status(), 400);
|
|
|
|
let body: serde_json::Value = response.json().await?;
|
|
assert_eq!(body["error"]["code"], -32600);
|
|
|
|
ct.cancel();
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stateful_rejected_initial_posts_do_not_create_sessions() -> anyhow::Result<()> {
|
|
let session_manager = Arc::new(LocalSessionManager::default());
|
|
let (client, url, ct) =
|
|
spawn_server_with_manager(stateful_config(), session_manager.clone()).await;
|
|
|
|
let response = post_non_initialize(&client, &url).await;
|
|
assert_eq!(response.status(), 422);
|
|
assert_eq!(session_manager.sessions.read().await.len(), 0);
|
|
|
|
let response = post_init(&client, &url, Some("2024-11-05"), "2025-11-25").await;
|
|
assert_eq!(response.status(), 400);
|
|
assert_eq!(session_manager.sessions.read().await.len(), 0);
|
|
|
|
ct.cancel();
|
|
Ok(())
|
|
}
|
|
|
|
/// The `initialize` handshake exists only in the revisions before `2026-07-28`,
|
|
/// so naming a later version in it does not make the request a modern one: the
|
|
/// server keeps legacy semantics, opens a session, and answers with a version it
|
|
/// can actually serve over the handshake.
|
|
#[tokio::test]
|
|
async fn stateful_init_naming_a_post_handshake_version_opens_a_legacy_session() -> anyhow::Result<()>
|
|
{
|
|
let (client, url, ct) = spawn_server(stateful_config()).await;
|
|
|
|
let response = post_init(&client, &url, None, "2026-07-28").await;
|
|
assert_eq!(
|
|
response.status(),
|
|
200,
|
|
"initialize selects legacy semantics whatever version it names"
|
|
);
|
|
assert!(
|
|
response.headers().contains_key("Mcp-Session-Id"),
|
|
"the handshake must open a session, got headers: {:?}",
|
|
response.headers()
|
|
);
|
|
|
|
let payload = sse_payload(response).await?;
|
|
assert_eq!(
|
|
payload["result"]["protocolVersion"], "2025-11-25",
|
|
"initialize must settle on a version that still has the handshake"
|
|
);
|
|
|
|
ct.cancel();
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stateless_missing_protocol_header_returns_header_mismatch() -> anyhow::Result<()> {
|
|
let (client, url, ct) = spawn_server(stateless_json_config()).await;
|
|
|
|
let body = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}"#;
|
|
let response = client
|
|
.post(&url)
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json, text/event-stream")
|
|
.header("Mcp-Method", "tools/list")
|
|
.body(body)
|
|
.send()
|
|
.await
|
|
.expect("send non-initialize request");
|
|
|
|
assert_eq!(response.status(), 400);
|
|
|
|
let body: serde_json::Value = response.json().await?;
|
|
assert_eq!(body["jsonrpc"], "2.0");
|
|
assert_eq!(body["id"], 1);
|
|
assert_eq!(body["error"]["code"], -32020);
|
|
assert!(
|
|
body["error"]["message"]
|
|
.as_str()
|
|
.is_some_and(|message| message.contains("requires MCP-Protocol-Version header")),
|
|
"expected missing protocol header message, got: {body}"
|
|
);
|
|
|
|
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();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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::InitializeResult {
|
|
rmcp::model::InitializeResult::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(())
|
|
}
|