#![cfg(all(
feature = "streamable-http",
feature = "http-client",
not(target_arch = "wasm32")
))]
mod common;
use async_trait::async_trait;
use common::v2::{
build_v2_server, default_client_capabilities, delete, get, header, post, post_raw,
spawn_default_config, v2_body, v2_headers, ALLOW, META_CLIENT_CAPABILITIES, META_CLIENT_INFO,
META_PROTOCOL_VERSION, REQUEST_META_KEY, V1, V2,
};
#[cfg(feature = "v1-compat")]
use common::v2::v1_body;
use pmcp::types::protocol::error_codes::{
HEADER_MISMATCH, METHOD_NOT_FOUND, UNSUPPORTED_PROTOCOL_VERSION,
};
#[cfg(feature = "v1-compat")]
use std::net::SocketAddr;
#[cfg(feature = "v1-compat")]
use pmcp::types::protocol::error_codes::PARSE_ERROR;
use pmcp::types::protocol::ProtocolVersion;
use pmcp::{RequestHandlerExtra, Server, ToolHandler};
use serde_json::{json, Value};
use std::sync::{Arc, Mutex, OnceLock};
fn body_claiming_version(method: &str, id: Value, params: Value, version: &str) -> String {
let meta = json!({
META_PROTOCOL_VERSION: version,
META_CLIENT_INFO: { "name": "pmcp-test-client", "version": "0.0.0" },
META_CLIENT_CAPABILITIES: default_client_capabilities(),
});
let mut params = params;
params
.as_object_mut()
.expect("params is an object")
.insert(REQUEST_META_KEY.to_string(), meta);
let mut body = serde_json::Map::new();
body.insert("jsonrpc".to_string(), json!("2.0"));
body.insert("id".to_string(), id);
body.insert("method".to_string(), json!(method));
body.insert("params".to_string(), params);
Value::Object(body).to_string()
}
fn v2_call_body(id: Value) -> String {
v2_body(
"tools/call",
id,
json!({ "name": "search", "arguments": {} }),
)
}
#[cfg(feature = "v1-compat")]
fn v1_initialize_body() -> String {
v1_body(
"initialize",
json!(1),
json!({
"protocolVersion": V1,
"capabilities": {},
"clientInfo": { "name": "v1-client", "version": "1.0.0" },
}),
)
}
#[tokio::test]
async fn no_session_id_on_v2() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let response = post(
addr,
&v2_headers("tools/call", "search"),
&v2_call_body(json!(1)),
)
.await;
handle.abort();
assert_eq!(response.status, 200, "body: {}", response.raw);
assert_eq!(
response.mcp_session_id, None,
"a v2 response must NOT mint or echo a session id; raw: {}",
response.raw
);
}
#[tokio::test]
async fn v2_requires_no_session_id() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let response = post(
addr,
&v2_headers("tools/call", "search"),
&v2_call_body(json!(2)),
)
.await;
handle.abort();
assert_eq!(
response.status, 200,
"a v2 request sending no Mcp-Session-Id must be served; body: {}",
response.raw
);
assert!(
response.body.get("result").is_some(),
"expected a result, got: {}",
response.raw
);
}
#[tokio::test]
async fn v2_ignores_inbound_session_id() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let mut headers = v2_headers("tools/call", "search");
headers.push(header("mcp-session-id", "nope"));
let response = post(addr, &headers, &v2_call_body(json!(3))).await;
handle.abort();
assert_eq!(
response.status, 200,
"an inbound Mcp-Session-Id on v2 must be IGNORED, not rejected; body: {}",
response.raw
);
assert_eq!(
response.mcp_session_id, None,
"...and still nothing is echoed back; raw: {}",
response.raw
);
}
#[cfg(feature = "v1-compat")]
#[tokio::test]
async fn v1_session_unchanged() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let init = post(addr, &[], &v1_initialize_body()).await;
assert_eq!(init.status, 200, "v1 initialize: {}", init.raw);
let session_id = init
.mcp_session_id
.clone()
.expect("a v1 initialize on a stateful server MUST mint a session id");
assert!(!session_id.is_empty());
let listed = post(
addr,
&[header("mcp-session-id", &session_id)],
&v1_body("tools/list", json!(2), json!({})),
)
.await;
assert_eq!(listed.status, 200, "v1 tools/list: {}", listed.raw);
assert!(
listed.body.get("result").is_some(),
"expected a result, got: {}",
listed.raw
);
let bare = post(addr, &[], &v1_body("tools/list", json!(3), json!({}))).await;
handle.abort();
assert_eq!(
bare.status, 400,
"a v1 non-init request with no session id must still be rejected; body: {}",
bare.raw
);
assert!(
bare.raw.contains("Session ID required"),
"expected the v1 session gate, got: {}",
bare.raw
);
}
#[tokio::test]
async fn v2_get_405() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let response = get(
addr,
&[
header("mcp-protocol-version", V2),
header("mcp-session-id", "nope"),
],
)
.await;
handle.abort();
assert_eq!(
response.status, 405,
"a v2 GET must be 405 Method Not Allowed; body: {}",
response.raw
);
assert_eq!(
response.allow.as_deref(),
Some(ALLOW),
"a v2 GET refusal must carry `Allow: {ALLOW}` (RFC 9110 §15.5.6 MUST); got {:?}",
response.allow
);
}
#[tokio::test]
async fn v2_delete_405() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let response = delete(
addr,
&[
header("mcp-protocol-version", V2),
header("mcp-session-id", "nope"),
],
)
.await;
handle.abort();
assert_eq!(
response.status, 405,
"a v2 DELETE must be 405 Method Not Allowed; body: {}",
response.raw
);
assert_eq!(
response.allow.as_deref(),
Some(ALLOW),
"a v2 DELETE refusal must carry `Allow: {ALLOW}` (RFC 9110 §15.5.6 MUST); got {:?}",
response.allow
);
}
#[cfg(feature = "v1-compat")]
#[tokio::test]
async fn v1_get_delete_unchanged() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let unknown_session = [header("mcp-session-id", "nope")];
let got = get(addr, &unknown_session).await;
let deleted = delete(addr, &unknown_session).await;
handle.abort();
assert_ne!(got.status, 405, "a v1 GET must NOT be 405: {}", got.raw);
assert_eq!(
got.status, 404,
"a v1 GET with an unknown session id is still 404; body: {}",
got.raw
);
assert_ne!(
deleted.status, 405,
"a v1 DELETE must NOT be 405: {}",
deleted.raw
);
assert_eq!(
deleted.status, 404,
"a v1 DELETE with an unknown session id is still 404; body: {}",
deleted.raw
);
}
#[tokio::test]
async fn v2_unknown_method_404() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let response = post_raw(
addr,
&v2_headers("totally/unknown", ""),
&v2_body("totally/unknown", json!(7), json!({})),
)
.await;
handle.abort();
assert_eq!(
response.status, 404,
"a v2 unknown method must be HTTP 404; body: {}",
response.raw
);
assert_eq!(
response.body["error"]["code"], METHOD_NOT_FOUND,
"...with JSON-RPC METHOD_NOT_FOUND; body: {}",
response.raw
);
assert_eq!(
response.body["id"], 7,
"the ORIGINAL request id must survive a body that never typed-parses; body: {}",
response.raw
);
}
#[cfg(feature = "v1-compat")]
#[tokio::test]
async fn v1_unknown_method_still_200() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let init = post(addr, &[], &v1_initialize_body()).await;
let session = init
.mcp_session_id
.clone()
.expect("v1 initialize mints a session");
let session_header = [header("mcp-session-id", &session)];
let discover = post_raw(
addr,
&session_header,
&v1_body("server/discover", json!(8), json!({})),
)
.await;
let arbitrary = post_raw(
addr,
&session_header,
&v1_body("totally/unknown", json!(9), json!({})),
)
.await;
handle.abort();
assert_eq!(
discover.status, 200,
"a v1 unimplemented method stays at HTTP 200; body: {}",
discover.raw
);
assert_eq!(discover.body["error"]["code"], METHOD_NOT_FOUND);
assert_eq!(discover.body["id"], 8, "v1 preserves the id too");
assert_ne!(
arbitrary.status, 404,
"the 404 mapping must be v2-only; body: {}",
arbitrary.raw
);
assert_eq!(
arbitrary.status, 400,
"an unparseable v1 method string is still a 400 parse error; body: {}",
arbitrary.raw
);
assert_eq!(arbitrary.body["error"]["code"], PARSE_ERROR);
}
#[tokio::test]
async fn v2_header_gate_rejection_is_400() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let response = post(
addr,
&[
header("mcp-name", "search"),
header("mcp-protocol-version", V2),
],
&v2_call_body(json!(4)),
)
.await;
handle.abort();
assert_eq!(
response.status, 400,
"a v2 header-gate rejection must be HTTP 400; body: {}",
response.raw
);
assert_eq!(
response.body["error"]["code"], HEADER_MISMATCH,
"...with HEADER_MISMATCH; body: {}",
response.raw
);
}
#[tokio::test]
async fn v2_nameless_method_empty_mcp_name_accepted() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let headers = v2_headers("tools/list", "");
assert_eq!(
headers[1],
("mcp-name".to_string(), String::new()),
"the harness must emit an EMPTY Mcp-Name, not omit the header"
);
let response = post(addr, &headers, &v2_body("tools/list", json!(5), json!({}))).await;
handle.abort();
assert_eq!(
response.status, 200,
"an EMPTY Mcp-Name on a name-less v2 method must be ACCEPTED; body: {}",
response.raw
);
assert!(
response.body.get("result").is_some(),
"expected a result, got: {}",
response.raw
);
}
#[tokio::test]
async fn v2_nameless_method_absent_mcp_name_accepted() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let response = post(
addr,
&[
header("mcp-method", "tools/list"),
header("mcp-protocol-version", V2),
],
&v2_body("tools/list", json!(6), json!({})),
)
.await;
handle.abort();
assert_eq!(
response.status, 200,
"tools/list carries no routing name, so an ABSENT Mcp-Name must be ACCEPTED \
(Phase 118 D-13); body: {}",
response.raw
);
assert!(
response.body["result"]["tools"].is_array(),
"the request must reach dispatch, not the header gate; body: {}",
response.raw
);
}
#[tokio::test]
async fn v2_unsupported_version_400_with_supported() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let response = post(
addr,
&[
header("mcp-method", "tools/call"),
header("mcp-name", "search"),
header("mcp-protocol-version", "1999-01-01"),
],
&body_claiming_version(
"tools/call",
json!(10),
json!({ "name": "search", "arguments": {} }),
"1999-01-01",
),
)
.await;
handle.abort();
assert_eq!(
response.status, 400,
"an unsupported version must be HTTP 400; body: {}",
response.raw
);
assert_eq!(
response.body["error"]["code"], UNSUPPORTED_PROTOCOL_VERSION,
"body: {}",
response.raw
);
assert!(
response.body["error"]["data"]["supported"].is_array(),
"the rejection MUST carry an error.data.supported ARRAY; body: {}",
response.raw
);
assert!(
response.body["error"]["data"]["supported"]
.as_array()
.is_some_and(|versions| versions.iter().any(|v| v == V2)),
"...listing the server's accept-list; body: {}",
response.raw
);
}
#[tokio::test]
async fn v2_malformed_json_400() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let broken = post_raw(addr, &v2_headers("tools/call", "search"), "{not json").await;
let after = post(
addr,
&v2_headers("tools/call", "search"),
&v2_call_body(json!(11)),
)
.await;
handle.abort();
assert_eq!(
broken.status, 400,
"malformed JSON must be a clean 400; body: {}",
broken.raw
);
assert_eq!(
after.status, 200,
"the server must still serve: {}",
after.raw
);
}
#[tokio::test]
async fn v2_string_id_preserved() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let response = post(
addr,
&v2_headers("tools/call", "search"),
&v2_call_body(json!("req-abc")),
)
.await;
handle.abort();
assert_eq!(response.status, 200, "body: {}", response.raw);
assert_eq!(
response.body["id"], "req-abc",
"a string id must be preserved verbatim; body: {}",
response.raw
);
}
static CACHED_PAYLOAD_PTRS: Mutex<Vec<usize>> = Mutex::new(Vec::new());
static CACHED_PAYLOAD: OnceLock<Arc<Value>> = OnceLock::new();
struct CachedPayloadTool;
#[async_trait]
impl ToolHandler for CachedPayloadTool {
async fn handle(&self, _args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
let cached =
CACHED_PAYLOAD.get_or_init(|| Arc::new(json!({ "cached": true, "nonce": "reused" })));
CACHED_PAYLOAD_PTRS
.lock()
.expect("ptr log")
.push(Arc::as_ptr(cached) as usize);
Ok((**cached).clone())
}
}
fn build_cached_payload_server() -> Server {
Server::builder()
.name("v2-cached-payload")
.version("1.0.0")
.with_supported_protocol_versions([
ProtocolVersion(V1.to_string()),
ProtocolVersion(V2.to_string()),
])
.tool("cached", CachedPayloadTool)
.build()
.expect("server builds")
}
#[cfg(feature = "v1-compat")]
async fn sse_first_data_frame(
addr: SocketAddr,
extra: &[(String, String)],
) -> (u16, Option<String>, Option<Value>) {
let client = reqwest::Client::new();
let mut request = client
.get(format!("http://{addr}"))
.header("accept", "text/event-stream");
for (name, value) in extra {
request = request.header(name.as_str(), value.as_str());
}
let mut response = request.send().await.expect("request sent");
let status = response.status().as_u16();
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), async {
let mut buffer = String::new();
while let Ok(Some(chunk)) = response.chunk().await {
buffer.push_str(&String::from_utf8_lossy(&chunk));
for line in buffer.lines() {
if let Some(payload) = line.strip_prefix("data:") {
if let Ok(value) = serde_json::from_str::<Value>(payload.trim()) {
return Some(value);
}
}
}
}
None
})
.await
.unwrap_or(None);
(status, content_type, frame)
}
#[tokio::test]
async fn response_id_always_from_live_request() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
for id in [101_i64, 102, 103, 104] {
let response = post(
addr,
&v2_headers("tools/call", "search"),
&v2_call_body(json!(id)),
)
.await;
assert_eq!(response.status, 200, "body: {}", response.raw);
assert_eq!(
response.body["id"], id,
"request {id} must get its OWN id back; body: {}",
response.raw
);
}
handle.abort();
}
#[tokio::test]
async fn response_id_concurrent_callers_do_not_cross() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let mut tasks = Vec::new();
for id in 200_i64..212 {
tasks.push(tokio::spawn(async move {
let response = post(
addr,
&v2_headers("tools/call", "search"),
&v2_call_body(json!(id)),
)
.await;
(id, response)
}));
}
let mut seen = Vec::new();
for task in tasks {
let (id, response) = task.await.expect("request task joins");
assert_eq!(response.status, 200, "id {id}; body: {}", response.raw);
assert_eq!(
response.body["id"], id,
"concurrent caller {id} received another caller's id; body: {}",
response.raw
);
seen.push(response.body["id"].clone());
}
handle.abort();
assert_eq!(seen.len(), 12, "every concurrent caller answered");
let mut unique = seen.clone();
unique.sort_by_key(std::string::ToString::to_string);
unique.dedup();
assert_eq!(unique.len(), seen.len(), "no id was delivered twice");
}
#[tokio::test]
async fn response_id_preserved_for_string_ids() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let response = post(
addr,
&v2_headers("tools/call", "search"),
&v2_call_body(json!("caller-alpha")),
)
.await;
handle.abort();
assert_eq!(response.status, 200, "body: {}", response.raw);
assert!(
response.body["id"].is_string(),
"a string id must stay a STRING, not be coerced; body: {}",
response.raw
);
assert_eq!(response.body["id"], "caller-alpha");
}
#[tokio::test]
async fn response_id_preserved_on_error() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let unknown = post_raw(
addr,
&v2_headers("totally/unknown", ""),
&v2_body("totally/unknown", json!(4242), json!({})),
)
.await;
let no_such_tool = post(
addr,
&v2_headers("tools/call", "nope"),
&v2_body(
"tools/call",
json!("err-id"),
json!({ "name": "nope", "arguments": {} }),
),
)
.await;
handle.abort();
assert_eq!(unknown.status, 404, "body: {}", unknown.raw);
assert_eq!(unknown.body["error"]["code"], METHOD_NOT_FOUND);
assert_eq!(
unknown.body["id"], 4242,
"an error response must carry the ORIGINAL id; body: {}",
unknown.raw
);
assert!(
no_such_tool.body.get("error").is_some(),
"an unknown tool must be an error; body: {}",
no_such_tool.raw
);
assert_eq!(
no_such_tool.body["id"], "err-id",
"a handler-produced error must carry the ORIGINAL id; body: {}",
no_such_tool.raw
);
}
#[tokio::test]
async fn cached_payload_is_reenveloped_with_live_id() {
CACHED_PAYLOAD_PTRS.lock().expect("ptr log").clear();
let (addr, handle) = spawn_default_config(build_cached_payload_server()).await;
let body = |id: Value| {
v2_body(
"tools/call",
id,
json!({ "name": "cached", "arguments": {} }),
)
};
let first = post(addr, &v2_headers("tools/call", "cached"), &body(json!(901))).await;
let second = post(
addr,
&v2_headers("tools/call", "cached"),
&body(json!("902-string")),
)
.await;
handle.abort();
assert_eq!(first.status, 200, "body: {}", first.raw);
assert_eq!(second.status, 200, "body: {}", second.raw);
let ptrs = CACHED_PAYLOAD_PTRS.lock().expect("ptr log").clone();
assert_eq!(ptrs.len(), 2, "the cached tool ran exactly twice");
assert_eq!(
ptrs[0], ptrs[1],
"the fixture must reuse the SAME payload object, or it proves nothing"
);
assert_eq!(
first.body["id"], 901,
"a reused payload must be re-enveloped with the live id; body: {}",
first.raw
);
assert_eq!(
second.body["id"], "902-string",
"...including when the live id is a string; body: {}",
second.raw
);
assert_ne!(
first.body["id"], second.body["id"],
"the two callers must NOT share an id"
);
}
#[tokio::test]
async fn last_event_id_ignored() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let mut headers = v2_headers("tools/call", "search");
headers.push(header("last-event-id", "12345"));
let response = post(addr, &headers, &v2_call_body(json!(777))).await;
handle.abort();
assert_eq!(
response.status, 200,
"a v2 request carrying Last-Event-ID must be served normally; body: {}",
response.raw
);
assert!(
response.body.get("result").is_some(),
"...with a real result; body: {}",
response.raw
);
assert_eq!(
response.body["id"], 777,
"...and its OWN live id, not a replayed one; body: {}",
response.raw
);
}
#[cfg(feature = "v1-compat")]
#[tokio::test]
async fn v1_resumability_unchanged() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let init = post(addr, &[], &v1_initialize_body()).await;
let session = init
.mcp_session_id
.clone()
.expect("v1 initialize mints a session");
let (status, content_type, frame) = sse_first_data_frame(
addr,
&[
header("mcp-session-id", &session),
header("last-event-id", "no-such-event"),
],
)
.await;
handle.abort();
assert_eq!(status, 200, "a v1 resumable GET must open an SSE stream");
assert!(
content_type
.as_deref()
.is_some_and(|ct| ct.starts_with("text/event-stream")),
"...framed as SSE, got {content_type:?}"
);
assert!(
frame.is_some(),
"a v1 GET with Last-Event-ID must REPLAY the session's stored events"
);
}
#[cfg(feature = "v1-compat")]
#[tokio::test]
async fn v1_replayed_event_retains_original_id() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let init = post(
addr,
&[],
&v1_body(
"initialize",
json!(4711),
json!({
"protocolVersion": V1,
"capabilities": {},
"clientInfo": { "name": "v1-client", "version": "1.0.0" },
}),
),
)
.await;
assert_eq!(init.status, 200, "v1 initialize: {}", init.raw);
assert_eq!(init.body["id"], 4711, "the DIRECT response carries 4711");
let session = init
.mcp_session_id
.clone()
.expect("v1 initialize mints a session");
let listed = post(
addr,
&[header("mcp-session-id", &session)],
&v1_body("tools/list", json!(9999), json!({})),
)
.await;
assert_eq!(listed.status, 200, "v1 tools/list: {}", listed.raw);
let (status, _content_type, frame) = sse_first_data_frame(
addr,
&[
header("mcp-session-id", &session),
header("last-event-id", "no-such-event"),
],
)
.await;
handle.abort();
assert_eq!(status, 200);
let replayed = frame.expect("the v1 replay must emit the stored event");
assert_eq!(
replayed["id"], 4711,
"a REPLAYED historical event keeps its ORIGINAL id — this is correct \
behavior, not a violation of the direct-response invariant; frame: {replayed}"
);
assert_ne!(
replayed["id"], 9999,
"...and is emphatically NOT re-stamped with a later request's id"
);
}