#![cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
use async_trait::async_trait;
use pmcp::server::builder::ServerCoreBuilder;
use pmcp::server::core::ProtocolHandler;
use pmcp::server::task_store::{InMemoryTaskStore, TaskStore};
use pmcp::server::typed_tool::TypedTool;
use pmcp::shared::{Transport, TransportMessage};
use pmcp::types::tasks::RELATED_TASK_META_KEY;
use pmcp::types::{
CallToolResult, ClientCapabilities, Content, TaskSupport, ToolExecution, ToolInfo,
};
use pmcp::{Client, Error, RequestHandlerExtra, Result, Server, ToolHandler, ToolOutput};
use serde_json::{json, Value};
use tokio::sync::mpsc;
#[derive(Debug)]
struct DuplexTransport {
tx: mpsc::UnboundedSender<TransportMessage>,
rx: mpsc::UnboundedReceiver<TransportMessage>,
connected: bool,
}
impl DuplexTransport {
fn pair() -> (Self, Self) {
let (client_tx, server_rx) = mpsc::unbounded_channel();
let (server_tx, client_rx) = mpsc::unbounded_channel();
(
Self {
tx: client_tx,
rx: client_rx,
connected: true,
},
Self {
tx: server_tx,
rx: server_rx,
connected: true,
},
)
}
}
#[async_trait]
impl Transport for DuplexTransport {
async fn send(&mut self, message: TransportMessage) -> Result<()> {
self.tx
.send(message)
.map_err(|_| Error::internal("duplex peer dropped"))
}
async fn receive(&mut self) -> Result<TransportMessage> {
self.rx
.recv()
.await
.ok_or_else(|| Error::internal("duplex peer closed"))
}
async fn close(&mut self) -> Result<()> {
self.connected = false;
Ok(())
}
fn is_connected(&self) -> bool {
self.connected
}
fn transport_type(&self) -> &'static str {
"in-process-duplex"
}
}
fn spawn_core_pump(mut server_transport: DuplexTransport, handler: Arc<dyn ProtocolHandler>) {
tokio::spawn(async move {
while let Ok(message) = server_transport.receive().await {
if let TransportMessage::Request { id, request } = message {
let response = handler.handle_request(id, request, None).await;
if server_transport
.send(TransportMessage::Response(response))
.await
.is_err()
{
break;
}
}
}
});
}
struct VerbatimTool;
#[async_trait]
impl ToolHandler for VerbatimTool {
async fn handle(&self, _args: Value, _extra: RequestHandlerExtra) -> Result<Value> {
Ok(json!({ "via": "handle-should-not-be-called" }))
}
async fn handle_output(&self, _args: Value, _extra: RequestHandlerExtra) -> Result<ToolOutput> {
let mut meta = serde_json::Map::new();
meta.insert(
RELATED_TASK_META_KEY.to_string(),
json!({ "taskId": "hint-123" }),
);
let result = CallToolResult::new(vec![Content::text("verbatim content")]).with_meta(meta);
Ok(ToolOutput::Result(result))
}
}
struct VerbatimTaskRequiredTool;
#[async_trait]
impl ToolHandler for VerbatimTaskRequiredTool {
async fn handle(&self, args: Value, extra: RequestHandlerExtra) -> Result<Value> {
VerbatimTool.handle(args, extra).await
}
async fn handle_output(&self, args: Value, extra: RequestHandlerExtra) -> Result<ToolOutput> {
VerbatimTool.handle_output(args, extra).await
}
fn metadata(&self) -> Option<ToolInfo> {
let mut info = ToolInfo::new("verbatim_required", None, json!({ "type": "object" }));
info.execution = Some(ToolExecution::new().with_task_support(TaskSupport::Required));
Some(info)
}
}
fn payload_tool() -> impl ToolHandler {
TypedTool::new_with_schema(
"payload_tool",
json!({ "type": "object" }),
|_args: Value, _extra| Box::pin(async { Ok(json!({ "answer": 42 })) }),
)
.with_description("A plain Payload-path tool")
}
fn completing_task_tool() -> impl ToolHandler {
TypedTool::new_with_schema(
"complete_now",
json!({ "type": "object" }),
|_args: Value, _extra| {
Box::pin(async {
Ok(json!({
"taskId": "tool-fabricated",
"status": "completed",
"ttl": 60000,
"createdAt": "2026-07-04T00:00:00Z",
"lastUpdatedAt": "2026-07-04T00:00:00Z",
"result": {
"content": [ { "type": "text", "text": "terminal result payload" } ]
}
}))
})
},
)
.with_description("A task tool that completes synchronously")
.with_execution(ToolExecution::new().with_task_support(TaskSupport::Required))
}
fn build_core_with_verbatim() -> Arc<dyn ProtocolHandler> {
Arc::new(
ServerCoreBuilder::new()
.name("passthrough-core")
.version("1.0.0")
.tool("verbatim_tool", VerbatimTool)
.tool("payload_tool", payload_tool())
.build()
.expect("core builds"),
)
}
fn build_server_with_verbatim() -> Server {
Server::builder()
.name("passthrough-server")
.version("1.0.0")
.tool("verbatim_tool", VerbatimTool)
.tool("payload_tool", payload_tool())
.build()
.expect("server builds")
}
async fn call_via_core(core: Arc<dyn ProtocolHandler>, name: &str, args: Value) -> CallToolResult {
let (client_t, server_t) = DuplexTransport::pair();
spawn_core_pump(server_t, core);
let mut client = Client::new(client_t);
client
.initialize(ClientCapabilities::default())
.await
.expect("client initializes against core");
client
.call_tool(name.to_string(), args)
.await
.expect("tools/call succeeds against core")
}
async fn call_via_server(server: Server, name: &str, args: Value) -> CallToolResult {
let (client_t, server_t) = DuplexTransport::pair();
tokio::spawn(async move {
let _ = server.run(server_t).await;
});
let mut client = Client::new(client_t);
client
.initialize(ClientCapabilities::default())
.await
.expect("client initializes against server");
client
.call_tool(name.to_string(), args)
.await
.expect("tools/call succeeds against server")
}
fn assert_verbatim(result: &CallToolResult) {
let v = serde_json::to_value(result).expect("serialize CallToolResult");
assert_eq!(
v["_meta"][RELATED_TASK_META_KEY]["taskId"], "hint-123",
"top-level _meta[relatedTask].taskId must survive verbatim"
);
let text = v["content"][0]["text"]
.as_str()
.expect("content[0].text is a string");
assert_eq!(
text, "verbatim content",
"content must be the handler's verbatim text, not a stringified value"
);
assert!(
!text.contains(RELATED_TASK_META_KEY) && !text.contains("_meta"),
"content must NOT be a stringified envelope (double-wrap bug)"
);
assert!(
!text.contains("handle-should-not-be-called"),
"dispatcher must call handle_output, not handle"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn verbatim_result_meta_survives_on_server() {
let result = call_via_server(build_server_with_verbatim(), "verbatim_tool", json!({})).await;
assert_verbatim(&result);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn verbatim_result_meta_survives_on_core() {
let result = call_via_core(build_core_with_verbatim(), "verbatim_tool", json!({})).await;
assert_verbatim(&result);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn payload_path_text_wraps_and_has_no_verbatim_meta_on_server() {
let result = call_via_server(build_server_with_verbatim(), "payload_tool", json!({})).await;
let v = serde_json::to_value(&result).expect("serialize");
let text = v["content"][0]["text"].as_str().expect("text present");
let parsed: Value = serde_json::from_str(text).expect("payload text is the stringified value");
assert_eq!(parsed, json!({ "answer": 42 }), "payload value round-trips");
assert!(
v.get("_meta").is_none_or(Value::is_null),
"payload path must not inject a verbatim _meta"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn payload_path_text_wraps_on_core() {
let result = call_via_core(build_core_with_verbatim(), "payload_tool", json!({})).await;
let v = serde_json::to_value(&result).expect("serialize");
let text = v["content"][0]["text"].as_str().expect("text present");
let parsed: Value = serde_json::from_str(text).expect("payload text is the stringified value");
assert_eq!(parsed, json!({ "answer": 42 }), "payload value round-trips");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn task_shaped_payload_still_routes_through_create_path() {
let store = Arc::new(InMemoryTaskStore::new()) as Arc<dyn TaskStore>;
let core: Arc<dyn ProtocolHandler> = Arc::new(
ServerCoreBuilder::new()
.name("create-path-core")
.version("1.0.0")
.tool("complete_now", completing_task_tool())
.task_store(store)
.build()
.expect("core builds"),
);
let (client_t, server_t) = DuplexTransport::pair();
spawn_core_pump(server_t, core);
let mut client = Client::new(client_t);
client
.initialize(ClientCapabilities::default())
.await
.expect("client initializes");
let response = client
.call_tool_with_task("complete_now".to_string(), json!({}))
.await
.expect("task-augmented tools/call succeeds");
match response {
pmcp::client::ToolCallResponse::Task(task) => {
assert_ne!(
task.task_id, "tool-fabricated",
"create-path must mint the canonical id"
);
},
pmcp::client::ToolCallResponse::Result(r) => {
panic!("create-path precedence lost — got a CallToolResult: {r:?}");
},
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn task_augmented_call_to_verbatim_tool_returns_plain_result() {
let store = Arc::new(InMemoryTaskStore::new()) as Arc<dyn TaskStore>;
let core: Arc<dyn ProtocolHandler> = Arc::new(
ServerCoreBuilder::new()
.name("verbatim-gate-core")
.version("1.0.0")
.tool("verbatim_required", VerbatimTaskRequiredTool)
.task_store(store)
.build()
.expect("core builds"),
);
let (client_t, server_t) = DuplexTransport::pair();
spawn_core_pump(server_t, core);
let mut client = Client::new(client_t);
client
.initialize(ClientCapabilities::default())
.await
.expect("client initializes");
let response = client
.call_tool_with_task("verbatim_required".to_string(), json!({}))
.await
.expect("task-augmented tools/call succeeds");
match response {
pmcp::client::ToolCallResponse::Result(result) => {
assert_verbatim(&result);
},
pmcp::client::ToolCallResponse::Task(task) => {
panic!(
"verbatim path must return BEFORE the create-path gate — got a minted task: {task:?}"
);
},
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn server_and_core_emit_equal_verbatim_result() {
let via_server =
call_via_server(build_server_with_verbatim(), "verbatim_tool", json!({})).await;
let via_core = call_via_core(build_core_with_verbatim(), "verbatim_tool", json!({})).await;
let sv = serde_json::to_value(&via_server).expect("serialize server result");
let cv = serde_json::to_value(&via_core).expect("serialize core result");
assert_eq!(
sv, cv,
"Server and ServerCore must emit byte-equal serialized results for the same ToolOutput::Result"
);
}
use pmcp::server::tool_middleware::{ToolContext, ToolMiddleware};
use std::sync::Mutex;
const MW_RESPONSE_MARKER: &str = "_mw_response_marker";
type Recorder = Arc<Mutex<Vec<String>>>;
struct RecordingMiddleware {
requests_seen: Recorder,
responses_seen: Recorder,
}
#[async_trait]
impl ToolMiddleware for RecordingMiddleware {
async fn on_request(
&self,
tool_name: &str,
args: &mut Value,
_extra: &mut RequestHandlerExtra,
_context: &ToolContext,
) -> Result<()> {
self.requests_seen
.lock()
.unwrap()
.push(tool_name.to_string());
if let Value::Object(map) = args {
map.insert("_mw_request_marker".to_string(), json!(tool_name));
}
Ok(())
}
async fn on_response(
&self,
tool_name: &str,
result: &mut Result<Value>,
_context: &ToolContext,
) -> Result<()> {
self.responses_seen
.lock()
.unwrap()
.push(tool_name.to_string());
if let Ok(Value::Object(map)) = result {
map.insert(MW_RESPONSE_MARKER.to_string(), json!(tool_name));
}
Ok(())
}
}
struct ErrorResultTool;
#[async_trait]
impl ToolHandler for ErrorResultTool {
async fn handle(&self, _args: Value, _extra: RequestHandlerExtra) -> Result<Value> {
Err(Error::internal("boom from handle"))
}
async fn handle_output(&self, _args: Value, _extra: RequestHandlerExtra) -> Result<ToolOutput> {
Err(Error::internal("boom from handle_output"))
}
}
struct BatteryOutcome {
requests_seen: Vec<String>,
responses_seen: Vec<String>,
payload_result: CallToolResult,
verbatim_result: CallToolResult,
error_is_err: bool,
}
fn assert_d04a(outcome: &BatteryOutcome, dispatcher: &str) {
assert!(
outcome.responses_seen.iter().any(|t| t == "payload_tool"),
"[{dispatcher}] response middleware must fire for the Payload tool"
);
let payload_v = serde_json::to_value(&outcome.payload_result).expect("serialize payload");
let payload_text = payload_v["content"][0]["text"]
.as_str()
.expect("payload text");
assert!(
payload_text.contains(MW_RESPONSE_MARKER),
"[{dispatcher}] the Payload result must show the response-middleware mutation"
);
assert!(
!outcome.responses_seen.iter().any(|t| t == "verbatim_tool"),
"[{dispatcher}] response middleware must be BYPASSED for the successful Result tool (D-04a #1)"
);
assert_verbatim(&outcome.verbatim_result);
let verbatim_v = serde_json::to_value(&outcome.verbatim_result).expect("serialize verbatim");
assert!(
!serde_json::to_string(&verbatim_v)
.unwrap()
.contains(MW_RESPONSE_MARKER),
"[{dispatcher}] the verbatim Result must carry no response-middleware marker"
);
assert!(
outcome.requests_seen.iter().any(|t| t == "verbatim_tool"),
"[{dispatcher}] request middleware must still fire for the Result tool (D-04a #2)"
);
assert!(
outcome.error_is_err,
"[{dispatcher}] an erroring Result handler must still yield an error response (D-04a #3)"
);
assert!(
outcome
.responses_seen
.iter()
.any(|t| t == "error_result_tool"),
"[{dispatcher}] the erroring Result tool must flow through response middleware / error path"
);
}
fn recording_middleware() -> (Arc<RecordingMiddleware>, Recorder, Recorder) {
let requests_seen = Arc::new(Mutex::new(Vec::new()));
let responses_seen = Arc::new(Mutex::new(Vec::new()));
let mw = Arc::new(RecordingMiddleware {
requests_seen: requests_seen.clone(),
responses_seen: responses_seen.clone(),
});
(mw, requests_seen, responses_seen)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn d04a_middleware_and_error_battery_on_server() {
let (mw, requests_seen, responses_seen) = recording_middleware();
let server = Server::builder()
.name("d04a-server")
.version("1.0.0")
.tool("payload_tool", payload_tool())
.tool("verbatim_tool", VerbatimTool)
.tool("error_result_tool", ErrorResultTool)
.tool_middleware(mw as Arc<dyn ToolMiddleware>)
.build()
.expect("server builds");
let (client_t, server_t) = DuplexTransport::pair();
tokio::spawn(async move {
let _ = server.run(server_t).await;
});
let mut client = Client::new(client_t);
client
.initialize(ClientCapabilities::default())
.await
.expect("client initializes");
let mut outcome = drive_battery_reuse(&client).await;
outcome.requests_seen = requests_seen.lock().unwrap().clone();
outcome.responses_seen = responses_seen.lock().unwrap().clone();
assert_d04a(&outcome, "Server");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn d04a_middleware_and_error_battery_on_core() {
let (mw, requests_seen, responses_seen) = recording_middleware();
let core: Arc<dyn ProtocolHandler> = Arc::new(
ServerCoreBuilder::new()
.name("d04a-core")
.version("1.0.0")
.tool("payload_tool", payload_tool())
.tool("verbatim_tool", VerbatimTool)
.tool("error_result_tool", ErrorResultTool)
.tool_middleware(mw as Arc<dyn ToolMiddleware>)
.build()
.expect("core builds"),
);
let (client_t, server_t) = DuplexTransport::pair();
spawn_core_pump(server_t, core);
let mut client = Client::new(client_t);
client
.initialize(ClientCapabilities::default())
.await
.expect("client initializes");
let mut outcome = drive_battery_reuse(&client).await;
outcome.requests_seen = requests_seen.lock().unwrap().clone();
outcome.responses_seen = responses_seen.lock().unwrap().clone();
assert_d04a(&outcome, "ServerCore");
}
async fn drive_battery_reuse(client: &Client<DuplexTransport>) -> BatteryOutcome {
let payload_result = client
.call_tool("payload_tool".to_string(), json!({}))
.await
.expect("payload tool call succeeds");
let verbatim_result = client
.call_tool("verbatim_tool".to_string(), json!({}))
.await
.expect("verbatim tool call succeeds");
let error_is_err = client
.call_tool("error_result_tool".to_string(), json!({}))
.await
.is_err();
BatteryOutcome {
requests_seen: Vec::new(),
responses_seen: Vec::new(),
payload_result,
verbatim_result,
error_is_err,
}
}