#![cfg(all(
feature = "streamable-http",
feature = "http-client",
not(target_arch = "wasm32")
))]
mod common;
use common::v2::{
header, post, spawn_tasks_server_with_store, teardown, v1_body, v2_body_with_client_extensions,
v2_headers, AuthPosture, Resp, PAUSING_TOOL_NAME, PAUSING_TOOL_REQUEST_KEY, TASKS_TOOL_NAME,
};
use pmcp::server::task_store::{InMemoryTaskStore, StoreConfig, TaskStore};
use pmcp::testing::ANONYMOUS_PRINCIPAL;
use pmcp::types::capabilities::TASKS_EXTENSION_KEY;
use pmcp::types::protocol::error_codes::INVALID_PARAMS;
use serde_json::{json, Value};
use std::collections::BTreeSet;
use std::net::SocketAddr;
const OWNER_A: &str = "alice";
const OWNER_B: &str = "mallory";
const V1_LOCAL_OWNER: &str = "local";
const NEVER_MINTED: &str = "3f2504e0-4f89-41d3-9a0c-0305e82c3301";
fn bearer(subject: &str) -> Vec<(String, String)> {
vec![header("authorization", &format!("Bearer {subject}"))]
}
async fn declaring(
addr: SocketAddr,
subject: Option<&str>,
method: &str,
name: &str,
id: i64,
params: Value,
) -> Resp {
let mut headers = v2_headers(method, name);
if let Some(subject) = subject {
headers.extend(bearer(subject));
}
let body = v2_body_with_client_extensions(method, json!(id), params, &[TASKS_EXTENSION_KEY]);
post(addr, &headers, &body).await
}
async fn tasks_get(addr: SocketAddr, subject: Option<&str>, task_id: &str, id: i64) -> Resp {
declaring(
addr,
subject,
"tasks/get",
task_id,
id,
json!({ "taskId": task_id }),
)
.await
}
async fn tasks_cancel(addr: SocketAddr, subject: Option<&str>, task_id: &str, id: i64) -> Resp {
declaring(
addr,
subject,
"tasks/cancel",
task_id,
id,
json!({ "taskId": task_id }),
)
.await
}
async fn tasks_update(
addr: SocketAddr,
subject: Option<&str>,
task_id: &str,
id: i64,
responses: Value,
) -> Resp {
declaring(
addr,
subject,
"tasks/update",
task_id,
id,
json!({ "taskId": task_id, "inputResponses": responses }),
)
.await
}
fn roots_answer() -> Value {
json!({ PAUSING_TOOL_REQUEST_KEY: { "roots": [] } })
}
async fn create_task(addr: SocketAddr, subject: Option<&str>, tool: &str, id: i64) -> Value {
let created = declaring(
addr,
subject,
"tools/call",
tool,
id,
json!({ "name": tool, "arguments": {} }),
)
.await;
created
.body
.get("result")
.filter(|result| result.get("taskId").is_some())
.unwrap_or_else(|| {
panic!(
"a declaring v2 tools/call on `{tool}` mints a flat task handle: {}",
created.raw
)
})
.clone()
}
fn task_id_of(create_result: &Value) -> String {
create_result["taskId"]
.as_str()
.expect("a flat v2 create result carries a top-level taskId")
.to_string()
}
fn result_of(response: &Resp) -> &Value {
response.body.get("result").unwrap_or_else(|| {
panic!("expected a success result, got {}", response.raw);
})
}
fn error_of(response: &Resp) -> &Value {
response
.body
.get("error")
.unwrap_or_else(|| panic!("expected a JSON-RPC error, got {}", response.raw))
}
fn code_of(response: &Resp) -> i64 {
error_of(response)["code"]
.as_i64()
.unwrap_or_else(|| panic!("an error carries a numeric code; got {}", response.raw))
}
fn message_of(response: &Resp) -> String {
error_of(response)["message"]
.as_str()
.unwrap_or_default()
.to_string()
}
const LEAKY_WORDS: [&str; 3] = ["owner", "mismatch", "forbidden"];
fn assert_indistinguishable_not_found(
method: &str,
refused: &Resp,
absent: &Resp,
other_subject: &str,
) {
assert_eq!(
code_of(refused),
i64::from(INVALID_PARAMS),
"{method} for another caller's task is the v2 task-not-found code: {}",
refused.raw
);
assert_eq!(
code_of(absent),
i64::from(INVALID_PARAMS),
"{method} for a genuinely absent id must reach the same code, or the equality \
below would compare two unrelated failures: {}",
absent.raw
);
assert_eq!(
message_of(refused),
message_of(absent),
"{method}: a wrong-owner refusal must be INDISTINGUISHABLE from an absent id. \
Both messages are computed in this test from the same server, so this is a \
measurement, not a literal. wrong-owner: {} / absent: {}",
refused.raw,
absent.raw
);
assert_eq!(
error_of(refused).get("data"),
error_of(absent).get("data"),
"{method}: an `error.data` payload present on one and not the other is an oracle \
even when the messages match: {}",
refused.raw
);
let lowered = refused.raw.to_lowercase();
for word in LEAKY_WORDS {
assert!(
!lowered.contains(word),
"{method}: the refusal must not contain `{word}` — naming the reason confirms \
the id exists, which is the one fact the owner-scoped lookup withholds: {}",
refused.raw
);
}
assert!(
!lowered.contains(&other_subject.to_lowercase()),
"{method}: the refusal must not name `{other_subject}`, the task's real owner: {}",
refused.raw
);
assert!(
refused.body["result"].is_null(),
"{method}: a refusal carries no result: {}",
refused.raw
);
for fragment in [
"\"status\"",
"\"createdAt\"",
"\"lastUpdatedAt\"",
"\"taskId\"",
] {
assert!(
!refused.raw.contains(fragment),
"{method}: no fragment of the refused task may appear on the wire, and \
{fragment} did: {}",
refused.raw
);
}
}
fn assert_record_untouched(before: &Value, after: &Value, context: &str) {
for field in ["taskId", "status", "createdAt", "lastUpdatedAt"] {
assert!(
before[field].is_string(),
"{context}: the baseline payload must carry `{field}` or the comparison \
below is vacuous: {before}"
);
assert!(
after[field].is_string(),
"{context}: the re-read payload must carry `{field}` or the comparison \
below is vacuous: {after}"
);
assert_eq!(
before[field], after[field],
"{context}: `{field}` moved, so the refused call DID write to the record. \
before: {before} / after: {after}"
);
}
}
#[tokio::test]
async fn v2_cross_caller_tasks_get_is_not_found() {
let (addr, handle, _store) = spawn_tasks_server_with_store(AuthPosture::Required).await;
let created = create_task(addr, Some(OWNER_A), TASKS_TOOL_NAME, 1).await;
let task_id = task_id_of(&created);
let refused = tasks_get(addr, Some(OWNER_B), &task_id, 2).await;
let absent = tasks_get(addr, Some(OWNER_B), NEVER_MINTED, 3).await;
let owner_view = tasks_get(addr, Some(OWNER_A), &task_id, 4).await;
teardown(handle, ()).await;
assert_indistinguishable_not_found("tasks/get", &refused, &absent, OWNER_A);
assert_eq!(
result_of(&owner_view)["taskId"],
json!(task_id),
"the control: the SAME id resolves for its OWNER, so the refusal above is \
attributable to the caller and not to a broken lookup: {}",
owner_view.raw
);
assert_record_untouched(&created, result_of(&owner_view), "tasks/get");
}
#[tokio::test]
async fn v2_cross_caller_tasks_update_is_not_found() {
let (addr, handle, store) = spawn_tasks_server_with_store(AuthPosture::Required).await;
let created = create_task(addr, Some(OWNER_A), PAUSING_TOOL_NAME, 1).await;
let task_id = task_id_of(&created);
let refused = tasks_update(addr, Some(OWNER_B), &task_id, 2, roots_answer()).await;
let absent = tasks_update(addr, Some(OWNER_B), NEVER_MINTED, 3, roots_answer()).await;
let after_refusal = tasks_get(addr, Some(OWNER_A), &task_id, 4).await;
let snapshot = store
.task_input_snapshot(&task_id, OWNER_A)
.await
.expect("the owner can snapshot its own paused task");
let accepted = tasks_update(addr, Some(OWNER_A), &task_id, 5, roots_answer()).await;
let after_delivery = tasks_get(addr, Some(OWNER_A), &task_id, 6).await;
teardown(handle, ()).await;
assert_indistinguishable_not_found("tasks/update", &refused, &absent, OWNER_A);
assert_eq!(
result_of(&after_refusal)["status"],
json!("input_required"),
"the task must still be PAUSED: a refusal that resumed it would be a complete \
cross-caller write: {}",
after_refusal.raw
);
assert!(
snapshot.input_responses.is_empty(),
"B's payload must not have been persisted; the record held {:?}",
snapshot.input_responses
);
assert_eq!(
snapshot.outstanding().len(),
1,
"the outstanding set is untouched, so nothing was consumed on B's behalf"
);
assert!(
accepted.body.get("error").is_none(),
"the control: the SAME payload from the OWNER is acknowledged, so the refusal \
above is attributable to the caller and not to the payload: {}",
accepted.raw
);
assert_eq!(
result_of(&after_delivery)["status"],
json!("working"),
"and the owner's delivery genuinely resumed the task — without this the refusal \
above would be consistent with `tasks/update` being broken for everyone: {}",
after_delivery.raw
);
}
#[tokio::test]
async fn v2_cross_caller_tasks_cancel_is_not_found() {
let (addr, handle, _store) = spawn_tasks_server_with_store(AuthPosture::Required).await;
let created = create_task(addr, Some(OWNER_A), TASKS_TOOL_NAME, 1).await;
let task_id = task_id_of(&created);
let refused = tasks_cancel(addr, Some(OWNER_B), &task_id, 2).await;
let absent = tasks_cancel(addr, Some(OWNER_B), NEVER_MINTED, 3).await;
let after_refusal = tasks_get(addr, Some(OWNER_A), &task_id, 4).await;
let cancelled = tasks_cancel(addr, Some(OWNER_A), &task_id, 5).await;
let after_cancel = tasks_get(addr, Some(OWNER_A), &task_id, 6).await;
teardown(handle, ()).await;
assert_indistinguishable_not_found("tasks/cancel", &refused, &absent, OWNER_A);
assert_eq!(
result_of(&after_refusal)["status"],
json!("working"),
"THE load-bearing assertion of this test: a refusal that still cancelled would \
pass every assertion above it: {}",
after_refusal.raw
);
assert_record_untouched(&created, result_of(&after_refusal), "tasks/cancel");
assert!(
cancelled.body.get("error").is_none(),
"the control: the OWNER's cancel is acknowledged: {}",
cancelled.raw
);
assert_eq!(
result_of(&after_cancel)["status"],
json!("cancelled"),
"and it genuinely cancelled — without this the untouched status above would be \
consistent with cancel being broken for everyone: {}",
after_cancel.raw
);
}
#[tokio::test]
async fn v2_owner_isolation_holds_for_a_second_task_of_the_same_shape() {
let (addr, handle, _store) = spawn_tasks_server_with_store(AuthPosture::Required).await;
let a_task = task_id_of(&create_task(addr, Some(OWNER_A), TASKS_TOOL_NAME, 1).await);
let b_task = task_id_of(&create_task(addr, Some(OWNER_B), TASKS_TOOL_NAME, 2).await);
assert_ne!(
a_task, b_task,
"two creates must mint two distinct ids, or the isolation claim is vacuous"
);
let a_reads_b = tasks_get(addr, Some(OWNER_A), &b_task, 3).await;
let b_reads_a = tasks_get(addr, Some(OWNER_B), &a_task, 4).await;
let a_absent = tasks_get(addr, Some(OWNER_A), NEVER_MINTED, 5).await;
let b_absent = tasks_get(addr, Some(OWNER_B), NEVER_MINTED, 6).await;
let a_reads_a = tasks_get(addr, Some(OWNER_A), &a_task, 7).await;
let b_reads_b = tasks_get(addr, Some(OWNER_B), &b_task, 8).await;
teardown(handle, ()).await;
assert_indistinguishable_not_found("tasks/get (A->B)", &a_reads_b, &a_absent, OWNER_B);
assert_indistinguishable_not_found("tasks/get (B->A)", &b_reads_a, &b_absent, OWNER_A);
assert_eq!(
result_of(&a_reads_a)["taskId"],
json!(a_task),
"A reads its OWN task: {}",
a_reads_a.raw
);
assert_eq!(
result_of(&b_reads_b)["taskId"],
json!(b_task),
"B reads its OWN task — so the refusals above are isolation, not an outage: {}",
b_reads_b.raw
);
}
#[tokio::test]
async fn v2_a_guessed_task_id_is_not_found() {
let (addr, handle, _store) = spawn_tasks_server_with_store(AuthPosture::Required).await;
let owned = task_id_of(&create_task(addr, Some(OWNER_A), TASKS_TOOL_NAME, 1).await);
let guessed_get = tasks_get(addr, Some(OWNER_A), NEVER_MINTED, 2).await;
let guessed_update = tasks_update(addr, Some(OWNER_A), NEVER_MINTED, 3, roots_answer()).await;
let guessed_cancel = tasks_cancel(addr, Some(OWNER_A), NEVER_MINTED, 4).await;
let real = tasks_get(addr, Some(OWNER_A), &owned, 5).await;
teardown(handle, ()).await;
for (method, response) in [
("tasks/get", &guessed_get),
("tasks/update", &guessed_update),
("tasks/cancel", &guessed_cancel),
] {
assert_eq!(
code_of(response),
i64::from(INVALID_PARAMS),
"{method} on a never-minted id is the same task-not-found code every other \
unreachable id earns: {}",
response.raw
);
assert!(
!response.raw.contains(NEVER_MINTED),
"{method} must not echo the guessed id back — an echo turns the log into an \
attacker-chosen channel: {}",
response.raw
);
}
assert_eq!(
result_of(&real)["taskId"],
json!(owned),
"the control: a REAL id owned by the same caller resolves, so the three refusals \
above are about the id and not about the server: {}",
real.raw
);
}
const ENTROPY_SAMPLE: usize = 1024;
const REQUIRED_ENTROPY_BITS: f64 = 122.0;
const MAX_SHARED_PREFIX: usize = 8;
fn sampling_store() -> InMemoryTaskStore {
InMemoryTaskStore::with_config(StoreConfig {
max_tasks_per_owner: ENTROPY_SAMPLE * 4,
..StoreConfig::default()
})
}
async fn mint_ids(store: &InMemoryTaskStore, owner: &str, count: usize) -> Vec<String> {
let mut ids = Vec::with_capacity(count);
for _ in 0..count {
let task = store
.create(owner, None)
.await
.expect("the sampling store mints without hitting its per-owner cap");
ids.push(task.task_id);
}
ids
}
fn observed_entropy_bits(ids: &[String]) -> f64 {
let rows: Vec<Vec<char>> = ids.iter().map(|id| id.chars().collect()).collect();
let width = rows[0].len();
(0..width)
.map(|position| {
let distinct: BTreeSet<char> = rows.iter().map(|row| row[position]).collect();
(distinct.len() as f64).log2()
})
.sum()
}
fn common_prefix_len(left: &str, right: &str) -> usize {
left.chars()
.zip(right.chars())
.take_while(|(a, b)| a == b)
.count()
}
fn shared_literal_prefix(ids: &[String]) -> usize {
ids.iter()
.skip(1)
.fold(ids[0].chars().count(), |shortest, id| {
shortest.min(common_prefix_len(&ids[0], id))
})
}
fn as_integer(id: &str) -> Option<u128> {
let digits: String = id.chars().filter(|c| *c != '-').collect();
if digits.len() != 32 || !digits.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
u128::from_str_radix(&digits, 16).ok()
}
#[tokio::test]
async fn task_ids_are_unguessable() {
let store = sampling_store();
let ids = mint_ids(&store, OWNER_A, ENTROPY_SAMPLE).await;
let (addr, handle, _store) = spawn_tasks_server_with_store(AuthPosture::Required).await;
let over_the_wire = task_id_of(&create_task(addr, Some(OWNER_A), TASKS_TOOL_NAME, 1).await);
teardown(handle, ()).await;
let width = ids[0].chars().count();
assert!(
ids.iter().all(|id| id.chars().count() == width),
"the per-position estimator below assumes a fixed width; a variable-length \
encoding needs a different estimator and this assertion is where a reader \
finds that out"
);
assert_eq!(
over_the_wire.chars().count(),
width,
"an id minted over a real socket has the same shape as the sampled ones, so \
the measurements below describe the ids a caller actually holds"
);
let bits = observed_entropy_bits(&ids);
assert!(
bits >= REQUIRED_ENTROPY_BITS,
"minted ids realize only {bits:.1} bits of entropy across {ENTROPY_SAMPLE} \
samples, below the {REQUIRED_ENTROPY_BITS} the unguessability requirement \
needs. This is a LOWER bound computed from the sample, so a value under the \
floor means the encoding genuinely cannot carry it"
);
let unique: BTreeSet<&String> = ids.iter().collect();
assert_eq!(
unique.len(),
ids.len(),
"a repeated id is a collision, and a collision is a cross-caller read waiting \
for the right two owners"
);
let mut sorted = ids.clone();
sorted.sort();
assert_ne!(
sorted, ids,
"the mint order must not BE the sort order: every monotonic generator — a \
counter, a timestamp, a lexicographically-sortable identifier — produces ids \
already in order, and one that holds for {ENTROPY_SAMPLE} draws is not chance"
);
let numbers: Vec<u128> = sorted
.iter()
.map(|id| {
as_integer(id).unwrap_or_else(|| {
panic!(
"the adjacency check needs a total order the encoding admits, and \
`{id}` is not one this helper knows how to read; teach it the new \
encoding rather than deleting the check"
)
})
})
.collect();
for pair in numbers.windows(2) {
assert_ne!(
pair[1] - pair[0],
1,
"two minted ids are numerically ADJACENT ({:x} and {:x}); holding one would \
hand an attacker its neighbour",
pair[0],
pair[1]
);
}
let literal = shared_literal_prefix(&ids);
for pair in ids.windows(2) {
let shared = common_prefix_len(&pair[0], &pair[1]).saturating_sub(literal);
assert!(
shared <= MAX_SHARED_PREFIX,
"`{}` and `{}` were minted back-to-back for the same owner and share {shared} \
characters beyond the {literal}-character fixed literal — that is the \
signature of a timestamp or owner-derived prefix, which makes the \
high-order part of an id predictable",
pair[0],
pair[1]
);
}
assert!(
ids.iter().all(|id| !id.contains(OWNER_A)),
"no minted id may embed the owner it belongs to"
);
let other = mint_ids(&store, OWNER_B, 64).await;
let mut population = ids.clone();
population.extend(other.iter().cloned());
assert_eq!(
shared_literal_prefix(&other),
shared_literal_prefix(&population),
"one owner's ids share no more of a prefix than the whole population does, so \
the id carries no owner tag"
);
}
const GENERIC_STORE_SOURCE: &str = "crates/pmcp-tasks/src/store/generic.rs";
const BACKEND_SOURCE: &str = "crates/pmcp-tasks/src/store/backend.rs";
fn read_workspace_source(relative: &str) -> String {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(relative);
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()))
}
async fn v1_session_headers(addr: SocketAddr) -> Vec<(String, String)> {
if !cfg!(feature = "v1-compat") {
return Vec::new();
}
let initialized = post(
addr,
&[],
&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
)
});
vec![header(
pmcp::shared::http_constants::MCP_SESSION_ID,
&session,
)]
}
#[tokio::test]
async fn v1_local_and_v2_anonymous_buckets_are_disjoint() {
let (addr, handle, store) = spawn_tasks_server_with_store(AuthPosture::None).await;
let v1_headers = v1_session_headers(addr).await;
let v1_created = post(
addr,
&v1_headers,
&v1_body(
"tools/call",
json!(1),
json!({ "name": TASKS_TOOL_NAME, "arguments": {}, "task": {} }),
),
)
.await;
let v1_task = v1_created.body["result"]["task"]["taskId"]
.as_str()
.unwrap_or_else(|| {
panic!(
"a v1 create envelope nests under `task`: {}",
v1_created.raw
)
})
.to_string();
let v2_task = task_id_of(&create_task(addr, None, TASKS_TOOL_NAME, 2).await);
let v2_reads_v1 = tasks_get(addr, None, &v1_task, 3).await;
let v1_reads_v2 = post(
addr,
&v1_headers,
&v1_body("tasks/get", json!(4), json!({ "taskId": v2_task })),
)
.await;
let v2_reads_v2 = tasks_get(addr, None, &v2_task, 5).await;
let v1_reads_v1 = post(
addr,
&v1_headers,
&v1_body("tasks/get", json!(6), json!({ "taskId": v1_task })),
)
.await;
teardown(handle, ()).await;
assert_ne!(
v1_task, v2_task,
"the two eras minted two distinct ids, or the disjointness claim is vacuous"
);
assert!(
v2_reads_v1.body.get("error").is_some(),
"a v2 (anonymous-principal) caller must not reach the v1 `local` bucket: {}",
v2_reads_v1.raw
);
assert!(
v1_reads_v2.body.get("error").is_some(),
"and a v1 (`local`) caller must not reach the v2 anonymous bucket: {}",
v1_reads_v2.raw
);
assert_eq!(
result_of(&v2_reads_v2)["taskId"],
json!(v2_task),
"the control: the v2 caller reads its OWN task: {}",
v2_reads_v2.raw
);
assert_eq!(
v1_reads_v1.body["result"]["task"]["taskId"],
json!(v1_task),
"the control: the v1 caller reads its OWN task, so the refusals above are \
disjointness and not an outage: {}",
v1_reads_v1.raw
);
assert!(
store.get(&v1_task, ANONYMOUS_PRINCIPAL).await.is_err(),
"the v1 task is not readable under the v2 anonymous owner"
);
assert!(
store.get(&v2_task, V1_LOCAL_OWNER).await.is_err(),
"the v2 task is not readable under the v1 `local` owner"
);
assert!(
store.get(&v1_task, V1_LOCAL_OWNER).await.is_ok(),
"and each IS readable under its own owner, so the two refusals above are about \
the owner and not about the ids"
);
assert!(store.get(&v2_task, ANONYMOUS_PRINCIPAL).await.is_ok());
assert_ne!(
ANONYMOUS_PRINCIPAL, V1_LOCAL_OWNER,
"the two buckets are two DIFFERENT owner strings; that is what makes their \
storage namespaces disjoint"
);
let generic = read_workspace_source(GENERIC_STORE_SOURCE);
assert!(
generic.contains("fn is_anonymous_owner(owner_id: &str) -> bool {\n owner_id.is_empty() || owner_id == DEFAULT_LOCAL_OWNER\n }"),
"`is_anonymous_owner` in {GENERIC_STORE_SOURCE} must keep treating the empty \
owner and the `local` owner identically. If this predicate was split, the \
`allow_anonymous: false` default no longer refuses both buckets and this \
test's rustdoc has become wrong"
);
let backend = read_workspace_source(BACKEND_SOURCE);
assert!(
backend.contains(r#"format!("{owner_id}:{task_id}")"#),
"`make_key` in {BACKEND_SOURCE} must keep prefixing by owner; dropping the \
prefix collapses every owner's tasks into one namespace and the disjointness \
asserted above stops holding on the production backends"
);
}
#[tokio::test]
async fn a_no_auth_provider_server_shares_one_v2_bucket() {
let (addr, handle, _store) = spawn_tasks_server_with_store(AuthPosture::None).await;
let created = task_id_of(&create_task(addr, Some(OWNER_A), TASKS_TOOL_NAME, 1).await);
let read_by_other = tasks_get(addr, Some(OWNER_B), &created, 2).await;
let read_by_nobody = tasks_get(addr, None, &created, 3).await;
teardown(handle, ()).await;
assert_eq!(
result_of(&read_by_other)["taskId"],
json!(created),
"ACCEPTED: with no auth provider there is one shared bucket, so a different \
bearer reads the same task. See this test's rustdoc before changing it: {}",
read_by_other.raw
);
assert_eq!(
result_of(&read_by_nobody)["taskId"],
json!(created),
"and a caller presenting no credential at all reads it too — the bearer was \
never interpreted, which is precisely why the bucket is shared: {}",
read_by_nobody.raw
);
}