#![cfg(not(target_arch = "wasm32"))]
use pmcp::__test_support::{double_wrap_tripwire, looks_like_call_tool_result, DoubleWrapMarker};
use serde_json::json;
#[test]
fn looks_like_fires_on_related_task_meta() {
let v = json!({
"_meta": { "io.modelcontextprotocol/related-task": { "taskId": "t1" } }
});
assert_eq!(
looks_like_call_tool_result(&v),
Some(DoubleWrapMarker::RelatedTaskMeta)
);
}
#[test]
fn looks_like_fires_on_content_array() {
let v = json!({ "content": [ { "type": "text", "text": "hi" } ] });
assert_eq!(
looks_like_call_tool_result(&v),
Some(DoubleWrapMarker::ContentArray)
);
}
#[test]
fn looks_like_fires_on_full_envelope_keys() {
let v = json!({
"content": [ { "type": "text", "text": "hi" } ],
"isError": false,
"structuredContent": { "answer": 42 }
});
assert_eq!(
looks_like_call_tool_result(&v),
Some(DoubleWrapMarker::ContentArray)
);
}
#[test]
fn looks_like_ignores_chat_message_payload() {
let v = json!({
"role": "assistant",
"content": [ { "type": "text", "text": "hello from the model" } ]
});
assert_eq!(looks_like_call_tool_result(&v), None);
let v = json!({
"role": "assistant",
"model": "some-model",
"stopReason": "end_turn",
"content": [ { "type": "text", "text": "hello" } ]
});
assert_eq!(looks_like_call_tool_result(&v), None);
}
#[test]
fn looks_like_ignores_empty_content_array() {
let v = json!({ "content": [] });
assert_eq!(looks_like_call_tool_result(&v), None);
}
#[test]
fn looks_like_ignores_non_content_element() {
let v = json!({ "content": [ "not-a-content-item" ] });
assert_eq!(looks_like_call_tool_result(&v), None);
}
#[test]
fn looks_like_ignores_benign_object() {
let v = json!({ "foo": 1 });
assert_eq!(looks_like_call_tool_result(&v), None);
}
#[test]
fn looks_like_ignores_non_object() {
assert_eq!(looks_like_call_tool_result(&json!(42)), None);
assert_eq!(looks_like_call_tool_result(&json!("string")), None);
assert_eq!(looks_like_call_tool_result(&json!([1, 2, 3])), None);
}
mod precision {
use super::*;
use proptest::prelude::*;
fn benign_json() -> impl Strategy<Value = serde_json::Value> {
let leaf = prop_oneof![
Just(serde_json::Value::Null),
any::<bool>().prop_map(serde_json::Value::from),
any::<i64>().prop_map(serde_json::Value::from),
"[a-z]{1,6}".prop_map(serde_json::Value::from),
];
leaf.prop_recursive(3, 16, 4, |inner| {
prop_oneof![
prop::collection::vec(inner.clone(), 0..4).prop_map(serde_json::Value::from),
prop::collection::hash_map("[a-z]{1,6}", inner, 0..4).prop_map(|m| {
serde_json::Value::from(m.into_iter().collect::<serde_json::Map<_, _>>())
}),
]
})
}
proptest! {
#[test]
fn benign_json_never_trips(v in benign_json()) {
prop_assert_eq!(looks_like_call_tool_result(&v), None);
}
}
}
mod decision {
use super::*;
fn tripping() -> serde_json::Value {
json!({ "content": [ { "type": "text", "text": "x" } ] })
}
#[test]
fn suppressed_never_fires() {
assert_eq!(double_wrap_tripwire("wrapper", &tripping(), true), None);
}
#[test]
fn benign_never_fires() {
let benign = json!({ "answer": 42 });
assert_eq!(double_wrap_tripwire("plain", &benign, false), None);
}
#[cfg(not(debug_assertions))]
#[test]
fn release_returns_marker_without_panic() {
assert_eq!(
double_wrap_tripwire("wrapper", &tripping(), false),
Some(DoubleWrapMarker::ContentArray)
);
}
#[cfg(debug_assertions)]
#[test]
fn debug_panics_on_tripping_unsuppressed() {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {})); let outcome =
std::panic::catch_unwind(|| double_wrap_tripwire("wrapper", &tripping(), false));
std::panic::set_hook(prev);
assert!(
outcome.is_err(),
"debug build must panic via debug_assert! on an unsuppressed tripping value"
);
}
#[cfg(not(debug_assertions))]
#[test]
fn release_related_task_meta_marker_returns() {
let v = json!({ "_meta": { "io.modelcontextprotocol/related-task": { "taskId": "t" } } });
assert_eq!(
double_wrap_tripwire("wrapper", &v, false),
Some(DoubleWrapMarker::RelatedTaskMeta)
);
}
}
mod parity {
use async_trait::async_trait;
use pmcp::server::builder::ServerCoreBuilder;
use pmcp::server::core::ProtocolHandler;
use pmcp::shared::{Transport, TransportMessage};
use pmcp::types::{CallToolResult, ClientCapabilities};
use pmcp::{Client, Error, RequestHandlerExtra, Result, Server, ToolHandler};
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::sync::mpsc;
const WRAPPER: &str = "wrapper_tool";
struct WrapperTool;
#[async_trait]
impl ToolHandler for WrapperTool {
async fn handle(&self, _args: Value, _extra: RequestHandlerExtra) -> Result<Value> {
Ok(json!({ "content": [ { "type": "text", "text": "already-wrapped" } ] }))
}
}
struct DuplexTransport {
tx: mpsc::UnboundedSender<TransportMessage>,
rx: mpsc::UnboundedReceiver<TransportMessage>,
connected: bool,
}
impl std::fmt::Debug for DuplexTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("DuplexTransport")
}
}
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;
}
}
}
});
}
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 (suppression must prevent debug panic)")
}
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 (suppression must prevent debug panic)")
}
fn assert_text_wrapped(result: &CallToolResult) {
let v = serde_json::to_value(result).expect("serialize CallToolResult");
let text = v["content"][0]["text"]
.as_str()
.expect("content[0].text present");
assert!(
text.contains("already-wrapped"),
"suppressed wrapper payload should be text-wrapped, got: {text}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn core_honors_suppression() {
let core: Arc<dyn ProtocolHandler> = Arc::new(
ServerCoreBuilder::new()
.name("tripwire-core")
.version("1.0.0")
.tool(WRAPPER, WrapperTool)
.suppress_double_wrap_check(WRAPPER)
.build()
.expect("core builds"),
);
let result = call_via_core(core, WRAPPER, json!({})).await;
assert_text_wrapped(&result);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn server_honors_suppression() {
let server = Server::builder()
.name("tripwire-server")
.version("1.0.0")
.tool(WRAPPER, WrapperTool)
.suppress_double_wrap_check(WRAPPER)
.build()
.expect("server builds");
let result = call_via_server(server, WRAPPER, json!({})).await;
assert_text_wrapped(&result);
}
}