#![cfg(all(
feature = "streamable-http",
feature = "http-client",
not(target_arch = "wasm32")
))]
mod common;
#[path = "common/duplex.rs"]
mod duplex;
use async_trait::async_trait;
use common::v2::{
post, spawn_stateless_config, teardown, v1_body, v2_body, v2_headers, Resp, V1, V2,
};
use pmcp::server::typed_tool::TypedTool;
use pmcp::server::{PromptHandler, ResourceHandler, Server};
use pmcp::types::protocol::error_codes::{INVALID_REQUEST, METHOD_NOT_FOUND};
use pmcp::types::protocol::ProtocolVersion;
use pmcp::types::CacheScope;
use pmcp::types::{
Content, GetPromptResult, ListResourcesResult, PromptInfo, ReadResourceResult, ResourceInfo,
};
use pmcp::RequestHandlerExtra;
use serde_json::{json, Value};
use std::collections::HashMap;
const RESULT_TYPE_KEY: &str = "resultType";
const TTL_MS_KEY: &str = "ttlMs";
const CACHE_SCOPE_KEY: &str = "cacheScope";
const DEFAULT_TTL_MS: u64 = pmcp::types::DEFAULT_TTL_MS;
fn default_cache_scope() -> String {
serde_json::to_value(CacheScope::default())
.expect("a unit enum always serializes")
.as_str()
.expect("CacheScope serializes to a JSON string")
.to_string()
}
fn result_of<'a>(response: &'a Resp, ctx: &str) -> &'a Value {
assert_eq!(
response.status, 200,
"{ctx}: expected HTTP 200, raw response was: {}",
response.raw
);
response.body.get("result").unwrap_or_else(|| {
panic!(
"{ctx}: the response carries no `result` at all, raw response was: {}",
response.raw
)
})
}
fn assert_v2_era_witness(response: &Resp, ctx: &str) {
let result = result_of(response, ctx);
assert!(
result.get(RESULT_TYPE_KEY).is_some(),
"{ctx}: no `{RESULT_TYPE_KEY}` in the result, so the dispatcher did NOT resolve Era::V2 \
for this request. Every caching-hint assertion after this line would be measuring the \
v1 path under a v2 test name. Check the fixture's \
`with_supported_protocol_versions` opt-in and the request's `_meta` \
protocol-version signal. Raw response was: {}",
response.raw
);
}
fn assert_no_v2_era_witness(response: &Resp, ctx: &str) {
let result = result_of(response, ctx);
assert!(
result.get(RESULT_TYPE_KEY).is_none(),
"{ctx}: found `{RESULT_TYPE_KEY}` in the result, so the dispatcher resolved Era::V2 for a \
request that was supposed to be served as v1. Raw response was: {}",
response.raw
);
}
fn assert_default_hints(response: &Resp, ctx: &str) {
assert_hints(response, ctx, DEFAULT_TTL_MS, &default_cache_scope());
}
fn assert_hints(response: &Resp, ctx: &str, ttl_ms: u64, cache_scope: &str) {
let result = result_of(response, ctx);
let sdk_default_scope = default_cache_scope();
assert_eq!(
result.get(TTL_MS_KEY),
Some(&json!(ttl_ms)),
"{ctx}: D-07 makes `{TTL_MS_KEY}` REQUIRED on every v2 `CacheableResult`, and D-08 fixes \
the SDK default at {DEFAULT_TTL_MS} (immediately stale, which asserts nothing about \
cacheability). Expected {ttl_ms}. Raw response was: {}",
response.raw
);
assert_eq!(
result.get(CACHE_SCOPE_KEY),
Some(&json!(cache_scope)),
"{ctx}: D-07 makes `{CACHE_SCOPE_KEY}` REQUIRED on every v2 `CacheableResult`, and D-08 \
fixes the SDK default at `{sdk_default_scope}` — marking an un-considered response \
`public` authorizes a shared gateway to serve one caller's body to another caller \
holding a different access token. Expected `{cache_scope}`. Raw response was: {}",
response.raw
);
assert!(
response.raw.contains(r#""ttlMs""#),
"{ctx}: the RAW wire must spell the key `ttlMs` (camelCase). A struct-level \
`rename_all` regression emitting `ttl_ms` is invisible to a parsed-value assertion. \
Raw response was: {}",
response.raw
);
assert!(
response.raw.contains(r#""cacheScope""#),
"{ctx}: the RAW wire must spell the key `cacheScope` (camelCase). A struct-level \
`rename_all` regression emitting `cache_scope` is invisible to a parsed-value \
assertion. Raw response was: {}",
response.raw
);
}
fn leaked_hint_key(wire: &str) -> Option<&'static str> {
[TTL_MS_KEY, CACHE_SCOPE_KEY]
.into_iter()
.find(|key| wire.contains(key))
}
fn assert_no_hints_in(wire: &str, ctx: &str) {
assert!(
leaked_hint_key(wire).is_none(),
"{ctx}: the response carries the SCHM-03 caching hint \
`{}` where it must carry neither. D-11 era-gates the hints OFF on v1, and a v1 \
response carrying a v2 field breaks this milestone's severability story: Phases \
116-119 all rest on v1 responses staying byte-identical. Fix the projection — never \
relax this assertion. Wire was: {wire}",
leaked_hint_key(wire).unwrap_or("<none>")
);
}
fn assert_no_hints(response: &Resp, ctx: &str) {
assert_no_hints_in(&response.raw, ctx);
}
#[test]
fn v2_caching_hints_the_no_hints_guard_is_load_bearing() {
const CLEAN: &str = r#"{"jsonrpc":"2.0","id":1,"result":{"contents":[],"nextCursor":"c"}}"#;
assert_eq!(
leaked_hint_key(CLEAN),
None,
"a clean wire must PASS the guard — one that rejects everything would satisfy the leak \
cases below while proving nothing"
);
for (key, wire) in [
(
TTL_MS_KEY,
r#"{"jsonrpc":"2.0","id":1,"result":{"contents":[],"ttlMs":0}}"#,
),
(
CACHE_SCOPE_KEY,
r#"{"jsonrpc":"2.0","id":1,"result":{"contents":[],"cacheScope":"private"}}"#,
),
] {
assert_eq!(
leaked_hint_key(wire),
Some(key),
"the guard must REJECT a wire carrying `{key}` and must NAME it, so a future reader \
knows which field leaked"
);
}
}
const HINT_FREE_URI: &str = "hints://free/one.txt";
struct HintFreeResources;
#[async_trait]
impl ResourceHandler for HintFreeResources {
async fn read(
&self,
uri: &str,
_extra: RequestHandlerExtra,
) -> pmcp::Result<ReadResourceResult> {
Ok(ReadResourceResult::new(vec![Content::resource_with_text(
uri,
"a hint-free resource body",
"text/plain",
)]))
}
async fn list(
&self,
_cursor: Option<String>,
_extra: RequestHandlerExtra,
) -> pmcp::Result<ListResourcesResult> {
Ok(ListResourcesResult::new(vec![
ResourceInfo::new(HINT_FREE_URI, "one").with_mime_type("text/plain"),
ResourceInfo::new("hints://free/two.txt", "two").with_mime_type("text/plain"),
]))
}
}
fn fixture_tool(name: &'static str) -> impl pmcp::ToolHandler {
TypedTool::new_with_schema(name, json!({ "type": "object" }), |_args: Value, _extra| {
Box::pin(async { Ok(json!({ "ok": true })) })
})
.with_description("a hint-free fixture tool")
}
struct FixturePrompt(&'static str);
#[async_trait]
impl PromptHandler for FixturePrompt {
async fn handle(
&self,
_args: HashMap<String, String>,
_extra: RequestHandlerExtra,
) -> pmcp::Result<GetPromptResult> {
Ok(GetPromptResult::new(vec![], None))
}
fn metadata(&self) -> Option<PromptInfo> {
Some(PromptInfo::new(self.0).with_description("a hint-free fixture prompt"))
}
}
const TOOL_ALPHA: &str = "hint_free_alpha";
const PROMPT_ONE: &str = "hint_free_one";
fn hint_free_server() -> Server {
hint_free_builder(true)
}
fn not_opted_in_server() -> Server {
hint_free_builder(false)
}
fn hint_free_builder(opt_in_v2: bool) -> Server {
let mut builder = Server::builder().name("v2-caching-hints").version("1.0.0");
if opt_in_v2 {
builder = builder.with_supported_protocol_versions([
ProtocolVersion(V1.to_string()),
ProtocolVersion(V2.to_string()),
]);
}
builder
.tool(TOOL_ALPHA, fixture_tool(TOOL_ALPHA))
.tool("hint_free_beta", fixture_tool("hint_free_beta"))
.prompt(PROMPT_ONE, FixturePrompt(PROMPT_ONE))
.prompt("hint_free_two", FixturePrompt("hint_free_two"))
.resources(HintFreeResources)
.build()
.expect("the hint-free caching fixture server builds")
}
const HINTED_URI: &str = "hints://set/one.txt";
const LIST_TTL_MS: u64 = 300_000;
const READ_TTL_MS: u64 = 60_000;
struct HintedResources;
#[async_trait]
impl ResourceHandler for HintedResources {
async fn read(
&self,
uri: &str,
_extra: RequestHandlerExtra,
) -> pmcp::Result<ReadResourceResult> {
Ok(ReadResourceResult::new(vec![Content::resource_with_text(
uri,
"a hinted resource body",
"text/plain",
)])
.with_ttl_ms(READ_TTL_MS)
.with_cache_scope(CacheScope::Private))
}
async fn list(
&self,
_cursor: Option<String>,
_extra: RequestHandlerExtra,
) -> pmcp::Result<ListResourcesResult> {
Ok(ListResourcesResult::new(vec![
ResourceInfo::new(HINTED_URI, "one").with_mime_type("text/plain")
])
.with_ttl_ms(LIST_TTL_MS)
.with_cache_scope(CacheScope::Public))
}
}
fn hinted_server() -> Server {
Server::builder()
.name("v2-caching-hints-set")
.version("1.0.0")
.with_supported_protocol_versions([
ProtocolVersion(V1.to_string()),
ProtocolVersion(V2.to_string()),
])
.resources(HintedResources)
.build()
.expect("the handler-set caching fixture server builds")
}
async fn round_trip(server: Server, headers: &[(String, String)], body: &str) -> Resp {
let (addr, handle) = spawn_stateless_config(server).await;
let response = post(addr, headers, body).await;
teardown(handle, ()).await;
response
}
async fn v2_round_trip(method: &str, name: &str, id: i64, params: Value) -> Resp {
round_trip(
hint_free_server(),
&v2_headers(method, name),
&v2_body(method, json!(id), params),
)
.await
}
async fn v1_round_trip(method: &str, id: i64, params: Value) -> Resp {
round_trip(hint_free_server(), &[], &v1_body(method, json!(id), params)).await
}
#[tokio::test]
async fn v2_caching_hints_tools_list_carries_the_defaults() {
let response = v2_round_trip("tools/list", "", 1, json!({})).await;
assert_v2_era_witness(&response, "v2 tools/list");
assert_default_hints(&response, "v2 tools/list");
}
#[tokio::test]
async fn v2_caching_hints_prompts_list_carries_the_defaults() {
let response = v2_round_trip("prompts/list", "", 2, json!({})).await;
assert_v2_era_witness(&response, "v2 prompts/list");
assert_default_hints(&response, "v2 prompts/list");
}
#[tokio::test]
async fn v2_caching_hints_resources_list_carries_the_defaults() {
let response = v2_round_trip("resources/list", "", 3, json!({})).await;
assert_v2_era_witness(&response, "v2 resources/list");
assert_default_hints(&response, "v2 resources/list");
}
#[tokio::test]
async fn v2_caching_hints_resources_templates_list_carries_the_defaults() {
let response = v2_round_trip("resources/templates/list", "", 4, json!({})).await;
assert_v2_era_witness(&response, "v2 resources/templates/list");
assert_default_hints(&response, "v2 resources/templates/list");
}
#[tokio::test]
async fn v2_caching_hints_resources_read_carries_the_defaults() {
let response = v2_round_trip(
"resources/read",
HINT_FREE_URI,
5,
json!({ "uri": HINT_FREE_URI }),
)
.await;
assert_v2_era_witness(&response, "v2 resources/read");
assert_default_hints(&response, "v2 resources/read");
}
#[tokio::test]
async fn v2_caching_hints_discover_is_the_sixth_cacheable_result() {
let response = v2_round_trip("server/discover", "", 6, json!({})).await;
assert_v2_era_witness(&response, "v2 server/discover");
assert_default_hints(&response, "v2 server/discover");
}
#[tokio::test]
async fn v2_caching_hints_non_cacheable_methods_gain_neither_key() {
let response = v2_round_trip(
"tools/call",
TOOL_ALPHA,
7,
json!({ "name": TOOL_ALPHA, "arguments": {} }),
)
.await;
assert_v2_era_witness(&response, "v2 tools/call (non-cacheable)");
assert_no_hints(
&response,
"v2 tools/call is not a CacheableResult (D-07), so it must gain neither key",
);
}
#[tokio::test]
async fn v2_caching_hints_v1_methods_gain_neither_key() {
for (id, method, params) in [
(11_i64, "tools/list", json!({})),
(12, "prompts/list", json!({})),
(13, "resources/list", json!({})),
(14, "resources/templates/list", json!({})),
(15, "resources/read", json!({ "uri": HINT_FREE_URI })),
] {
let response = v1_round_trip(method, id, params).await;
let ctx = format!("v1 {method}");
assert_no_v2_era_witness(&response, &ctx);
assert_no_hints(&response, &ctx);
}
let discover = v1_round_trip("server/discover", 16, json!({})).await;
assert_eq!(
discover.body["error"]["code"], METHOD_NOT_FOUND,
"server/discover is v2-only (D-10); a v1 request must be method-not-found, raw: {}",
discover.raw
);
assert_no_hints(&discover, "v1 server/discover");
}
#[tokio::test]
async fn v2_caching_hints_the_v2_era_witness_is_load_bearing() {
let as_v2 = round_trip(
hint_free_server(),
&v2_headers("tools/list", ""),
&v2_body("tools/list", json!(21), json!({})),
)
.await;
assert_v2_era_witness(&as_v2, "opted-in server, v2-signalling tools/list");
assert_default_hints(&as_v2, "opted-in server, v2-signalling tools/list");
let as_v1 = round_trip(
hint_free_server(),
&[],
&v1_body("tools/list", json!(22), json!({})),
)
.await;
assert_no_v2_era_witness(&as_v1, "the SAME opted-in server, v1-signalling tools/list");
assert_no_hints(
&as_v1,
"the SAME opted-in server serves a v1-signalling request as v1, so the projection STRIPS",
);
}
#[tokio::test]
async fn v2_caching_hints_a_non_opted_in_server_refuses_a_v2_request_over_http() {
let refused = round_trip(
not_opted_in_server(),
&v2_headers("tools/list", ""),
&v2_body("tools/list", json!(23), json!({})),
)
.await;
assert_eq!(
refused.status, 400,
"a non-opted-in server must REFUSE a v2 request at the HTTP boundary, raw: {}",
refused.raw
);
assert_eq!(
refused.body["error"]["code"], INVALID_REQUEST,
"the refusal is the transport's unsupported-protocol-version gate, raw: {}",
refused.raw
);
assert!(
refused.body["error"]["message"]
.as_str()
.is_some_and(|message| message.contains("Unsupported protocol version")),
"the refusal must name the version gate rather than some other -32600, raw: {}",
refused.raw
);
assert!(
refused.body.get("result").is_none(),
"a refusal carries no result, so it cannot carry a projected hint either, raw: {}",
refused.raw
);
assert_no_hints(&refused, "a refused v2 request");
}
#[tokio::test]
async fn v2_caching_hints_handler_set_values_reach_the_wire_unmodified() {
let list = round_trip(
hinted_server(),
&v2_headers("resources/list", ""),
&v2_body("resources/list", json!(31), json!({})),
)
.await;
assert_v2_era_witness(&list, "v2 resources/list, handler-set");
assert_hints(
&list,
"v2 resources/list, handler-set",
LIST_TTL_MS,
"public",
);
let read = round_trip(
hinted_server(),
&v2_headers("resources/read", HINTED_URI),
&v2_body("resources/read", json!(32), json!({ "uri": HINTED_URI })),
)
.await;
assert_v2_era_witness(&read, "v2 resources/read, handler-set");
assert_hints(
&read,
"v2 resources/read, handler-set",
READ_TTL_MS,
"private",
);
assert!(
list.raw.contains(r#""ttlMs":300000"#) && list.raw.contains(r#""cacheScope":"public""#),
"the handler-set list pair must reach the v2 wire verbatim, raw: {}",
list.raw
);
assert!(
read.raw.contains(r#""ttlMs":60000"#) && read.raw.contains(r#""cacheScope":"private""#),
"the handler-set read pair must reach the v2 wire verbatim, raw: {}",
read.raw
);
}
#[tokio::test]
async fn v2_caching_hints_v1_strips_handler_set_values() {
for (id, method, params) in [
(33_i64, "resources/list", json!({})),
(34, "resources/read", json!({ "uri": HINTED_URI })),
] {
let response = round_trip(hinted_server(), &[], &v1_body(method, json!(id), params)).await;
let ctx = format!("v1 {method} against a handler that SET both hints");
assert_no_v2_era_witness(&response, &ctx);
assert_no_hints(&response, &ctx);
}
}
mod server_core {
use super::duplex::{
assert_no_v2_witness, assert_v2_witness, call_tool_request, initialize_via_core,
raw_via_core, read_resource_request, result_object, v2_accept_list,
};
use super::{
assert_no_hints_in, default_cache_scope, fixture_tool, HintFreeResources, HintedResources,
CACHE_SCOPE_KEY, DEFAULT_TTL_MS, HINTED_URI, HINT_FREE_URI, READ_TTL_MS, TOOL_ALPHA,
TTL_MS_KEY, V2,
};
use pmcp::server::builder::ServerCoreBuilder;
use pmcp::server::core::ProtocolHandler;
use pmcp::types::jsonrpc::JSONRPCResponse;
use pmcp::types::protocol::error_codes::V1_TASK_PENDING;
use pmcp::types::protocol::Era;
use pmcp::types::{ClientRequest, Request};
use serde_json::json;
use std::sync::Arc;
fn hint_free_core() -> Arc<dyn ProtocolHandler> {
Arc::new(
ServerCoreBuilder::new()
.name("v2-caching-hints-core")
.version("1.0.0")
.with_supported_protocol_versions(v2_accept_list())
.tool(TOOL_ALPHA, fixture_tool(TOOL_ALPHA))
.resources(HintFreeResources)
.build()
.expect("the hint-free caching fixture core builds"),
)
}
fn hinted_core() -> Arc<dyn ProtocolHandler> {
hinted_core_builder(true)
}
fn v1_hinted_core() -> Arc<dyn ProtocolHandler> {
hinted_core_builder(false)
}
fn hinted_core_builder(opt_in_v2: bool) -> Arc<dyn ProtocolHandler> {
let mut builder = ServerCoreBuilder::new()
.name("v2-caching-hints-set-core")
.version("1.0.0");
if opt_in_v2 {
builder = builder.with_supported_protocol_versions(v2_accept_list());
}
Arc::new(
builder
.resources(HintedResources)
.build()
.expect("the handler-set caching fixture core builds"),
)
}
fn assert_hints(response: &JSONRPCResponse, ctx: &str, ttl_ms: u64, cache_scope: &str) {
let result = result_object(response);
assert_eq!(
result.get(TTL_MS_KEY),
Some(&json!(ttl_ms)),
"{ctx}: D-07 makes `{TTL_MS_KEY}` REQUIRED on a v2 `CacheableResult`; expected \
{ttl_ms}, result was: {result:?}"
);
assert_eq!(
result.get(CACHE_SCOPE_KEY),
Some(&json!(cache_scope)),
"{ctx}: D-07 makes `{CACHE_SCOPE_KEY}` REQUIRED on a v2 `CacheableResult`; expected \
`{cache_scope}`, result was: {result:?}"
);
let wire = serde_json::to_string(response).expect("response serializes");
assert!(
wire.contains(r#""ttlMs""#) && wire.contains(r#""cacheScope""#),
"{ctx}: both keys must reach the wire in camelCase, got: {wire}"
);
}
fn assert_no_hints(response: &JSONRPCResponse, ctx: &str) {
let wire = serde_json::to_string(response).expect("response serializes");
assert_no_hints_in(&wire, ctx);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn v2_caching_hints_server_core_resources_read_v2_carries_the_defaults() {
let response = raw_via_core(
hint_free_core(),
read_resource_request(HINT_FREE_URI, Era::V2),
)
.await;
assert_v2_witness(&response, "ServerCore / v2 resources/read, hint-free");
assert_hints(
&response,
"ServerCore / v2 resources/read, hint-free",
DEFAULT_TTL_MS,
&default_cache_scope(),
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn v2_caching_hints_server_core_resources_read_v2_preserves_handler_set_values() {
let response =
raw_via_core(hinted_core(), read_resource_request(HINTED_URI, Era::V2)).await;
assert_v2_witness(&response, "ServerCore / v2 resources/read, handler-set");
assert_hints(
&response,
"ServerCore / v2 resources/read, handler-set",
READ_TTL_MS,
"private",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn v2_caching_hints_server_core_resources_read_v1_strips_handler_set_values() {
let core = v1_hinted_core();
initialize_via_core(&core).await;
let response = raw_via_core(core, read_resource_request(HINTED_URI, Era::V1)).await;
assert_no_v2_witness(&response, "ServerCore / v1 resources/read, handler-set");
assert_no_hints(&response, "ServerCore / v1 resources/read, handler-set");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn v2_caching_hints_server_core_tools_call_gains_neither_key() {
let response = raw_via_core(
hint_free_core(),
call_tool_request(TOOL_ALPHA, json!({}), Era::V2),
)
.await;
assert_v2_witness(&response, "ServerCore / v2 tools/call");
assert_no_hints(
&response,
"ServerCore / v2 tools/call is not a CacheableResult (D-07)",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn v2_caching_hints_list_methods_cannot_reach_v2_through_the_typed_dispatch_route() {
let core = hint_free_core();
let refused = raw_via_core(core.clone(), list_resources_signalling_v2()).await;
let refusal = serde_json::to_string(&refused).expect("response serializes");
assert!(
refusal.contains(&V1_TASK_PENDING.to_string()),
"an opted-in core must still demand the v1 handshake for a `resources/list` \
carrying the v2 `_meta` signal — proof the signal was dropped. Got: {refusal}"
);
initialize_via_core(&core).await;
let response = raw_via_core(core, list_resources_signalling_v2()).await;
assert_no_v2_witness(
&response,
"opted-in ServerCore, `resources/list` carrying the v2 `_meta` signal",
);
assert_no_hints(
&response,
"a `resources/list` that resolved v1 despite signalling v2",
);
}
fn list_resources_signalling_v2() -> Request {
signalling_v2("resources/list", json!({}))
}
fn signalling_v2(method: &str, params: serde_json::Value) -> Request {
let mut params = params;
params.as_object_mut().expect("params is an object").insert(
"_meta".to_string(),
json!({ "io.modelcontextprotocol/protocolVersion": V2 }),
);
let mut envelope = serde_json::Map::new();
envelope.insert("method".to_string(), json!(method));
envelope.insert("params".to_string(), params);
let request: ClientRequest = serde_json::from_value(serde_json::Value::Object(envelope))
.unwrap_or_else(|e| panic!("`{method}` deserializes into ClientRequest ({e})"));
Request::Client(Box::new(request))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn v2_caching_hints_server_core_the_dropped_signal_is_a_real_one() {
let response = raw_via_core(
hint_free_core(),
signalling_v2("resources/read", json!({ "uri": HINT_FREE_URI })),
)
.await;
assert_v2_witness(
&response,
"the SAME `_meta` literal on `resources/read`, a `_meta`-bearing variant",
);
assert_hints(
&response,
"the SAME `_meta` literal on `resources/read`",
DEFAULT_TTL_MS,
&default_cache_scope(),
);
}
}