#![cfg(all(
feature = "streamable-http",
feature = "http-client",
not(target_arch = "wasm32")
))]
#![allow(dead_code)]
use std::collections::HashMap;
use std::net::{Ipv4Addr, SocketAddr};
use std::sync::Arc;
use async_trait::async_trait;
use pmcp::server::streamable_http_server::{StreamableHttpServer, StreamableHttpServerConfig};
use pmcp::server::{PromptHandler, ResourceHandler, Server};
use pmcp::shared::http_constants::{
ACCEPT_STREAMABLE, MCP_METHOD, MCP_NAME, MCP_PROTOCOL_VERSION, MCP_SESSION_ID,
};
use pmcp::types::protocol::{
ProtocolVersion, LATEST_PROTOCOL_VERSION, PROTOCOL_VERSION_2026_07_28,
};
use pmcp::types::{Content, GetPromptResult, ListResourcesResult, ReadResourceResult, RequestMeta};
use pmcp::ServerCapabilities;
use pmcp::{RequestHandlerExtra, ToolHandler};
use serde_json::{json, Value};
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
pub const V2: &str = PROTOCOL_VERSION_2026_07_28;
pub const V1: &str = LATEST_PROTOCOL_VERSION;
pub use pmcp::testing::{META_CLIENT_CAPABILITIES, META_CLIENT_INFO, META_PROTOCOL_VERSION};
pub struct SearchTool;
#[async_trait]
impl ToolHandler for SearchTool {
async fn handle(&self, _args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
Ok(json!({ "answer": "ok" }))
}
}
pub struct GreetingPrompt;
#[async_trait]
impl PromptHandler for GreetingPrompt {
async fn handle(
&self,
_args: HashMap<String, String>,
_extra: RequestHandlerExtra,
) -> pmcp::Result<GetPromptResult> {
Ok(GetPromptResult::new(vec![], Some("greeting".to_string())))
}
}
pub struct GreetingResource;
#[async_trait]
impl ResourceHandler for GreetingResource {
async fn read(
&self,
uri: &str,
_extra: RequestHandlerExtra,
) -> pmcp::Result<ReadResourceResult> {
Ok(ReadResourceResult::new(vec![Content::resource_with_text(
uri.to_string(),
"hello".to_string(),
"text/plain".to_string(),
)]))
}
async fn list(
&self,
_cursor: Option<String>,
_extra: RequestHandlerExtra,
) -> pmcp::Result<ListResourcesResult> {
Ok(ListResourcesResult::new(vec![]))
}
}
pub const TASKS_TOOL_NAME: &str = "long_task";
pub fn long_task_tool() -> impl ToolHandler {
pmcp::server::typed_tool::TypedTool::new_with_schema(
TASKS_TOOL_NAME,
json!({ "type": "object" }),
|_args: Value, _extra| {
Box::pin(async {
Ok(json!({
"taskId": "tool-fabricated",
"status": "working",
"createdAt": "2026-07-28T00:00:00Z",
"lastUpdatedAt": "2026-07-28T00:00:00Z"
}))
})
},
)
.with_description("a task-capable tool whose task stays pending")
.with_execution(
pmcp::types::ToolExecution::new().with_task_support(pmcp::types::TaskSupport::Required),
)
}
pub const COMPLETING_TOOL_NAME: &str = "reporting_task";
pub fn completing_error_task_tool() -> impl ToolHandler {
pmcp::server::typed_tool::TypedTool::new_with_schema(
COMPLETING_TOOL_NAME,
json!({ "type": "object" }),
|_args: Value, _extra| {
Box::pin(async {
Ok(json!({
"taskId": "tool-fabricated",
"status": "working",
"createdAt": "2026-07-28T00:00:00Z",
"lastUpdatedAt": "2026-07-28T00:00:00Z",
"result": {
"content": [{ "type": "text", "text": "the upstream returned 404" }],
"isError": true
}
}))
})
},
)
.with_description("a task-capable tool that completes with an isError result")
.with_execution(
pmcp::types::ToolExecution::new().with_task_support(pmcp::types::TaskSupport::Required),
)
}
pub const PAUSING_TOOL_NAME: &str = "elicit_task";
pub const PAUSING_TOOL_REQUEST_KEY: &str = "roots";
pub fn pausing_task_tool() -> impl ToolHandler {
pmcp::server::typed_tool::TypedTool::new_with_schema(
PAUSING_TOOL_NAME,
json!({ "type": "object" }),
|_args: Value, _extra| {
Box::pin(async {
Ok(json!({
"taskId": "tool-fabricated",
"status": "input_required",
"createdAt": "2026-07-28T00:00:00Z",
"lastUpdatedAt": "2026-07-28T00:00:00Z",
"inputRequests": {
PAUSING_TOOL_REQUEST_KEY: { "method": "roots/list" }
}
}))
})
},
)
.with_description("a task-capable tool that pauses for a roots/list answer")
.with_execution(
pmcp::types::ToolExecution::new().with_task_support(pmcp::types::TaskSupport::Required),
)
}
pub const DISCOVER_EXTENSION_KEY: &str = "io.example/experimental";
pub fn extensions_capabilities() -> ServerCapabilities {
let mut caps = ServerCapabilities::default();
let mut ext = HashMap::new();
ext.insert(
DISCOVER_EXTENSION_KEY.to_string(),
json!({ "enabled": true }),
);
caps.extensions = Some(ext);
caps
}
pub fn build_v2_server() -> Server {
build_v2_server_with("v2-harness", extensions_capabilities())
}
pub fn build_v2_server_with(name: &str, capabilities: ServerCapabilities) -> Server {
Server::builder()
.name(name)
.version("1.0.0")
.capabilities(capabilities)
.with_supported_protocol_versions([
ProtocolVersion(V1.to_string()),
ProtocolVersion(V2.to_string()),
])
.tool("search", SearchTool)
.prompt("greeting", GreetingPrompt)
.resources(GreetingResource)
.build()
.expect("server builds")
}
pub struct BearerSubjects;
#[async_trait]
impl pmcp::server::auth::AuthProvider for BearerSubjects {
async fn validate_request(
&self,
authorization_header: Option<&str>,
) -> pmcp::Result<Option<pmcp::server::auth::AuthContext>> {
match authorization_header.and_then(|h| h.strip_prefix("Bearer ")) {
Some(subject) if !subject.is_empty() => {
Ok(Some(pmcp::server::auth::AuthContext::new(subject)))
},
_ => Err(pmcp::Error::authentication("missing or invalid token")),
}
}
}
pub struct OptionalBearer;
#[async_trait]
impl pmcp::server::auth::AuthProvider for OptionalBearer {
async fn validate_request(
&self,
authorization_header: Option<&str>,
) -> pmcp::Result<Option<pmcp::server::auth::AuthContext>> {
Ok(authorization_header
.and_then(|header| header.strip_prefix("Bearer "))
.filter(|subject| !subject.is_empty())
.map(pmcp::server::auth::AuthContext::new))
}
}
pub async fn spawn_default_config(server: Server) -> (SocketAddr, JoinHandle<()>) {
spawn_with(server, StreamableHttpServerConfig::default()).await
}
pub async fn spawn_stateless_config(server: Server) -> (SocketAddr, JoinHandle<()>) {
spawn_with(server, StreamableHttpServerConfig::stateless()).await
}
pub async fn spawn_with(
server: Server,
config: StreamableHttpServerConfig,
) -> (SocketAddr, JoinHandle<()>) {
spawn_shared_with(Arc::new(Mutex::new(server)), config).await
}
pub async fn spawn_shared_with(
server: Arc<Mutex<Server>>,
config: StreamableHttpServerConfig,
) -> (SocketAddr, JoinHandle<()>) {
let addr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 0);
let http = StreamableHttpServer::with_config(addr, server, config);
http.start().await.expect("server starts")
}
pub async fn spawn_shared(server: Arc<Mutex<Server>>) -> (SocketAddr, JoinHandle<()>) {
spawn_shared_with(server, StreamableHttpServerConfig::default()).await
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthPosture {
None,
Optional,
Required,
}
pub async fn spawn_tasks_server(posture: AuthPosture) -> (SocketAddr, JoinHandle<()>) {
let (addr, handle, _store) = spawn_tasks_server_with_store(posture).await;
(addr, handle)
}
pub async fn spawn_tasks_server_with_store(
posture: AuthPosture,
) -> (
SocketAddr,
JoinHandle<()>,
Arc<pmcp::server::task_store::InMemoryTaskStore>,
) {
let store = Arc::new(pmcp::server::task_store::InMemoryTaskStore::new());
let mut builder = Server::builder()
.name("v2-tasks-harness")
.version("1.0.0")
.capabilities(extensions_capabilities())
.with_supported_protocol_versions([
ProtocolVersion(V1.to_string()),
ProtocolVersion(V2.to_string()),
])
.tool(TASKS_TOOL_NAME, long_task_tool())
.tool(COMPLETING_TOOL_NAME, completing_error_task_tool())
.tool(PAUSING_TOOL_NAME, pausing_task_tool())
.task_store(store.clone() as Arc<dyn pmcp::server::task_store::TaskStore>);
builder = match posture {
AuthPosture::None => builder,
AuthPosture::Optional => builder.auth_provider(OptionalBearer),
AuthPosture::Required => builder.auth_provider(BearerSubjects),
};
let server = builder.build().expect("tasks server builds");
let (addr, handle) = spawn_default_config(server).await;
(addr, handle, store)
}
pub const ALLOW: &str = "POST, OPTIONS";
pub const FRAME_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
pub async fn teardown<S: Send>(handle: JoinHandle<()>, sockets: S) {
drop(sockets);
handle.abort();
let _ = handle.await;
}
pub fn default_client_capabilities() -> Value {
json!({ "elicitation": {}, "sampling": {}, "roots": {} })
}
pub fn v2_body(method: &str, id: Value, params: Value) -> String {
v2_body_with_caps(method, id, params, default_client_capabilities())
}
pub const REQUEST_META_KEY: &str = "_meta";
pub fn v2_body_with_caps(method: &str, id: Value, params: Value, caps: Value) -> String {
let mut params = match params {
Value::Object(map) => Value::Object(map),
_ => json!({}),
};
let meta = RequestMeta::new()
.with_meta(META_PROTOCOL_VERSION, json!(V2))
.with_meta(
META_CLIENT_INFO,
json!({ "name": "pmcp-test-client", "version": "0.0.0" }),
)
.with_meta(META_CLIENT_CAPABILITIES, caps);
let meta = serde_json::to_value(&meta).expect("request meta serializes");
if let Some(object) = params.as_object_mut() {
object.insert(REQUEST_META_KEY.to_string(), meta);
}
jsonrpc_envelope(method, id, params)
}
fn jsonrpc_envelope(method: &str, id: Value, params: Value) -> String {
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()
}
pub fn v2_body_with_client_extensions(
method: &str,
id: Value,
params: Value,
extension_keys: &[&str],
) -> String {
let mut extensions = serde_json::Map::new();
for key in extension_keys {
extensions.insert((*key).to_string(), json!({}));
}
let mut caps = match default_client_capabilities() {
Value::Object(map) => map,
_ => serde_json::Map::new(),
};
caps.insert("extensions".to_string(), Value::Object(extensions));
v2_body_with_caps(method, id, params, Value::Object(caps))
}
pub fn tasks_request_body(method: &str, id: Value, task_id: &str) -> String {
v2_body(method, id, json!({ "taskId": task_id }))
}
pub fn v2_discover_body(id: Value) -> String {
v2_body("server/discover", id, json!({}))
}
pub fn v1_body(method: &str, id: Value, params: Value) -> String {
jsonrpc_envelope(method, id, params)
}
pub use pmcp::testing::encode_mcp_name as encode_header_value;
pub fn v2_headers(method: &str, name: &str) -> Vec<(String, String)> {
v2_headers_raw(method, &encode_header_value(name))
}
pub fn v2_headers_for(method: &str, params: &Value) -> Vec<(String, String)> {
let name = pmcp::testing::routing_name_key(method)
.and_then(|key| params.get(key))
.and_then(Value::as_str)
.unwrap_or_default();
v2_headers(method, name)
}
pub fn v2_headers_raw(method: &str, raw_name: &str) -> Vec<(String, String)> {
vec![
(MCP_METHOD.to_string(), method.to_string()),
(MCP_NAME.to_string(), raw_name.to_string()),
(MCP_PROTOCOL_VERSION.to_string(), V2.to_string()),
]
}
pub fn header(name: &str, value: &str) -> (String, String) {
(name.to_string(), value.to_string())
}
#[derive(Debug, Clone)]
pub struct Resp {
pub status: u16,
pub mcp_method: Option<String>,
pub mcp_name: Option<String>,
pub mcp_version: Option<String>,
pub mcp_session_id: Option<String>,
pub content_type: Option<String>,
pub allow: Option<String>,
pub body: Value,
pub raw: String,
}
fn parse_body(text: &str, content_type: Option<&str>) -> Value {
if let Ok(value) = serde_json::from_str::<Value>(text) {
return value;
}
let looks_like_sse = content_type.is_some_and(|ct| ct.starts_with("text/event-stream"));
if looks_like_sse || text.contains("data:") {
for line in text.lines() {
if let Some(payload) = line.strip_prefix("data:") {
if let Ok(value) = serde_json::from_str::<Value>(payload.trim()) {
return value;
}
}
}
}
Value::Null
}
async fn send(request: reqwest::RequestBuilder, extra: &[(String, String)]) -> Resp {
let mut request = request;
for (name, value) in extra {
request = request.header(name.as_str(), value.as_str());
}
let response = request.send().await.expect("request sent");
let status = response.status().as_u16();
let hget = |name: &str| {
response
.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
};
let mcp_method = hget(MCP_METHOD);
let mcp_name = hget(MCP_NAME);
let mcp_version = hget(MCP_PROTOCOL_VERSION);
let mcp_session_id = hget(MCP_SESSION_ID);
let content_type = hget("content-type");
let allow = hget("allow");
let raw = response.text().await.unwrap_or_default();
let body = parse_body(&raw, content_type.as_deref());
Resp {
status,
mcp_method,
mcp_name,
mcp_version,
mcp_session_id,
content_type,
allow,
body,
raw,
}
}
pub const ACCEPT_BOTH: &str = ACCEPT_STREAMABLE;
pub async fn post(addr: SocketAddr, extra: &[(String, String)], body: &str) -> Resp {
post_with_accept(addr, ACCEPT_BOTH, extra, body).await
}
static CLIENT: std::sync::LazyLock<reqwest::Client> =
std::sync::LazyLock::new(reqwest::Client::new);
pub async fn post_with_accept(
addr: SocketAddr,
accept: &str,
extra: &[(String, String)],
body: &str,
) -> Resp {
let request = CLIENT
.post(format!("http://{addr}"))
.header("content-type", "application/json")
.header("accept", accept)
.body(body.to_string());
send(request, extra).await
}
pub async fn post_raw(addr: SocketAddr, extra: &[(String, String)], raw_body: &str) -> Resp {
post_with_accept(addr, ACCEPT_BOTH, extra, raw_body).await
}
pub async fn get(addr: SocketAddr, extra: &[(String, String)]) -> Resp {
let request = CLIENT
.get(format!("http://{addr}"))
.header("accept", "text/event-stream");
send(request, extra).await
}
pub async fn delete(addr: SocketAddr, extra: &[(String, String)]) -> Resp {
let request = CLIENT
.delete(format!("http://{addr}"))
.header("accept", "application/json");
send(request, extra).await
}