#![cfg(all(feature = "streamable-http", not(target_arch = "wasm32")))]
use std::net::SocketAddr;
use std::sync::Arc;
use async_trait::async_trait;
use pmcp::server::builder::ServerCoreBuilder;
use pmcp::server::core::ProtocolHandler;
use pmcp::server::streamable_http_server::StreamableHttpServer;
use pmcp::shared::streamable_http::{
StreamableHttpTransport, StreamableHttpTransportConfigBuilder,
};
use pmcp::shared::{Transport, TransportMessage};
use pmcp::types::jsonrpc::ResponsePayload;
use pmcp::types::tasks::{TaskMetadata, RELATED_TASK_META_KEY};
use pmcp::types::{
CallToolRequest, CallToolResult, ClientCapabilities, ClientRequest, Content, Implementation,
InitializeRequest, Request, RequestId,
};
use pmcp::{RequestHandlerExtra, Server, ToolHandler, ToolOutput};
use tokio::sync::Mutex;
use url::Url;
const RELATED_TASK_ID: &str = "t-http";
struct AugmentedResultTool;
impl AugmentedResultTool {
fn envelope() -> CallToolResult {
CallToolResult::new(vec![Content::text("done")])
.with_related_task(TaskMetadata::new(RELATED_TASK_ID))
}
}
#[async_trait]
impl ToolHandler for AugmentedResultTool {
async fn handle(
&self,
_args: serde_json::Value,
_extra: RequestHandlerExtra,
) -> pmcp::Result<serde_json::Value> {
Ok(serde_json::to_value(Self::envelope())?)
}
async fn handle_output(
&self,
_args: serde_json::Value,
_extra: RequestHandlerExtra,
) -> pmcp::Result<ToolOutput> {
Ok(ToolOutput::Result(Self::envelope()))
}
}
fn build_server() -> pmcp::Result<Server> {
Server::builder()
.name("tool-output-result-http")
.version("1.0.0")
.tool("augmented", AugmentedResultTool)
.tool("meta_drain", MetaDrainTool)
.build()
}
fn build_core() -> pmcp::Result<Arc<dyn ProtocolHandler>> {
Ok(Arc::new(
ServerCoreBuilder::new()
.name("tool-output-result-core")
.version("1.0.0")
.tool("augmented", AugmentedResultTool)
.tool("meta_drain", MetaDrainTool)
.build()?,
))
}
async fn spawn_http_server() -> pmcp::Result<(SocketAddr, tokio::task::JoinHandle<()>)> {
let server = Arc::new(Mutex::new(build_server()?));
let bind_addr: SocketAddr = "127.0.0.1:0".parse().expect("valid loopback addr");
let http_server = StreamableHttpServer::new(bind_addr, server);
let (bound, handle) = http_server.start().await?;
Ok((bound, handle))
}
fn http_transport(bound: SocketAddr) -> pmcp::Result<StreamableHttpTransport> {
let config = StreamableHttpTransportConfigBuilder::new(
Url::parse(&format!("http://{bound}")).map_err(|e| pmcp::Error::Internal(e.to_string()))?,
)
.enable_json_response()
.build();
Ok(StreamableHttpTransport::new(config))
}
fn expect_result_value(msg: TransportMessage) -> pmcp::Result<serde_json::Value> {
match msg {
TransportMessage::Response(resp) => match resp.payload {
ResponsePayload::Result(value) => Ok(value),
ResponsePayload::Error(err) => Err(pmcp::Error::internal(format!(
"expected a result over HTTP, got JSON-RPC error: {}",
err.message
))),
},
other => Err(pmcp::Error::internal(format!(
"expected a Response message, got: {other:?}"
))),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_output_result_carries_meta_at_top_level_over_http() -> pmcp::Result<()> {
let (bound, server_handle) = spawn_http_server().await?;
let outcome = run_round_trip(bound).await;
server_handle.abort();
match server_handle.await {
Ok(()) => {},
Err(e) if e.is_cancelled() => {},
Err(e) => panic!("HTTP server task ended unexpectedly: {e}"),
}
outcome
}
async fn run_round_trip(bound: SocketAddr) -> pmcp::Result<()> {
let mut transport = http_transport(bound)?;
transport
.send(TransportMessage::Request {
id: 1i64.into(),
request: Request::Client(Box::new(ClientRequest::Initialize(InitializeRequest::new(
Implementation::new("tout-04-gate", "1.0.0"),
ClientCapabilities::default(),
)))),
})
.await?;
let _init = transport.receive().await?;
transport
.send(TransportMessage::Request {
id: 2i64.into(),
request: Request::Client(Box::new(ClientRequest::CallTool(CallToolRequest::new(
"augmented",
serde_json::json!({}),
)))),
})
.await?;
let result = expect_result_value(transport.receive().await?)?;
let meta = result.get("_meta").ok_or_else(|| {
pmcp::Error::internal(format!(
"result._meta must be present at top level over HTTP; got: {result}"
))
})?;
assert!(
meta.get("io.modelcontextprotocol/related-task").is_some(),
"result._meta must carry the related-task envelope, got: {meta}"
);
assert_eq!(
meta["io.modelcontextprotocol/related-task"]["taskId"].as_str(),
Some(RELATED_TASK_ID),
"related-task taskId must equal the tool's minted id over HTTP"
);
if let Some(text) = result
.get("content")
.and_then(|c| c.get(0))
.and_then(|c0| c0.get("text"))
.and_then(serde_json::Value::as_str)
{
assert_eq!(
text, "done",
"content[0].text must be the real text, not a wrapped envelope"
);
assert!(
!text.contains("_meta"),
"content[0].text must NOT contain a stringified `_meta` (the double-wrap bug), got: {text}"
);
}
Ok(())
}
const ENVELOPE_ONLY_KEY: &str = "com.example/envelope-only";
const ENVELOPE_ONLY_VALUE: &str = "kept";
const HANDLER_ONLY_KEY: &str = "com.example/handler-only";
const HANDLER_ONLY_VALUE: &str = "added";
const ENVELOPE_TASK_ID: &str = "t-envelope-loses";
const HANDLER_TASK_ID: &str = "t-handler-wins";
struct MetaDrainTool;
impl MetaDrainTool {
fn envelope() -> CallToolResult {
let mut meta = serde_json::Map::new();
meta.insert(
ENVELOPE_ONLY_KEY.to_string(),
serde_json::Value::String(ENVELOPE_ONLY_VALUE.to_string()),
);
CallToolResult::new(vec![Content::text("drained")])
.with_meta(meta)
.with_related_task(TaskMetadata::new(ENVELOPE_TASK_ID))
}
fn handler_meta() -> serde_json::Map<String, serde_json::Value> {
let mut meta = serde_json::Map::new();
meta.insert(
HANDLER_ONLY_KEY.to_string(),
serde_json::Value::String(HANDLER_ONLY_VALUE.to_string()),
);
meta.insert(
RELATED_TASK_META_KEY.to_string(),
serde_json::json!({ "taskId": HANDLER_TASK_ID }),
);
meta
}
}
#[async_trait]
impl ToolHandler for MetaDrainTool {
async fn handle(
&self,
_args: serde_json::Value,
extra: RequestHandlerExtra,
) -> pmcp::Result<serde_json::Value> {
extra.set_result_meta(Self::handler_meta());
Ok(serde_json::to_value(Self::envelope())?)
}
async fn handle_output(
&self,
_args: serde_json::Value,
extra: RequestHandlerExtra,
) -> pmcp::Result<ToolOutput> {
extra.set_result_meta(Self::handler_meta());
Ok(ToolOutput::Result(Self::envelope()))
}
}
fn assert_drained_union(result: &serde_json::Value) {
let meta = result.get("_meta").unwrap_or_else(|| {
panic!("result._meta must be present after the D-06 drain; got: {result}")
});
assert_eq!(
meta.get(HANDLER_ONLY_KEY).and_then(serde_json::Value::as_str),
Some(HANDLER_ONLY_VALUE),
"a handler-only `set_result_meta` key must reach the wire on the verbatim path, got: {meta}"
);
assert_eq!(
meta.get(ENVELOPE_ONLY_KEY)
.and_then(serde_json::Value::as_str),
Some(ENVELOPE_ONLY_VALUE),
"the envelope's unrelated key must SURVIVE the merge (never a whole-map replace), got: {meta}"
);
assert_eq!(
meta[RELATED_TASK_META_KEY]["taskId"].as_str(),
Some(HANDLER_TASK_ID),
"handler-key-wins: the handler's colliding key must overwrite the envelope's, got: {meta}"
);
}
async fn call_tool_via_core(
core: &Arc<dyn ProtocolHandler>,
tool: &str,
) -> pmcp::Result<serde_json::Value> {
let init = Request::Client(Box::new(ClientRequest::Initialize(InitializeRequest::new(
Implementation::new("tout-04-core-gate", "1.0.0"),
ClientCapabilities::default(),
))));
let _init = core.handle_request(RequestId::from(0i64), init, None).await;
let call = Request::Client(Box::new(ClientRequest::CallTool(CallToolRequest::new(
tool,
serde_json::json!({}),
))));
let response = core.handle_request(RequestId::from(1i64), call, None).await;
match response.payload {
ResponsePayload::Result(value) => Ok(value),
ResponsePayload::Error(err) => Err(pmcp::Error::internal(format!(
"expected a result from ServerCore, got JSON-RPC error: {}",
err.message
))),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn set_result_meta_survives_the_verbatim_path_over_http() -> pmcp::Result<()> {
let (bound, server_handle) = spawn_http_server().await?;
let outcome = run_meta_drain_round_trip(bound).await;
server_handle.abort();
match server_handle.await {
Ok(()) => {},
Err(e) if e.is_cancelled() => {},
Err(e) => panic!("HTTP server task ended unexpectedly: {e}"),
}
outcome
}
async fn run_meta_drain_round_trip(bound: SocketAddr) -> pmcp::Result<()> {
let mut transport = http_transport(bound)?;
transport
.send(TransportMessage::Request {
id: 1i64.into(),
request: Request::Client(Box::new(ClientRequest::Initialize(InitializeRequest::new(
Implementation::new("tout-04-gate", "1.0.0"),
ClientCapabilities::default(),
)))),
})
.await?;
let _init = transport.receive().await?;
transport
.send(TransportMessage::Request {
id: 2i64.into(),
request: Request::Client(Box::new(ClientRequest::CallTool(CallToolRequest::new(
"meta_drain",
serde_json::json!({}),
)))),
})
.await?;
let result = expect_result_value(transport.receive().await?)?;
assert_drained_union(&result);
assert_eq!(
result["content"][0]["text"].as_str(),
Some("drained"),
"the drain must not disturb the handler's verbatim content, got: {result}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn set_result_meta_survives_the_verbatim_path_on_core() -> pmcp::Result<()> {
let core = build_core()?;
let result = call_tool_via_core(&core, "meta_drain").await?;
assert_drained_union(&result);
assert_eq!(
result["content"][0]["text"].as_str(),
Some("drained"),
"the drain must not disturb the handler's verbatim content, got: {result}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn no_opt_in_handler_emits_its_envelope_meta_unchanged_on_core() -> pmcp::Result<()> {
let core = build_core()?;
let result = call_tool_via_core(&core, "augmented").await?;
let meta = result
.get("_meta")
.and_then(serde_json::Value::as_object)
.unwrap_or_else(|| panic!("result._meta must be an object, got: {result}"));
assert_eq!(
meta.len(),
1,
"a handler that never opts into set_result_meta must emit EXACTLY its own \
envelope keys — no injected keys: {meta:?}"
);
assert_eq!(
meta[RELATED_TASK_META_KEY]["taskId"].as_str(),
Some(RELATED_TASK_ID),
"the sole key must be the envelope's own related-task, got: {meta:?}"
);
Ok(())
}