AID-H-034.007: MCP Task & Subscription Lifecycle Isolation

Version 1.20261015 · Actionable sub-technique

For negotiated MCP Tasks and subscriptions/listen, authorize every create, get, result, cancel, and listen operation independently; partition durable state and streams by principal, client, tenant, operation, object permission, and originating request; enforce terminal immutability, bounded retention, cancellation, and filtered delivery.

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.
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

Authorize each subscriptions/listen stream and filter by principal, client, tenant, resource, task, and the originating JSON-RPC request ID.
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

Commercial / hosted tools

Threat mappings

MITRE ATLAS

MAESTRO

OWASP LLM Top 10 2026

OWASP ML Top 10 2023

OWASP Top 10 for Agentic Applications 2026

NIST Adversarial Machine Learning 2025

Cisco Integrated AI Security and Safety Framework

Google Secure AI Framework 2.0 - Risks

Databricks AI Security Framework 3.0

AID-H-034.007: MCP Task & Subscription Lifecycle Isolation | AIDEFEND

Loading interactive view…

A Practical Knowledge Base for AI Security Defenses

Version