#![cfg(all(feature = "streamable-http", not(target_arch = "wasm32")))]
use std::net::SocketAddr;
use std::sync::Arc;
use pmcp::server::streamable_http_server::StreamableHttpServer;
use pmcp::server::task_store::{InMemoryTaskStore, TaskStore};
use pmcp::server::typed_tool::TypedTool;
use pmcp::shared::streamable_http::{StreamableHttpTransport, StreamableHttpTransportConfig};
use pmcp::types::{ClientCapabilities, TaskSupport, ToolExecution};
use pmcp::{Client, ErrorCode, Server, ToolCallResponse};
use tokio::sync::Mutex;
use url::Url;
fn completing_task_tool() -> impl pmcp::ToolHandler {
TypedTool::new_with_schema(
"complete_now",
serde_json::json!({ "type": "object" }),
|_args: serde_json::Value, _extra| {
Box::pin(async {
Ok(serde_json::json!({
"taskId": "tool-fabricated",
"status": "completed",
"ttl": 60000,
"createdAt": "2026-06-21T00:00:00Z",
"lastUpdatedAt": "2026-06-21T00:00:00Z",
"result": {
"content": [ { "type": "text", "text": "terminal result payload" } ]
}
}))
})
},
)
.with_description("A task tool that completes synchronously with content")
.with_execution(ToolExecution::new().with_task_support(TaskSupport::Required))
}
fn pending_task_tool() -> impl pmcp::ToolHandler {
TypedTool::new_with_schema(
"stay_pending",
serde_json::json!({ "type": "object" }),
|_args: serde_json::Value, _extra| {
Box::pin(async {
Ok(serde_json::json!({
"taskId": "tool-fabricated",
"status": "working",
"ttl": 60000,
"createdAt": "2026-06-21T00:00:00Z",
"lastUpdatedAt": "2026-06-21T00:00:00Z"
}))
})
},
)
.with_description("A task tool that stays pending (no terminal content)")
.with_execution(ToolExecution::new().with_task_support(TaskSupport::Required))
}
fn build_task_server() -> pmcp::Result<Server> {
let store = Arc::new(InMemoryTaskStore::new()) as Arc<dyn TaskStore>;
Server::builder()
.name("s46-http-task-server")
.version("1.0.0")
.tool("complete_now", completing_task_tool())
.tool("stay_pending", pending_task_tool())
.task_store(store)
.build()
}
async fn spawn_http_task_server() -> pmcp::Result<(SocketAddr, tokio::task::JoinHandle<()>)> {
let server = Arc::new(Mutex::new(build_task_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, server_handle) = http_server.start().await?;
Ok((bound, server_handle))
}
fn http_client(
bound: SocketAddr,
owner: Option<&str>,
) -> pmcp::Result<Client<StreamableHttpTransport>> {
let extra_headers = owner
.map(|id| vec![("x-pmcp-user-id".to_string(), id.to_string())])
.unwrap_or_default();
let config = StreamableHttpTransportConfig {
url: Url::parse(&format!("http://{bound}"))
.map_err(|e| pmcp::Error::Internal(e.to_string()))?,
extra_headers,
auth_provider: None,
session_id: None,
enable_json_response: true,
on_resumption_token: None,
http_middleware_chain: None,
};
Ok(Client::new(StreamableHttpTransport::new(config)))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn live_http_round_trip_typed_lifecycle_id_consistency_and_capability() -> pmcp::Result<()> {
let (bound, server_handle) = spawn_http_task_server().await?;
let outcome = run_lifecycle_over_http(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_lifecycle_over_http(bound: SocketAddr) -> pmcp::Result<()> {
let mut client = http_client(bound, None)?;
let init = client.initialize(ClientCapabilities::default()).await?;
assert!(
init.capabilities.tasks.is_some(),
"server must auto-advertise the `tasks` capability over HTTP (HTASK-03)"
);
let response = client
.call_tool_with_task("complete_now".to_string(), serde_json::json!({}))
.await?;
let client_task_id = match response {
ToolCallResponse::Task(task) => task.task_id,
ToolCallResponse::Result(_) => {
return Err(pmcp::Error::internal(
"expected a created task, got a sync result",
))
},
};
assert_ne!(
client_task_id, "tool-fabricated",
"wire id must be the store-minted id over HTTP, not a tool-fabricated one"
);
let mut polled = client.tasks_get(&client_task_id).await?;
for _ in 0..50 {
assert_eq!(
polled.task_id, client_task_id,
"polled task id must equal the client-observed (store-minted) id over HTTP"
);
if polled.status.is_terminal() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
polled = client.tasks_get(&client_task_id).await?;
}
assert!(
polled.status.is_terminal(),
"task should reach a terminal status over HTTP (synchronous completion)"
);
let result = client.tasks_result(&client_task_id).await?;
assert!(
!result.content.is_empty(),
"tasks/result must carry the persisted non-empty terminal content over HTTP"
);
let pending = client
.call_tool_with_task("stay_pending".to_string(), serde_json::json!({}))
.await?;
let pending_id = match pending {
ToolCallResponse::Task(task) => task.task_id,
ToolCallResponse::Result(_) => {
return Err(pmcp::Error::internal("expected a created (pending) task"))
},
};
let err = client
.tasks_result(&pending_id)
.await
.expect_err("pending tasks/result must be an error over HTTP, not Ok");
assert_eq!(
err.error_code(),
Some(ErrorCode(-32002)),
"pending tasks/result must return the specified -32002 not-completed error over HTTP, got: {err}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn live_http_cross_owner_isolation() -> pmcp::Result<()> {
let (bound, server_handle) = spawn_http_task_server().await?;
let outcome = run_cross_owner_over_http(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_cross_owner_over_http(bound: SocketAddr) -> pmcp::Result<()> {
let mut alice = http_client(bound, Some("alice"))?;
alice.initialize(ClientCapabilities::default()).await?;
let alice_task = match alice
.call_tool_with_task("complete_now".to_string(), serde_json::json!({}))
.await?
{
ToolCallResponse::Task(task) => task.task_id,
ToolCallResponse::Result(_) => {
return Err(pmcp::Error::internal("expected a created task for alice"))
},
};
let alice_view = alice.tasks_get(&alice_task).await?;
assert_eq!(
alice_view.task_id, alice_task,
"owner A must retain access to its own task over HTTP"
);
let mut bob = http_client(bound, Some("bob"))?;
bob.initialize(ClientCapabilities::default()).await?;
bob.tasks_get(&alice_task)
.await
.expect_err("owner B must NOT be able to tasks/get owner A's task over HTTP");
bob.tasks_result(&alice_task)
.await
.expect_err("owner B must NOT be able to tasks/result owner A's task over HTTP");
bob.tasks_cancel(&alice_task)
.await
.expect_err("owner B must NOT be able to tasks/cancel owner A's task over HTTP");
Ok(())
}