AID-H-034.006: MCP Server Explicit Application-Handle Isolation

Version 1.20261015 · Actionable sub-technique

Represent cross-call state with opaque expiring revocable handles authorized as ordinary arguments. Bind each handle to principal, client, tenant, object, scope, version, and policy; never recreate a session, Mcp-Session-Id, sticky routing, or hidden global state.

Pillars: app, infra

Lifecycle: building, operation, response

Scope boundary

Owns server-side authorization, expiry, revocation, and partitioning of explicit opaque application handles passed as ordinary arguments. It does not create sessions or own task, subscription, budget, telemetry, or disable-state lifecycles.

AID-H-034.007: AID-H-034.006 authorizes references to application objects across calls; AID-H-034.007 owns durable task and subscription creation, lifecycle, terminal state, cancellation, and filtered delivery. A task may be referenced by a handle, but its lifecycle is not generic handle state.

AID-H-028.005: AID-H-034.006 authorizes the object represented by a handle on the server; AID-H-028.005 isolates and expires client-side handle references and request state. Client storage discipline cannot grant server object access.

AID-H-018.002: AID-H-034.006 owns MCP-specific cross-call handle issuance, binding, expiry, revocation, and per-use authorization; AID-H-018.002 authorizes opaque application or service objects at non-MCP model- or agent-triggered boundaries. If an MCP handler calls an external service, preserve both enforcement boundaries but award no duplicate PASS for that service's object decision.

Implementation guidance

AID-H-034.006-G001

Represent cross-call application state with opaque, expiring, independently revocable handles and authorize the referenced object on every use.
Concept

A basket_id or browser_id is an untrusted tool argument, not authentication and not an MCP session. Store only a high-entropy handle at the client; retain its digest and owner binding server-side. Revalidate authenticated identity and the referenced object's authorization on every call.

-- File: migrations/20260728_mcp_state_handles.sql
CREATE TABLE mcp_state_handles (
  handle_sha256 bytea PRIMARY KEY,
  subject_key bytea NOT NULL,
  tenant_id text NOT NULL,
  client_id text NOT NULL,
  object_type text NOT NULL,
  object_ref text NOT NULL,
  object_version text NOT NULL,
  scopes text[] NOT NULL CHECK (cardinality(scopes) > 0),
  policy_version text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  expires_at timestamptz NOT NULL,
  revoked_at timestamptz,
  revocation_reason text
);
CREATE INDEX mcp_state_handles_owner_idx
  ON mcp_state_handles (subject_key, tenant_id, client_id, expires_at);
// File: src/state/applicationHandleStore.js
import crypto from "node:crypto";

const SUBJECT_KEY = Buffer.from(process.env.MCP_SUBJECT_HMAC_KEY_B64, "base64");
if (SUBJECT_KEY.length !== 32) throw new Error("MCP_SUBJECT_HMAC_KEY_B64 must decode to 32 bytes");
const handleDigest = value => crypto.createHash("sha256").update(value).digest();
const subjectKey = value => crypto.createHmac("sha256", SUBJECT_KEY).update(value).digest();

function validateContext(input) {
  if (!input.auth?.subject || !input.auth.activeTenantId || !input.clientId) {
    throw new Error("missing_handle_identity");
  }
  if (!input.policy?.version || !Number.isSafeInteger(input.policy.handleTtlMs) ||
      input.policy.handleTtlMs <= 0 || !Number.isSafeInteger(input.policy.maxScopes) ||
      input.policy.maxScopes <= 0 || !Array.isArray(input.policy.allowedScopes) ||
      input.policy.allowedScopes.length !== new Set(input.policy.allowedScopes).size ||
      !Array.isArray(input.policy.allowedObjectTypes)) {
    throw new Error("invalid_signed_handle_policy");
  }
}

export async function mintHandle(db, input) {
  validateContext(input);
  const allowedScopes = new Set(input.policy.allowedScopes || []);
  const scopes = [...new Set(input.scopes)].sort();
  if (!input.policy.allowedObjectTypes?.includes(input.objectType) || !input.objectRef ||
      !input.objectVersion || !scopes.length || scopes.length > input.policy.maxScopes ||
      scopes.some(scope => !allowedScopes.has(scope))) throw new Error("handle_grant_outside_policy");
  await input.authorizeObject({ ...input, scopes, phase: "mint" });
  const handle = crypto.randomBytes(32).toString("base64url");
  await db.query(
    "INSERT INTO mcp_state_handles " +
    "(handle_sha256,subject_key,tenant_id,client_id,object_type,object_ref,object_version," +
    "scopes,policy_version,expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9," +
    "now()+($10::bigint*interval '1 millisecond'))",
    [handleDigest(handle), subjectKey(input.auth.subject), input.auth.activeTenantId,
      input.clientId, input.objectType, input.objectRef, input.objectVersion, scopes,
      input.policy.version, input.policy.handleTtlMs]
  );
  return handle;
}

export async function resolveHandle(db, input) {
  validateContext(input);
  if (typeof input.requiredScope !== "string" || !input.requiredScope ||
      !input.policy.allowedScopes.includes(input.requiredScope)) {
    throw new Error("invalid_required_handle_scope");
  }
  const result = await db.query(
    "SELECT object_type,object_ref,object_version,scopes,policy_version FROM mcp_state_handles " +
    "WHERE handle_sha256=$1 AND subject_key=$2 AND tenant_id=$3 AND client_id=$4 " +
    "AND revoked_at IS NULL AND expires_at>now()",
    [handleDigest(input.handle), subjectKey(input.auth.subject),
      input.auth.activeTenantId, input.clientId]
  );
  const record = result.rows[0];
  if (!record || record.policy_version !== input.policy.version ||
      !record.scopes.includes(input.requiredScope)) {
    throw new Error("handle_not_found_or_policy_changed");
  }
  const currentObject = await input.loadObject({
    tenantId: input.auth.activeTenantId,
    objectType: record.object_type,
    objectRef: record.object_ref
  });
  if (!currentObject || String(currentObject.version) !== record.object_version) {
    throw new Error("handle_object_version_changed");
  }
  await input.authorizeObject({
    auth: input.auth, clientId: input.clientId, object: currentObject,
    requiredScope: input.requiredScope, policyVersion: record.policy_version, phase: "resolve"
  });
  return Object.freeze({ ...record, currentObject });
}

export async function revokeHandle(db, input) {
  validateContext(input);
  if (!input.reason || !input.operatorReceiptId) throw new Error("handle_revocation_evidence_required");
  const result = await db.query(
    "UPDATE mcp_state_handles SET revoked_at=COALESCE(revoked_at,now()),revocation_reason=$5 " +
    "WHERE handle_sha256=$1 AND subject_key=$2 AND tenant_id=$3 AND client_id=$4 " +
    "RETURNING revoked_at",
    [handleDigest(input.handle), subjectKey(input.auth.subject),
      input.auth.activeTenantId, input.clientId, input.reason]
  );
  if (result.rowCount !== 1) throw new Error("handle_not_found");
  return { revokedAt: result.rows[0].revoked_at, operatorReceiptId: input.operatorReceiptId };
}

Action and evidence: revoke on logout, token/role change, tenant switch, object deletion, suspicious use, or policy expiry. Record only handle/object digests, authenticated partition, required scope, decision, policy version, and trace ID. Cross-owner, expired, and revoked-handle tests must fail before backend work.

Verify safely

A separately credentialed verifier tests guessing, substitution across subject/client/tenant, expiry boundaries, scope escalation, object/version change, revocation race, concurrent use, store outage and database uniqueness while reconciling all lifecycle events.

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.006: MCP Server Explicit Application-Handle Isolation | AIDEFEND

Loading interactive view…

A Practical Knowledge Base for AI Security Defenses

Version