#![cfg(all(
feature = "streamable-http",
feature = "http-client",
not(target_arch = "wasm32")
))]
mod common;
use common::v2::{
build_v2_server, default_client_capabilities, header, jsonrpc_envelope, post,
spawn_default_config, teardown, v2_headers_claiming, Resp, META_CLIENT_CAPABILITIES,
META_CLIENT_INFO, META_PROTOCOL_VERSION, REQUEST_META_KEY, V1, V2,
};
use pmcp::shared::http_constants::{MCP_METHOD, MCP_PROTOCOL_VERSION};
use pmcp::types::protocol::error_codes::{
HEADER_MISMATCH, INVALID_PARAMS, UNSUPPORTED_PROTOCOL_VERSION,
};
#[cfg(feature = "v1-compat")]
use proptest::test_runner::{Config as ProptestConfig, TestRunner};
use serde_json::{json, Value};
use std::net::SocketAddr;
const UNSUPPORTED_VERSION: &str = "v999.0.0";
const DISCOVER: &str = "server/discover";
fn canonical_meta() -> Value {
json!({
META_PROTOCOL_VERSION: V2,
META_CLIENT_INFO: { "name": "pmcp-conformance-probe", "version": "0.0.0" },
META_CLIENT_CAPABILITIES: default_client_capabilities(),
})
}
fn canonical_meta_without(key: &str) -> Value {
let mut meta = canonical_meta();
meta.as_object_mut()
.expect("canonical _meta is an object")
.remove(key)
.unwrap_or_else(|| panic!("{key} must be present in the canonical _meta to be removed"));
meta
}
fn canonical_meta_claiming(version: &str) -> Value {
let mut meta = canonical_meta();
meta.as_object_mut()
.expect("canonical _meta is an object")
.insert(META_PROTOCOL_VERSION.to_string(), json!(version));
meta
}
fn matrix_body_with_id(method: &str, id: Value, meta: Option<Value>) -> String {
let mut params = serde_json::Map::new();
if let Some(meta) = meta {
params.insert(REQUEST_META_KEY.to_string(), meta);
}
jsonrpc_envelope(method, id, Value::Object(params))
}
fn discover_body(id: Value, meta: Option<Value>) -> String {
matrix_body_with_id(DISCOVER, id, meta)
}
fn discover_headers(version: &str) -> Vec<(String, String)> {
v2_headers_claiming(DISCOVER, "", version)
}
fn assert_rejected(response: &Resp, expected_code: i32, id: i64, what: &str) {
assert_eq!(
response.status, 400,
"{what}: the referee requires HTTP 400; raw: {}",
response.raw
);
assert_eq!(
response.body["error"]["code"], expected_code,
"{what}: wrong JSON-RPC error code; raw: {}",
response.raw
);
assert_eq!(
response.body["id"], id,
"{what}: HttpServerErrorJsonrpcId requires the request id to be echoed; raw: {}",
response.raw
);
}
fn assert_served(response: &Resp, what: &str) {
assert_eq!(
response.status, 200,
"{what}: must be SERVED; raw: {}",
response.raw
);
assert!(
response.body.get("error").is_none(),
"{what}: must carry no JSON-RPC error; raw: {}",
response.raw
);
assert!(
response.body.get("result").is_some(),
"{what}: must carry a result; raw: {}",
response.raw
);
}
async fn spawn() -> (SocketAddr, tokio::task::JoinHandle<()>) {
spawn_default_config(build_v2_server()).await
}
#[tokio::test]
async fn request_meta_invalid_missing_meta_is_32602() {
let (addr, handle) = spawn().await;
let response = post(
addr,
&discover_headers(V2),
&discover_body(json!(101), None),
)
.await;
teardown(handle, ()).await;
assert_rejected(&response, INVALID_PARAMS, 101, "missing-meta (rpcId 101)");
}
#[tokio::test]
async fn request_meta_invalid_missing_protocol_version_is_32602() {
let (addr, handle) = spawn().await;
let response = post(
addr,
&discover_headers(V2),
&discover_body(
json!(102),
Some(canonical_meta_without(META_PROTOCOL_VERSION)),
),
)
.await;
teardown(handle, ()).await;
assert_rejected(
&response,
INVALID_PARAMS,
102,
"missing-protocol-version (rpcId 102)",
);
}
#[tokio::test]
async fn request_meta_invalid_missing_client_capabilities_is_32602() {
let (addr, handle) = spawn().await;
let response = post(
addr,
&discover_headers(V2),
&discover_body(
json!(104),
Some(canonical_meta_without(META_CLIENT_CAPABILITIES)),
),
)
.await;
teardown(handle, ()).await;
assert_rejected(
&response,
INVALID_PARAMS,
104,
"missing-client-capabilities (rpcId 104)",
);
}
#[tokio::test]
async fn request_meta_client_info_optional_is_served() {
let (addr, handle) = spawn().await;
let response = post(
addr,
&discover_headers(V2),
&discover_body(json!(105), Some(canonical_meta_without(META_CLIENT_INFO))),
)
.await;
teardown(handle, ()).await;
assert_served(&response, "clientInfo-optional (rpcId 105)");
}
#[tokio::test]
async fn server_unsupported_version_error_is_32022_with_data() {
let (addr, handle) = spawn().await;
let response = post(
addr,
&discover_headers(UNSUPPORTED_VERSION),
&discover_body(
json!(301),
Some(canonical_meta_claiming(UNSUPPORTED_VERSION)),
),
)
.await;
teardown(handle, ()).await;
assert_rejected(
&response,
UNSUPPORTED_PROTOCOL_VERSION,
301,
"unsupported-version (rpcId 301)",
);
let supported = response.body["error"]["data"]["supported"].as_array();
assert!(
supported.is_some_and(|versions| !versions.is_empty()),
"-32022 MUST carry a NON-EMPTY error.data.supported array; raw: {}",
response.raw
);
assert_eq!(
response.body["error"]["data"]["requested"], UNSUPPORTED_VERSION,
"error.data.requested must echo the requested version; raw: {}",
response.raw
);
}
#[tokio::test]
async fn http_server_header_mismatch_400_is_32020() {
let (addr, handle) = spawn().await;
let response = post(
addr,
&discover_headers(V2),
&discover_body(
json!(302),
Some(canonical_meta_claiming(UNSUPPORTED_VERSION)),
),
)
.await;
teardown(handle, ()).await;
assert_rejected(
&response,
HEADER_MISMATCH,
302,
"header-mismatch (rpcId 302)",
);
}
#[cfg(feature = "v1-compat")]
async fn v1_session_headers(addr: SocketAddr) -> Vec<(String, String)> {
use common::v2::v1_body;
let init = post(
addr,
&[],
&v1_body(
"initialize",
json!(700),
json!({
"protocolVersion": V1,
"capabilities": {},
"clientInfo": { "name": "v1-client", "version": "1.0.0" },
}),
),
)
.await;
let session = init
.mcp_session_id
.clone()
.expect("a v1 initialize on a stateful server MUST mint a session id");
vec![
header("mcp-session-id", &session),
header(MCP_PROTOCOL_VERSION, V1),
]
}
#[cfg(feature = "v1-compat")]
#[tokio::test]
async fn v1_requests_omitting_client_capabilities_are_still_served() {
let (addr, handle) = spawn().await;
let session = v1_session_headers(addr).await;
let bare = post(
addr,
&session,
&matrix_body_with_id("tools/list", json!(701), None),
)
.await;
let with_v1_meta = post(
addr,
&session,
&matrix_body_with_id(
"tools/list",
json!(702),
Some(json!({
META_PROTOCOL_VERSION: V1,
META_CLIENT_INFO: { "name": "v1-client", "version": "1.0.0" },
})),
),
)
.await;
teardown(handle, ()).await;
for (label, response) in [
("no _meta at all", &bare),
("v1 _meta with no clientCapabilities", &with_v1_meta),
] {
assert_served(response, &format!("v1 tools/list ({label})"));
assert!(
response.body["result"]["tools"].is_array(),
"v1 ({label}) must reach dispatch, not the v2 gate; raw: {}",
response.raw
);
}
}
#[cfg(feature = "v1-compat")]
mod referee_matrix {
use super::{
default_client_capabilities, header, matrix_body_with_id, post, spawn, teardown,
v1_session_headers, ProptestConfig, Resp, TestRunner, HEADER_MISMATCH, INVALID_PARAMS,
MCP_METHOD, MCP_PROTOCOL_VERSION, META_CLIENT_CAPABILITIES, META_CLIENT_INFO,
META_PROTOCOL_VERSION, UNSUPPORTED_PROTOCOL_VERSION, UNSUPPORTED_VERSION, V1, V2,
};
use proptest::prelude::{prop_oneof, Just, Strategy};
use serde_json::{json, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HeaderCase {
V2,
Legacy,
Unsupported,
Absent,
}
impl HeaderCase {
fn value(self) -> Option<&'static str> {
match self {
Self::V2 => Some(V2),
Self::Legacy => Some(V1),
Self::Unsupported => Some(UNSUPPORTED_VERSION),
Self::Absent => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MetaCase {
Absent,
NoVersion,
V2Version,
LegacyVersion,
UnsupportedVersion,
}
impl MetaCase {
fn version(self) -> Option<&'static str> {
match self {
Self::Absent | Self::NoVersion => None,
Self::V2Version => Some(V2),
Self::LegacyVersion => Some(V1),
Self::UnsupportedVersion => Some(UNSUPPORTED_VERSION),
}
}
fn build(self, caps: bool) -> Option<Value> {
if self == Self::Absent {
return None;
}
let mut meta = serde_json::Map::new();
if let Some(version) = self.version() {
meta.insert(META_PROTOCOL_VERSION.to_string(), json!(version));
}
meta.insert(
META_CLIENT_INFO.to_string(),
json!({ "name": "pmcp-conformance-probe", "version": "0.0.0" }),
);
if caps {
meta.insert(
META_CLIENT_CAPABILITIES.to_string(),
default_client_capabilities(),
);
}
Some(Value::Object(meta))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Verdict {
Served,
Rejected(i32),
}
fn referee_verdict(header: HeaderCase, meta: MetaCase, caps: bool) -> Verdict {
let meta_version = meta.version();
let signals_v2 = header == HeaderCase::V2 || meta_version == Some(V2);
if signals_v2 {
if meta == MetaCase::Absent {
return Verdict::Rejected(INVALID_PARAMS);
}
let Some(version) = meta_version else {
return Verdict::Rejected(INVALID_PARAMS);
};
if !(header == HeaderCase::V2 && version == V2) {
return Verdict::Rejected(HEADER_MISMATCH);
}
if !caps {
return Verdict::Rejected(INVALID_PARAMS);
}
return Verdict::Served;
}
if matches!(meta_version, Some(v) if v != V1) {
return Verdict::Rejected(UNSUPPORTED_PROTOCOL_VERSION);
}
if header == HeaderCase::Unsupported {
return Verdict::Rejected(pmcp::types::protocol::error_codes::INVALID_REQUEST);
}
Verdict::Served
}
fn header_strategy() -> impl Strategy<Value = HeaderCase> {
prop_oneof![
Just(HeaderCase::V2),
Just(HeaderCase::Legacy),
Just(HeaderCase::Unsupported),
Just(HeaderCase::Absent),
]
}
fn meta_strategy() -> impl Strategy<Value = MetaCase> {
prop_oneof![
Just(MetaCase::Absent),
Just(MetaCase::NoVersion),
Just(MetaCase::V2Version),
Just(MetaCase::LegacyVersion),
Just(MetaCase::UnsupportedVersion),
]
}
fn expected_status(verdict: Verdict) -> u16 {
match verdict {
Verdict::Served => 200,
Verdict::Rejected(_) => 400,
}
}
fn observed_verdict(response: &Resp) -> Verdict {
response.body["error"]["code"].as_i64().map_or(
Verdict::Served,
#[allow(clippy::cast_possible_truncation)]
|code| Verdict::Rejected(code as i32),
)
}
#[cfg(feature = "v1-compat")]
#[test]
fn the_six_row_rule_holds_over_the_whole_header_meta_matrix() {
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime builds");
let (addr, handle) = runtime.block_on(spawn());
let session = runtime.block_on(v1_session_headers(addr));
let config = ProptestConfig {
cases: 120,
..ProptestConfig::default()
};
let outcome = TestRunner::new(config).run(
&(header_strategy(), meta_strategy(), proptest::bool::ANY),
|(header_case, meta, caps)| {
let expected = referee_verdict(header_case, meta, caps);
let mut headers = session.clone();
headers.push(header(MCP_METHOD, "tools/list"));
if let Some(version) = header_case.value() {
headers.retain(|(name, _)| !name.eq_ignore_ascii_case(MCP_PROTOCOL_VERSION));
headers.push(header(MCP_PROTOCOL_VERSION, version));
} else {
headers.retain(|(name, _)| !name.eq_ignore_ascii_case(MCP_PROTOCOL_VERSION));
}
let body = matrix_body_with_id("tools/list", json!(1), meta.build(caps));
let response = runtime.block_on(post(addr, &headers, &body));
let label =
format!("cell (header={header_case:?}, meta={meta:?}, caps={caps}) disagreed");
proptest::prop_assert_eq!(
observed_verdict(&response),
expected,
"{} with the referee's rule; raw: {}",
label,
response.raw
);
proptest::prop_assert_eq!(
response.status,
expected_status(expected),
"{} with the referee's HTTP status; raw: {}",
label,
response.raw
);
Ok(())
},
);
runtime.block_on(teardown(handle, ()));
outcome.expect("every matrix cell must match the referee's rule");
}
}