use async_trait::async_trait;
use pmcp::server::streamable_http_server::{StreamableHttpServer, StreamableHttpServerConfig};
use pmcp::server::typed_tool::TypedToolWithOutput;
use pmcp::server::{ResourceHandler, Server};
use pmcp::shared::http_constants::{MCP_METHOD, MCP_NAME, MCP_PROTOCOL_VERSION, MCP_SESSION_ID};
use pmcp::testing::{META_CLIENT_CAPABILITIES, META_CLIENT_INFO, META_PROTOCOL_VERSION};
use pmcp::types::protocol::{
ProtocolVersion, RequestMeta, LATEST_PROTOCOL_VERSION, PROTOCOL_VERSION_2026_07_28,
};
use pmcp::types::{
CacheScope, CallToolResult, Content, ListResourcesResult, ReadResourceResult, ResourceInfo,
DEFAULT_TTL_MS,
};
use pmcp::RequestHandlerExtra;
use serde_json::{json, Value};
use std::net::{Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
const CATALOGUE_TTL_MS: u64 = 300_000;
const CATALOGUE_URI: &str = "docs://catalogue";
const PROFILE_URI: &str = "docs://me/profile";
struct CatalogueAndProfile;
#[async_trait]
impl ResourceHandler for CatalogueAndProfile {
async fn list(
&self,
_cursor: Option<String>,
_extra: RequestHandlerExtra,
) -> pmcp::Result<ListResourcesResult> {
Ok(ListResourcesResult::new(vec![
ResourceInfo::new(CATALOGUE_URI, "catalogue")
.with_description("A public document catalogue — identical for every caller"),
ResourceInfo::new(PROFILE_URI, "profile")
.with_description("The calling user's own profile — never shareable"),
])
.with_ttl_ms(CATALOGUE_TTL_MS)
.with_cache_scope(CacheScope::Public))
}
async fn read(
&self,
uri: &str,
_extra: RequestHandlerExtra,
) -> pmcp::Result<ReadResourceResult> {
Ok(ReadResourceResult::new(vec![Content::text(format!(
"contents of {uri}"
))]))
}
}
fn build_server() -> Server {
let answer = TypedToolWithOutput::new_with_schemas(
"answer".to_string(),
json!({ "type": "object" }),
Some(json!({ "type": "integer" })),
|_args: Value, _extra| Box::pin(async move { Ok(json!(42)) }),
);
Server::builder()
.name("s52-caching-hints")
.version("1.0.0")
.with_supported_protocol_versions([
ProtocolVersion(LATEST_PROTOCOL_VERSION.to_string()),
ProtocolVersion(PROTOCOL_VERSION_2026_07_28.to_string()),
])
.tool("answer", answer)
.resources(CatalogueAndProfile)
.build()
.expect("server builds")
}
async fn post_v2(
client: &reqwest::Client,
addr: SocketAddr,
method: &str,
name: &str,
id: u64,
params: Value,
) -> Value {
let meta = RequestMeta::new()
.with_meta(META_PROTOCOL_VERSION, json!(PROTOCOL_VERSION_2026_07_28))
.with_meta(
META_CLIENT_INFO,
json!({ "name": "s52-example", "version": "1.0.0" }),
)
.with_meta(META_CLIENT_CAPABILITIES, json!({}));
let mut params = params;
if let Some(object) = params.as_object_mut() {
object.insert(
"_meta".to_string(),
serde_json::to_value(&meta).expect("request meta serializes"),
);
}
let body = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
let response = client
.post(format!("http://{addr}"))
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream")
.header(MCP_METHOD, method)
.header(MCP_NAME, name)
.header(MCP_PROTOCOL_VERSION, PROTOCOL_VERSION_2026_07_28)
.body(body.to_string())
.send()
.await
.expect("the loopback server answers");
parse(response, method).await
}
async fn post_v1(
client: &reqwest::Client,
addr: SocketAddr,
method: &str,
id: u64,
params: Value,
session: Option<&str>,
) -> (Option<String>, Value) {
let body = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
let mut request = client
.post(format!("http://{addr}"))
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream");
if let Some(session_id) = session {
request = request.header(MCP_SESSION_ID, session_id);
}
let response = request
.body(body.to_string())
.send()
.await
.expect("the loopback server answers");
let minted = response
.headers()
.get(MCP_SESSION_ID)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
(minted, parse(response, method).await)
}
async fn parse(response: reqwest::Response, method: &str) -> Value {
let status = response.status();
let raw = response.text().await.unwrap_or_default();
assert!(
status.is_success(),
"{method} returned HTTP {status}; body was {raw}"
);
let payload = raw
.lines()
.find_map(|line| line.strip_prefix("data: "))
.unwrap_or(&raw);
let body: Value =
serde_json::from_str(payload).unwrap_or_else(|e| panic!("{method}: {e}; body was {raw}"));
assert!(
body.get("error").is_none(),
"{method} returned a JSON-RPC error: {body}"
);
body.get("result")
.cloned()
.unwrap_or_else(|| panic!("{method} carries no result: {body}"))
}
fn show(label: &str, result: &Value) {
println!(" {label}");
println!(
" ttlMs = {}",
result
.get("ttlMs")
.map_or_else(|| "<absent>".to_string(), ToString::to_string)
);
println!(
" cacheScope = {}",
result
.get("cacheScope")
.map_or_else(|| "<absent>".to_string(), ToString::to_string)
);
println!(" raw = {result}");
}
fn demonstrate_structured_value_constructor() {
let result = CallToolResult::structured_value(json!(42));
assert_eq!(
result.structured_content,
Some(json!(42)),
"structured_value must carry a bare scalar verbatim"
);
let wire = serde_json::to_string(&result).expect("a result serializes");
assert!(
wire.contains(r#""structuredContent":42"#),
"the scalar must reach the wire unwrapped, got {wire}"
);
println!(" CallToolResult::structured_value(json!(42)) => {wire}");
let null_result = CallToolResult::structured_value(Value::Null);
let null_wire = serde_json::to_string(&null_result).expect("a result serializes");
assert!(
null_wire.contains(r#""structuredContent":null"#),
"Some(Value::Null) must emit an explicit null rather than be omitted, got {null_wire}"
);
println!(" CallToolResult::structured_value(json!(null)) => {null_wire}");
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let server = Arc::new(Mutex::new(build_server()));
let bind = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 0);
let (addr, http) = StreamableHttpServer::with_config(
bind,
Arc::clone(&server),
StreamableHttpServerConfig::default(),
)
.start()
.await?;
println!("server listening on http://{addr}/\n");
let client = reqwest::Client::new();
println!("1. resources/list — the HANDLER set the posture (v2)");
let list = post_v2(&client, addr, "resources/list", "", 1, json!({})).await;
show("resources/list", &list);
assert_eq!(
list.get("ttlMs"),
Some(&json!(CATALOGUE_TTL_MS)),
"the handler's five-minute freshness hint must survive the projection verbatim"
);
assert_eq!(
list.get("cacheScope"),
Some(&json!("public")),
"the handler's scope must survive the projection verbatim"
);
println!(" -> `public` authorizes a shared gateway to serve this body");
println!(" across authorization contexts. Correct here (the catalogue is identical");
println!(" for every caller); a data leak on a per-user body.\n");
println!("2. resources/read and tools/list — the SDK DEFAULT (v2)");
let read = post_v2(
&client,
addr,
"resources/read",
PROFILE_URI,
2,
json!({ "uri": PROFILE_URI }),
)
.await;
show("resources/read", &read);
let tools = post_v2(&client, addr, "tools/list", "", 3, json!({})).await;
show("tools/list", &tools);
for (label, result) in [("resources/read", &read), ("tools/list", &tools)] {
assert_eq!(
result.get("ttlMs"),
Some(&json!(DEFAULT_TTL_MS)),
"{label} must carry the SDK default ttlMs on v2"
);
assert_eq!(
result.get("cacheScope"),
Some(&json!(CacheScope::default())),
"{label} must carry the SDK default cacheScope on v2"
);
}
println!(" -> `ttlMs: 0` means \"immediately stale\", so the default asserts NOTHING");
println!(" about cacheability — inert, yet the v2 wire's required keys are present.\n");
println!("3. the SAME server answering a 2025-11-25 client — NEITHER key");
let (session, _init) = post_v1(
&client,
addr,
"initialize",
4,
json!({
"protocolVersion": LATEST_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": { "name": "s52-example", "version": "1.0.0" }
}),
None,
)
.await;
let session = session.expect("a 2025-11-25 initialize mints an Mcp-Session-Id");
println!(" (v1 handshake completed; session {session} — v2 needed neither)");
let (_, v1_list) = post_v1(
&client,
addr,
"resources/list",
5,
json!({}),
Some(&session),
)
.await;
show("resources/list (v1)", &v1_list);
let (_, v1_tools) = post_v1(&client, addr, "tools/list", 6, json!({}), Some(&session)).await;
show("tools/list (v1)", &v1_tools);
for (label, result) in [("resources/list", &v1_list), ("tools/list", &v1_tools)] {
assert!(
result.get("ttlMs").is_none(),
"{label}: a v1 response must never carry ttlMs, got {result}"
);
assert!(
result.get("cacheScope").is_none(),
"{label}: a v1 response must never carry cacheScope, got {result}"
);
}
println!(" -> the handler SET a hint on resources/list, and the v1 projection STRIPPED it.");
println!(" Not \"did not add\" — actively removed, so the legacy wire is unchanged.\n");
println!("4. tools/call — NON-OBJECT structuredContent (v2)");
let call = post_v2(
&client,
addr,
"tools/call",
"answer",
7,
json!({ "name": "answer", "arguments": {} }),
)
.await;
println!(" raw = {call}");
assert_eq!(
call.get("structuredContent"),
Some(&json!(42)),
"a tool declaring {{\"type\": \"integer\"}} must emit a BARE scalar, not an object \
wrapper, got {call}"
);
assert!(
call.get("ttlMs").is_none() && call.get("cacheScope").is_none(),
"tools/call is not a CacheableResult and must carry neither hint, got {call}"
);
println!(" -> `structuredContent: 42`. A scalar, not `{{\"value\": 42}}`.");
demonstrate_structured_value_constructor();
http.abort();
tokio::time::sleep(Duration::from_millis(50)).await;
println!("\nall four demonstrations asserted — exiting 0");
Ok(())
}