#![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::streamable_http_server::StreamableHttpServer;
use pmcp::shared::streamable_http::{StreamableHttpTransport, StreamableHttpTransportConfig};
use pmcp::shared::{Transport, TransportMessage};
use pmcp::types::jsonrpc::ResponsePayload;
use pmcp::types::tasks::TaskMetadata;
use pmcp::types::{
CallToolRequest, CallToolResult, ClientCapabilities, ClientRequest, Content, Implementation,
InitializeRequest, Request,
};
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)
.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 = StreamableHttpTransportConfig {
url: Url::parse(&format!("http://{bound}"))
.map_err(|e| pmcp::Error::Internal(e.to_string()))?,
extra_headers: vec![],
auth_provider: None,
session_id: None,
enable_json_response: true,
on_resumption_token: None,
http_middleware_chain: None,
};
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(())
}