#![cfg(all(
feature = "streamable-http",
feature = "http-client",
not(target_arch = "wasm32")
))]
mod common;
use common::v2::{
post, spawn_default_config, spawn_tasks_server_with_store, teardown, v1_body,
v2_body_with_caps, v2_body_with_client_extensions, v2_headers, AuthPosture, OptionalBearer,
Resp, PAUSING_TOOL_NAME, PAUSING_TOOL_REQUEST_KEY, TASKS_TOOL_NAME,
};
use pmcp::server::task_store::{InMemoryTaskStore, TaskStore};
use pmcp::types::capabilities::TASKS_EXTENSION_KEY;
use pmcp::types::protocol::ProtocolVersion;
use pmcp::types::{TaskSupport, ToolExecution};
use pmcp::Server;
use serde_json::{json, Value};
use std::net::SocketAddr;
use std::sync::Arc;
const SUBJECT: &str = "alice";
fn auth_header() -> Vec<(String, String)> {
vec![("authorization".to_string(), format!("Bearer {SUBJECT}"))]
}
async fn declaring(addr: SocketAddr, method: &str, name: &str, id: i64, params: Value) -> Resp {
let mut headers = v2_headers(method, name);
headers.extend(auth_header());
let body = v2_body_with_client_extensions(method, json!(id), params, &[TASKS_EXTENSION_KEY]);
post(addr, &headers, &body).await
}
async fn non_declaring(addr: SocketAddr, method: &str, name: &str, id: i64, params: Value) -> Resp {
let mut headers = v2_headers(method, name);
headers.extend(auth_header());
let body = v2_body_with_caps(
method,
json!(id),
params,
json!({ "elicitation": {}, "sampling": {}, "roots": {} }),
);
post(addr, &headers, &body).await
}
fn call_params(tool: &str, with_v1_task_field: bool) -> Value {
let mut params = json!({ "name": tool, "arguments": {} });
if with_v1_task_field {
params["task"] = json!({});
}
params
}
fn result_of(response: &Resp) -> &Value {
response.body.get("result").unwrap_or_else(|| {
panic!("expected a success result, got {}", response.raw);
})
}
fn minted_id(response: &Resp) -> String {
result_of(response)["taskId"]
.as_str()
.unwrap_or_else(|| {
panic!(
"a v2 create result carries a TOP-LEVEL taskId; got {}",
response.raw
)
})
.to_string()
}
fn assert_ordinary_result(response: &Resp) {
assert!(
response.raw.contains("\"resultType\":\"complete\""),
"an ordinary tool result carries resultType=complete: {}",
response.raw
);
assert!(
!response.raw.contains("\"resultType\":\"task\""),
"the task disposition must NOT be minted here: {}",
response.raw
);
assert!(
!response.raw.contains("\"taskId\":"),
"no PROTOCOL-level taskId key may appear anywhere in the bytes: {}",
response.raw
);
let result = result_of(response);
assert!(
result.get("taskId").is_none(),
"no TOP-LEVEL taskId may reach a caller that gets an ordinary result: {}",
response.raw
);
assert!(
result.get("task").is_none(),
"no nested task wrapper either: {}",
response.raw
);
assert!(
result
.get("_meta")
.and_then(|meta| meta.get("io.modelcontextprotocol/related-task"))
.is_none(),
"no _meta.relatedTask id echo either: {}",
response.raw
);
}
async fn assert_store_is_empty(store: &Arc<InMemoryTaskStore>) {
let (tasks, _cursor) = store
.list(SUBJECT, None)
.await
.expect("listing an owner's tasks succeeds");
assert!(
tasks.is_empty(),
"a closed create gate must mint NOTHING; the store held {tasks:?}"
);
}
#[tokio::test]
async fn a_declaring_v2_client_receives_a_task_handle() {
let (addr, handle, store) = spawn_tasks_server_with_store(AuthPosture::Optional).await;
let ignored = non_declaring(
addr,
"tools/call",
TASKS_TOOL_NAME,
1,
call_params(TASKS_TOOL_NAME, false),
)
.await;
assert_ordinary_result(&ignored);
assert_store_is_empty(&store).await;
let created = declaring(
addr,
"tools/call",
TASKS_TOOL_NAME,
2,
call_params(TASKS_TOOL_NAME, false),
)
.await;
let task_id = minted_id(&created);
let polled = declaring(addr, "tasks/get", &task_id, 3, json!({ "taskId": task_id })).await;
teardown(handle, ()).await;
assert!(
created.raw.contains("\"resultType\":\"task\""),
"a declaring v2 client's create earns the task disposition: {}",
created.raw
);
assert_eq!(
result_of(&created)["status"],
json!("working"),
"{}",
created.raw
);
assert_eq!(
result_of(&polled)["taskId"],
json!(task_id),
"the returned handle must resolve through a real tasks/get: {}",
polled.raw
);
assert_eq!(
result_of(&polled)["status"],
json!("working"),
"{}",
polled.raw
);
}
#[tokio::test]
async fn a_handler_declared_input_request_is_recorded_against_the_minted_id() {
let (addr, handle, store) = spawn_tasks_server_with_store(AuthPosture::Optional).await;
let ignored = non_declaring(
addr,
"tools/call",
PAUSING_TOOL_NAME,
1,
call_params(PAUSING_TOOL_NAME, false),
)
.await;
assert_ordinary_result(&ignored);
assert_store_is_empty(&store).await;
let created = declaring(
addr,
"tools/call",
PAUSING_TOOL_NAME,
2,
call_params(PAUSING_TOOL_NAME, false),
)
.await;
let task_id = minted_id(&created);
let polled = declaring(addr, "tasks/get", &task_id, 3, json!({ "taskId": task_id })).await;
teardown(handle, ()).await;
assert_ne!(
task_id, "tool-fabricated",
"the wire id must be store-minted: {}",
created.raw
);
assert_eq!(
result_of(&polled)["status"],
json!("input_required"),
"the returned handle must already be PAUSED: {}",
polled.raw
);
assert_eq!(
result_of(&polled)["inputRequests"][PAUSING_TOOL_REQUEST_KEY]["method"],
json!("roots/list"),
"the inlined map must be the one the HANDLER declared: {}",
polled.raw
);
assert!(
polled.raw.contains("\"inputRequests\""),
"the required key must survive egress to the WIRE: {}",
polled.raw
);
}
#[tokio::test]
async fn a_non_declaring_v2_client_receives_an_ordinary_result() {
let (addr, handle, store) = spawn_tasks_server_with_store(AuthPosture::Optional).await;
let response = non_declaring(
addr,
"tools/call",
TASKS_TOOL_NAME,
1,
call_params(TASKS_TOOL_NAME, false),
)
.await;
assert_ordinary_result(&response);
assert_store_is_empty(&store).await;
let probe = declaring(
addr,
"tasks/get",
"tool-fabricated",
2,
json!({ "taskId": "tool-fabricated" }),
)
.await;
teardown(handle, ()).await;
assert!(
probe.body.get("error").is_some(),
"the tool-fabricated id must not resolve to a task: {}",
probe.raw
);
}
#[tokio::test]
async fn a_v2_client_sending_the_v1_task_field_still_needs_the_declaration() {
let (addr, handle, store) = spawn_tasks_server_with_store(AuthPosture::Optional).await;
let with_v1_field = non_declaring(
addr,
"tools/call",
TASKS_TOOL_NAME,
1,
call_params(TASKS_TOOL_NAME, true),
)
.await;
assert_ordinary_result(&with_v1_field);
assert_store_is_empty(&store).await;
let declared = declaring(
addr,
"tools/call",
TASKS_TOOL_NAME,
2,
call_params(TASKS_TOOL_NAME, true),
)
.await;
teardown(handle, ()).await;
assert!(
declared.raw.contains("\"resultType\":\"task\""),
"the declaration is what makes this body creatable: {}",
declared.raw
);
}
const V1_CREATE: &str = r#"{"jsonrpc":"2.0","id":1,"result":{"task":{"taskId":"<TASK-ID>","status":"working","ttl":3600000,"createdAt":"<TIMESTAMP>","lastUpdatedAt":"<TIMESTAMP>","pollInterval":5000},"_meta":{"io.modelcontextprotocol/related-task":{"taskId":"<TASK-ID>"}}}}"#;
fn normalize_v1(raw: &str, task_id: &str) -> String {
let mut normalized = raw.replace(task_id, "<TASK-ID>");
while let Some(start) = normalized.find("20") {
let candidate = &normalized[start..];
if candidate.len() >= 20 && candidate.as_bytes()[4] == b'-' && candidate.contains('T') {
let end = start + candidate.find('"').unwrap_or(0);
if end > start {
normalized.replace_range(start..end, "<TIMESTAMP>");
continue;
}
}
break;
}
normalized
}
async fn v1_session_headers(addr: SocketAddr) -> Vec<(String, String)> {
if !cfg!(feature = "v1-compat") {
return auth_header();
}
let initialized = post(
addr,
&auth_header(),
&v1_body(
"initialize",
json!(0),
json!({
"protocolVersion": common::v2::V1,
"capabilities": {},
"clientInfo": { "name": "v1-client", "version": "0.0.0" }
}),
),
)
.await;
let session = initialized.mcp_session_id.unwrap_or_else(|| {
panic!(
"a stateful v1 handshake must mint a session id: {}",
initialized.raw
)
});
let mut headers = auth_header();
headers.push((
pmcp::shared::http_constants::MCP_SESSION_ID.to_string(),
session,
));
headers
}
#[tokio::test]
async fn a_v1_client_still_triggers_with_the_task_field() {
let (addr, handle, store) = spawn_tasks_server_with_store(AuthPosture::Optional).await;
let headers = v1_session_headers(addr).await;
let without_field = post(
addr,
&headers,
&v1_body("tools/call", json!(9), call_params(TASKS_TOOL_NAME, false)),
)
.await;
assert!(
without_field.body["result"].get("task").is_none(),
"v1 without a `task` field must still fall through: {}",
without_field.raw
);
assert_store_is_empty(&store).await;
let created = post(
addr,
&headers,
&v1_body("tools/call", json!(1), call_params(TASKS_TOOL_NAME, true)),
)
.await;
teardown(handle, ()).await;
let task_id = created.body["result"]["task"]["taskId"]
.as_str()
.unwrap_or_else(|| panic!("a v1 create envelope nests under `task`: {}", created.raw))
.to_string();
let body = serde_json::to_string(&created.body).expect("the body re-serializes");
assert_eq!(
normalize_v1(&body, &task_id),
V1_CREATE,
"the v1 create wire moved. raw: {}",
created.raw
);
for v2_only in ["ttlMs", "pollIntervalMs", "resultType"] {
assert!(
!created.raw.contains(v2_only),
"`{v2_only}` leaked onto the v1 wire: {}",
created.raw
);
}
}
const PROBE_TOOL_NAME: &str = "probe";
fn probe_tool(task_support: Option<TaskSupport>) -> impl pmcp::ToolHandler {
let tool = pmcp::server::typed_tool::TypedTool::new_with_schema(
PROBE_TOOL_NAME,
json!({ "type": "object" }),
|_args: Value, _extra| {
Box::pin(async {
Ok(json!({
"taskId": "tool-fabricated",
"status": "working",
"createdAt": "2026-07-28T00:00:00Z",
"lastUpdatedAt": "2026-07-28T00:00:00Z"
}))
})
},
)
.with_description("a probe tool that returns a task-shaped value");
match task_support {
Some(support) => tool.with_execution(ToolExecution::new().with_task_support(support)),
None => tool,
}
}
async fn spawn_gate_probe_server(
task_support: Option<TaskSupport>,
with_store: bool,
) -> (SocketAddr, tokio::task::JoinHandle<()>) {
let mut builder = Server::builder()
.name("v2-create-gate-probe")
.version("1.0.0")
.with_supported_protocol_versions([
ProtocolVersion(common::v2::V1.to_string()),
ProtocolVersion(common::v2::V2.to_string()),
])
.tool(PROBE_TOOL_NAME, probe_tool(task_support))
.auth_provider(OptionalBearer);
if with_store {
builder = builder.task_store(Arc::new(InMemoryTaskStore::new()) as Arc<dyn TaskStore>);
}
let server = builder.build().expect("probe server builds");
spawn_default_config(server).await
}
#[tokio::test]
async fn a_declaring_client_on_a_tool_with_no_task_support_receives_an_ordinary_result() {
for support in [None, Some(TaskSupport::Forbidden)] {
let (addr, handle) = spawn_gate_probe_server(support, true).await;
let response = declaring(
addr,
"tools/call",
PROBE_TOOL_NAME,
1,
call_params(PROBE_TOOL_NAME, false),
)
.await;
teardown(handle, ()).await;
assert!(
response.body.get("error").is_none(),
"a closed gate must NOT leak an error (support={support:?}): {}",
response.raw
);
assert_ordinary_result(&response);
}
}
#[tokio::test]
async fn a_declaring_client_on_a_server_with_no_store_receives_an_ordinary_result() {
let (addr, handle) = spawn_gate_probe_server(Some(TaskSupport::Optional), false).await;
let response = declaring(
addr,
"tools/call",
PROBE_TOOL_NAME,
1,
call_params(PROBE_TOOL_NAME, false),
)
.await;
teardown(handle, ()).await;
assert!(
response.body.get("error").is_none(),
"a backendless server must answer the tool call normally, not with an error: {}",
response.raw
);
assert_ordinary_result(&response);
}