AID-H-034.007: MCP Task & Subscription Lifecycle Isolation
Pillars: app, infra
Lifecycle: building, operation, response
Scope boundary
Owns independent authorization and tenant partitioning for MCP task and subscription create, get, result, cancel, and listen operations, including origin binding, terminal immutability, bounded retention, cancellation, and filtered delivery. It does not recreate protocol sessions.
AID-H-034.006: AID-H-034.007 governs task and subscription lifecycle state; AID-H-034.006 governs generic explicit application handles used as authorized arguments. Durable asynchronous lifecycle semantics require more than object-handle validation.
AID-H-034.008: AID-H-034.007 authorizes task and stream state transitions; AID-H-034.008 reserves, meters, renews, and settles the resource or spend budget attached to those operations. Lifecycle authorization and resource accounting must agree but have different authorities.
Implementation guidance
- AID-H-034.007-G001: Negotiate io.modelcontextprotocol/tasks and partition durable Tasks by principal, client, tenant, operation, object permission, and terminally immutable lifecycle state.
- AID-H-034.007-G002: Authorize each subscriptions/listen stream and filter by principal, client, tenant, resource, task, and the originating JSON-RPC request ID.
AID-H-034.007-G001
Tasks extension lifecycle
Tasks are server-directed and currently support only tools/call. The client opts in per request through io.modelcontextprotocol/tasks; there is no per-call task flag. Persist before returning the flat CreateTaskResult, use high-entropy IDs, and authorize each Task request independently.
// File: src/tasks/taskBoundary.js
import crypto from "node:crypto";
const EXT = "io.modelcontextprotocol/tasks";
const TERMINAL = new Set(["completed", "failed", "cancelled"]);
const error = (code, message, data) =>
Object.assign(new Error(message), { code, data });
function requireExtension(capabilities) {
if (!capabilities?.extensions?.[EXT]) {
throw error(-32003, "Missing required client capability", {
requiredCapabilities: { extensions: { [EXT]: {} } }
});
}
}
function wire(task) {
const result = {
taskId: task.taskId, status: task.status, createdAt: task.createdAt,
lastUpdatedAt: task.lastUpdatedAt, ttlMs: task.ttlMs
};
for (const key of ["pollIntervalMs", "statusMessage", "inputRequests", "result", "error"]) {
if (task[key] !== undefined) result[key] = task[key];
}
return result;
}
async function owned(repo, input) {
requireExtension(input.clientCapabilities);
const task = await repo.get(input.taskId);
if (!task || task.subject !== input.auth.subject ||
task.tenantId !== input.auth.activeTenantId ||
task.clientId !== input.clientId) {
throw error(-32602, "Invalid taskId");
}
await input.authorize(task);
return task;
}
export async function createToolTask(repo, input) {
requireExtension(input.clientCapabilities);
if (input.method !== "tools/call") {
throw error(-32602, "Only tools/call supports Tasks");
}
if (!(input.ttlMs === null ||
(Number.isSafeInteger(input.ttlMs) && input.ttlMs >= 0)) ||
(input.pollIntervalMs !== undefined &&
(!Number.isSafeInteger(input.pollIntervalMs) || input.pollIntervalMs <= 0))) {
throw error(-32603, "Invalid signed Task lifetime policy");
}
const now = new Date().toISOString();
const task = {
taskId: crypto.randomUUID(), status: "working",
createdAt: now, lastUpdatedAt: now,
ttlMs: input.ttlMs, pollIntervalMs: input.pollIntervalMs,
subject: input.auth.subject, tenantId: input.auth.activeTenantId,
clientId: input.clientId, sourceMethod: input.method,
sourceName: input.params.name, objectKey: input.objectKey, version: 1
};
await input.authorize(task);
const stored = await repo.createIfAbsent(task);
if (!stored || stored.taskId !== task.taskId) {
throw error(-32603, "Task durability failure");
}
return { resultType: "task", ...wire(stored) };
}
export async function getTask(repo, input) {
return wire(await owned(repo, input));
}
export async function updateTask(repo, input) {
const task = await owned(repo, input);
if (TERMINAL.has(task.status)) return {};
const accepted = {};
for (const [key, response] of Object.entries(input.inputResponses || {})) {
if (Object.hasOwn(task.inputRequests || {}, key)) accepted[key] = response;
}
if (Object.keys(accepted).length) {
await repo.acceptInputResponses(task, accepted);
}
return {};
}
export async function cancelTask(repo, input) {
const task = await owned(repo, input);
if (!TERMINAL.has(task.status)) await repo.recordCancellationIntent(task);
return {};
}Action: require Mcp-Name equal to taskId for get/update/cancel. Keep terminal states immutable and include complete state in tasks/get and notifications/tasks. Reject removed tasks/list and tasks/result with -32601. Treat an old unknown task argument as unknown data, never as opt-in. Update may accept a subset and ignores unknown or already-satisfied response keys; cancel acknowledgement is cooperative, not proof work stopped.
Verify safely
Test missing extension -32003, invalid ID -32602, durable-before-response ordering, flat fields, partial and duplicate updates, terminal immutability, cancel races, restart recovery, removed methods, routing names, and cross-user/client/tenant authorization.
AID-H-034.007-G002
Bind each listen stream to its request and filters
MCP 2026-07-28 replaces the standalone GET stream and sessions with subscriptions/listen. The subscription ID is the JSON-RPC ID of that request. The acknowledgement must be the first message carrying the ID. There is no reconnect cursor, Last-Event-ID, or server-generated identifier.
// File: src/subscriptions/listenBoundary.js
const SUB_ID = "io.modelcontextprotocol/subscriptionId";
const TASKS = "io.modelcontextprotocol/tasks";
const CORE = new Set([
"toolsListChanged", "promptsListChanged",
"resourcesListChanged", "resourceSubscriptions"
]);
const protocolError = (code, message, data) =>
Object.assign(new Error(message), { code, data });
export async function openListen(input) {
const requested = input.params?.notifications;
if (!requested || Object.getPrototypeOf(requested) !== Object.prototype ||
input.requestId === null || input.requestId === undefined) {
throw protocolError(-32602, "Invalid subscription filter");
}
const unknown = Object.keys(requested)
.filter(key => !CORE.has(key) && key !== "taskIds");
if (unknown.length) throw protocolError(-32602, "Unknown subscription filter");
const coreRequested = Object.fromEntries(
Object.entries(requested).filter(([key]) => CORE.has(key))
);
const accepted = await input.authorizeCoreFilters(coreRequested);
if (requested.taskIds !== undefined) {
if (!Array.isArray(requested.taskIds) ||
requested.taskIds.some(id => typeof id !== "string" || !id)) {
throw protocolError(-32602, "Invalid taskIds filter");
}
if (!input.clientCapabilities?.extensions?.[TASKS]) {
throw protocolError(-32003, "Missing required client capability", {
requiredCapabilities: { extensions: { [TASKS]: {} } }
});
}
accepted.taskIds = [];
for (const taskId of [...new Set(requested.taskIds)]) {
if (await input.authorizeOwnedTask(taskId)) accepted.taskIds.push(taskId);
}
}
const record = Object.freeze({
subscriptionId: input.requestId,
subject: input.auth.subject,
tenantId: input.auth.activeTenantId,
clientId: input.clientId,
notifications: Object.freeze(accepted),
expiresAt: input.expiresAt
});
await input.send({
jsonrpc: "2.0",
method: "notifications/subscriptions/acknowledged",
params: {
notifications: accepted,
_meta: { [SUB_ID]: input.requestId }
}
});
return record;
}
export async function sendAuthorizedEvent(record, notification, input) {
if (Date.now() >= record.expiresAt ||
input.auth.subject !== record.subject ||
input.auth.activeTenantId !== record.tenantId ||
input.clientId !== record.clientId) {
throw new Error("subscription_identity_or_expiry_mismatch");
}
await input.authorizeEvent(record.notifications, notification);
await input.send({
...notification,
params: {
...(notification.params || {}),
_meta: {
...(notification.params?._meta || {}),
[SUB_ID]: record.subscriptionId
}
}
});
}
export function gracefulClose(record) {
return { resultType: "complete", _meta: { [SUB_ID]: record.subscriptionId } };
}Action: authorize every core filter and resource URI. For Tasks, accept only owned task IDs and send full notifications/tasks state; do not send progress or logging notifications as Task status. Re-check authorization before every event and close on token expiry, revocation, policy change, backpressure failure, or shutdown.
Verify safely
Test acknowledgement-first ordering, exact request-ID metadata, forged filters, unowned task IDs, missing Tasks capability -32003, cross-identity delivery, expiry/revocation, abrupt and graceful close, and attempts to resume with a session ID or Last-Event-ID.
Open-source tools
- Valkey
- Redis 8+ (AGPL-3.0)
- Open Policy Agent (OPA)
Commercial / hosted tools
- Redis Cloud
- Amazon ElastiCache for Valkey
- Google Cloud Memorystore for Valkey
- Azure Managed Redis
Threat mappings
MITRE ATLAS
- AML.T0086 Exfiltration via AI Agent Tool Invocation
- AML.T0012 Valid Accounts
MAESTRO
- Agent Identity Attack (L7)
- Integration Risks (L7)
- Data Exfiltration (L2)
OWASP LLM Top 10 2026
- LLM02:2026 Sensitive Information Disclosure
- LLM03:2026 Excessive Agency
OWASP ML Top 10 2023
- N/A
OWASP Top 10 for Agentic Applications 2026
- ASI03:2026 Identity and Privilege Abuse
- ASI08:2026 Cascading Failures
NIST Adversarial Machine Learning 2025
- NISTAML.036 Leaking information from user interactions
- NISTAML.039 Compromising connected resources
Cisco Integrated AI Security and Safety Framework
- AISubtech-14.1.2 Insufficient Access Controls
- AISubtech-2.1.2 Capability and Permission Overreach
- AISubtech-4.2.2 Session Boundary Violation
- AISubtech-4.3.4 Replay Exploitation (bounds task and stream lifecycle replay; backend exactly-once effects remain separate)
- AITech-12.1 Tool Exploitation
- AITech-2.1 Excessive Agency
- AITech-4.2 Context Boundary Attacks
- AITech-4.3 Protocol Manipulation (bounds task and stream lifecycle replay; backend exactly-once effects remain separate)
- AITech-8.2 Data Exfiltration / Exposure
- AITech-8.3 Information Disclosure
Google Secure AI Framework 2.0 - Risks
- SDD: Sensitive Data Disclosure
Databricks AI Security Framework 3.0
- Agents - Tools MCP Server 13.20: Insecure Server Configuration
- Agents - Tools MCP Server 13.22: Excessive Permissions and Scope Creep
- Agents - Tools MCP Server 13.23: Data Exfiltration
- Agents - Tools MCP Server 13.24: Context Spoofing and Manipulation