AID-H-034.009: MCP Server Authoritative Disable-State & Commit-Fence Integration
AID-I-005 authority online before dispatch and immediately before the protected effect. Bind each signed current-state response to a fresh server nonce, exact target, enforcement phase, policy generation, monotonic head generation, and ENABLE or DISABLE state so a cached signed ENABLE cannot masquerade as current. For effects exposed to a post-check race, require an authority-issued commit fence that the backend atomically redeems and rejects after a newer DISABLE.Pillars: app, infra
Lifecycle: operation, response
Scope boundary
Owns complete MCP operation classification, nonce-bound current-head verification, fail-closed pre-dispatch and pre-effect checks, and delivery of authority-issued commit fences into covered backend effects.
AID-I-005: AID-H-034.009 enforces the current signed HALT or RUN state and commit fence at MCP handlers; AID-I-005 owns the incident decision, authorization, authoritative state, propagation, and fence invalidation. The MCP server consumes rather than creates emergency authority.
AID-E-001: AID-H-034.009 blocks covered MCP operations under authoritative disable state; AID-E-001 revokes compromised credentials, sessions, principals, and grants. An operation halt does not invalidate the credential objects that enabled prior access.
AID-E-002: AID-H-034.009 prevents new protected effects at the MCP server; AID-E-002 terminates compromised runtime processes, leases, and pods. Commit fencing and workload eviction address different residual execution paths.
Implementation guidance
- AID-H-034.009-G001: Verify a nonce-bound authoritative current checkpoint before MCP dispatch and effect, reject head rollback, and carry revocable authority fences into each covered backend commit.
AID-H-034.009-G001
Applicability and ownership
Use this integration only for an MCP operation that the complete, locally signature-verified operation registry classifies as COVERED. The MCP server owns policy classification, current-state verification, pre-dispatch and pre-effect enforcement, and delivery of commit fences. AID-I-005 owns the decision and authoritative state. A registry entry may be NOT_APPLICABLE only with a technical architecture reason; an unlisted operation is an error and never inherits an allow.
Verify the policy and online authority response locally
The authority signs each response over a fresh server-generated query nonce, exact target, enforcement phase, policy generation, and current head generation. This prevents a cached signed ENABLE from being replayed as the latest state. Numeric freshness bounds and operation coverage come from the signed policy.
// File: src/disable/mcpDisableEnforcement.js
import crypto from "node:crypto";
import fs from "node:fs";
import https from "node:https";
import path from "node:path";
const TARGET_FIELD = Object.freeze({
server_release: "serverRelease",
tenant: "tenantId",
client: "clientId",
tool: "toolName",
resource: "resourceId",
task: "taskId",
subscription: "subscriptionId",
upstream: "upstreamId",
object: "objectId"
});
const COVERAGE = new Set(["COVERED", "NOT_APPLICABLE"]);
const PHASES = new Set(["pre_dispatch", "pre_side_effect"]);
const exactKeys = (value, expected) =>
value && Object.getPrototypeOf(value) === Object.prototype
&& Object.keys(value).sort().join(",")
=== [...expected].sort().join(",");
const canonical = value => {
if (
value === null || typeof value === "string"
|| typeof value === "boolean"
) return JSON.stringify(value);
if (Number.isSafeInteger(value)) return String(value);
if (Array.isArray(value)) {
return "[" + value.map(canonical).join(",") + "]";
}
if (value && Object.getPrototypeOf(value) === Object.prototype) {
return "{" + Object.keys(value).sort().map(
key => JSON.stringify(key) + ":" + canonical(value[key])
).join(",") + "}";
}
throw new Error("non_canonical_disable_value");
};
const decodeBase64 = value => {
if (typeof value !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) {
throw new Error("invalid_disable_base64");
}
const decoded = Buffer.from(value, "base64");
if (!decoded.length || decoded.toString("base64") !== value) {
throw new Error("non_canonical_disable_base64");
}
return decoded;
};
function verifyEnvelope(rawEnvelope, publicKey) {
if (!Buffer.isBuffer(rawEnvelope) || !rawEnvelope.length) {
throw new Error("signed_disable_envelope_unavailable");
}
const envelope = JSON.parse(rawEnvelope.toString("utf8"));
if (!exactKeys(envelope, ["payload_b64", "signature_b64"])) {
throw new Error("disable_envelope_schema_differs");
}
const payloadBytes = decodeBase64(envelope.payload_b64);
const signature = decodeBase64(envelope.signature_b64);
if (!crypto.verify(null, payloadBytes, publicKey, signature)) {
throw new Error("disable_signature_invalid");
}
return {
payload: JSON.parse(payloadBytes.toString("utf8")),
payloadDigest: crypto.createHash("sha256")
.update(payloadBytes).digest("hex")
};
}
function positiveInteger(value, field) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error("invalid_" + field);
}
return value;
}
function postHttps(url, options, body) {
return new Promise((resolve, reject) => {
const request = https.request(url, {
method: "POST",
agent: options.agent,
headers: options.headers,
timeout: options.timeoutMs
}, response => {
const chunks = [];
let received = 0;
response.on("data", chunk => {
received += chunk.length;
if (received > options.maxResponseBytes) {
request.destroy(new Error("disable_authority_response_too_large"));
return;
}
chunks.push(chunk);
});
response.on("end", () => resolve({
statusCode: response.statusCode || 0,
body: Buffer.concat(chunks)
}));
});
request.on("timeout", () =>
request.destroy(new Error("disable_authority_timeout"))
);
request.on("error", reject);
request.end(body);
});
}
function loadHexKey(name) {
const encoded = process.env[name] || "";
if (!/^[a-f0-9]+$/.test(encoded) || encoded.length % 2 !== 0) {
throw new Error("invalid_hex_key:" + name);
}
const key = Buffer.from(encoded, "hex");
if (key.length < 32) throw new Error("short_key:" + name);
return key;
}
export function loadVerifiedDisablePolicy() {
const rootPublicKey = crypto.createPublicKey(
fs.readFileSync(process.env.MCP_DISABLE_POLICY_VERIFY_KEY)
);
const rawEnvelope = fs.readFileSync(
process.env.MCP_DISABLE_POLICY_ENVELOPE
);
const verified = verifyEnvelope(rawEnvelope, rootPublicKey);
const policy = verified.payload;
const fields = [
"authority", "authority_key_path", "authority_key_sha256",
"max_authority_response_age_ms", "not_after", "operations",
"policy_generation", "policy_version", "schema_version"
];
if (
!exactKeys(policy, fields)
|| policy.schema_version !== "aidefend.mcp-disable-policy.v2"
|| typeof policy.policy_version !== "string"
|| !policy.policy_version
|| !Number.isSafeInteger(policy.policy_generation)
|| policy.policy_generation <= 0
|| typeof policy.authority !== "string"
|| !/^[a-zA-Z0-9._:-]+$/.test(policy.authority)
|| !/^[a-f0-9]{64}$/.test(policy.authority_key_sha256)
|| !Number.isFinite(Date.parse(policy.not_after))
|| Date.parse(policy.not_after) <= Date.now()
|| !policy.operations
|| Object.getPrototypeOf(policy.operations) !== Object.prototype
|| Object.keys(policy.operations).length === 0
) {
throw new Error("disable_policy_schema_or_freshness_invalid");
}
positiveInteger(
policy.max_authority_response_age_ms,
"max_authority_response_age_ms"
);
for (const [operation, rule] of Object.entries(policy.operations)) {
if (
!operation
|| !exactKeys(rule, [
"coverage", "require_commit_fence",
"required_target_kinds", "technical_reason"
])
|| !COVERAGE.has(rule.coverage)
|| typeof rule.require_commit_fence !== "boolean"
|| !Array.isArray(rule.required_target_kinds)
|| rule.required_target_kinds.length
!== new Set(rule.required_target_kinds).size
|| rule.required_target_kinds.some(
kind => !Object.hasOwn(TARGET_FIELD, kind)
)
) {
throw new Error("invalid_disable_operation_rule:" + operation);
}
if (
rule.coverage === "COVERED"
&& (
rule.required_target_kinds.length === 0
|| rule.technical_reason !== null
)
) {
throw new Error("covered_disable_rule_is_incomplete:" + operation);
}
if (
rule.coverage === "NOT_APPLICABLE"
&& (
rule.required_target_kinds.length !== 0
|| rule.require_commit_fence
|| typeof rule.technical_reason !== "string"
|| !rule.technical_reason.trim()
)
) {
throw new Error("invalid_disable_not_applicable_rule:" + operation);
}
}
const keyRoot = path.resolve(process.env.MCP_DISABLE_AUTHORITY_KEY_ROOT);
const keyPath = path.resolve(keyRoot, policy.authority_key_path);
if (!keyPath.startsWith(keyRoot + path.sep)) {
throw new Error("disable_authority_key_escapes_trust_root");
}
const keyBytes = fs.readFileSync(keyPath);
const keyDigest = crypto.createHash("sha256")
.update(keyBytes).digest("hex");
if (keyDigest !== policy.authority_key_sha256) {
throw new Error("disable_authority_key_digest_differs");
}
return Object.freeze({
...policy,
policyDigest: verified.payloadDigest,
authorityPublicKey: crypto.createPublicKey(keyBytes)
});
}
const ACCEPT_MONOTONIC = [
"local current=tonumber(redis.call('HGET',KEYS[1],'generation') or '0')",
"local incoming=tonumber(ARGV[1])",
"if not incoming or incoming<1 then return redis.error_reply('generation') end",
"if incoming<current then return {'STALE'} end",
"if incoming==current then",
" if redis.call('HGET',KEYS[1],'digest')~=ARGV[2] then return {'CONFLICT'} end",
" return {'REPLAY'}",
"end",
"redis.call('HSET',KEYS[1],'generation',incoming,'digest',ARGV[2],'state',ARGV[3])",
"return {'ACCEPTED'}"
].join("\n");
async function acceptMonotonic(store, key, generation, digest, state) {
const result = await store.eval(ACCEPT_MONOTONIC, {
keys: [key],
arguments: [String(generation), digest, state]
});
const status = String(result[0]);
if (status === "STALE" || status === "CONFLICT") {
throw new Error("disable_monotonic_" + status.toLowerCase());
}
if (status !== "ACCEPTED" && status !== "REPLAY") {
throw new Error("disable_monotonic_store_error");
}
}
export class DisableAuthorityClient {
constructor() {
this.baseUrl = new URL(process.env.MCP_DISABLE_AUTHORITY_URL);
if (this.baseUrl.protocol !== "https:") {
throw new Error("disable authority must use HTTPS");
}
this.timeoutMs = positiveInteger(
Number(process.env.MCP_DISABLE_AUTHORITY_TIMEOUT_MS),
"disable_authority_timeout_ms"
);
this.maxResponseBytes = positiveInteger(
Number(process.env.MCP_DISABLE_AUTHORITY_MAX_RESPONSE_BYTES),
"disable_authority_max_response_bytes"
);
this.agent = new https.Agent({
ca: fs.readFileSync(process.env.MCP_DISABLE_AUTHORITY_CA_FILE),
cert: fs.readFileSync(process.env.MCP_DISABLE_CLIENT_CERT),
key: fs.readFileSync(process.env.MCP_DISABLE_CLIENT_KEY),
minVersion: "TLSv1.2"
});
}
async readCurrentHead(policy, target, phase) {
const queryNonce = crypto.randomUUID();
const url = new URL("/v1/disable/current-head", this.baseUrl);
if (url.origin !== this.baseUrl.origin) {
throw new Error("disable_authority_origin_changed");
}
const response = await postHttps(url, {
agent: this.agent,
headers: {
"cache-control": "no-store",
"content-type": "application/json"
},
timeoutMs: this.timeoutMs,
maxResponseBytes: this.maxResponseBytes
}, canonical({
schema_version: "aidefend.mcp-disable-query.v1",
policy_digest: policy.policyDigest,
policy_generation: policy.policy_generation,
query_nonce: queryNonce,
phase,
target
}));
const rawEnvelope = response.body;
if (response.statusCode !== 200) {
throw new Error("disable_authority_http_" + response.statusCode);
}
const verified = verifyEnvelope(
rawEnvelope, policy.authorityPublicKey
);
const head = verified.payload;
const fields = [
"authority", "commit_token", "expires_at", "generation",
"issued_at", "phase", "policy_digest", "policy_generation",
"query_nonce", "reason_code", "schema_version", "state", "target"
];
const now = Date.now();
const issuedAt = Date.parse(head.issued_at);
const expiresAt = Date.parse(head.expires_at);
if (
!exactKeys(head, fields)
|| !exactKeys(head.target, ["digest", "kind"])
|| head.schema_version !== "aidefend.mcp-disable-head.v2"
|| head.authority !== policy.authority
|| head.policy_digest !== policy.policyDigest
|| head.policy_generation !== policy.policy_generation
|| head.query_nonce !== queryNonce
|| head.phase !== phase
|| head.target.kind !== target.kind
|| head.target.digest !== target.digest
|| !Number.isSafeInteger(head.generation)
|| head.generation <= 0
|| !["ENABLE", "DISABLE"].includes(head.state)
|| typeof head.reason_code !== "string"
|| !head.reason_code
|| !Number.isFinite(issuedAt)
|| !Number.isFinite(expiresAt)
|| issuedAt > now
|| now - issuedAt > policy.max_authority_response_age_ms
|| expiresAt <= now
) {
throw new Error("disable_authority_head_invalid");
}
if (
head.commit_token !== null
&& (typeof head.commit_token !== "string" || !head.commit_token)
) {
throw new Error("disable_commit_token_invalid");
}
// Only authority state belongs to the monotonic identity. Nonce, phase,
// response times and one-use commit tokens remain verified envelope data.
const stateDigest = crypto.createHash("sha256").update(canonical({
authority: head.authority,
target: head.target,
generation: head.generation,
state: head.state,
reason_code: head.reason_code
})).digest("hex");
return {...head, payloadDigest: verified.payloadDigest, stateDigest};
}
}
function operationRule(policy, operationKey) {
if (typeof operationKey !== "string" || !operationKey) {
throw new Error("server_derived_operation_key_missing");
}
const rule = policy.operations[operationKey];
if (!rule) {
throw new Error("operation_absent_from_complete_disable_registry");
}
return rule;
}
function requiredTargets(context, rule, digestKey) {
return rule.required_target_kinds.map(kind => {
const value = context[TARGET_FIELD[kind]];
if (typeof value !== "string" || !value) {
throw new Error("required_disable_target_missing:" + kind);
}
const digest = crypto.createHmac("sha256", digestKey)
.update(kind).update(Buffer.from([0]))
.update(value).digest("hex");
return {kind, digest};
});
}
export async function createDisableEnforcer(input) {
const policy = loadVerifiedDisablePolicy();
await acceptMonotonic(
input.generationStore,
"mcp-disable-policy:{" + policy.authority + "}",
policy.policy_generation,
policy.policyDigest,
"POLICY"
);
const digestKey = loadHexKey("MCP_DISABLE_TARGET_HMAC_KEY_HEX");
const client = new DisableAuthorityClient();
return async function enforce(operationKey, context, phase) {
if (!PHASES.has(phase)) throw new Error("invalid_disable_phase");
if (Date.parse(policy.not_after) <= Date.now()) {
throw new Error("disable_policy_expired");
}
const rule = operationRule(policy, operationKey);
if (rule.coverage === "NOT_APPLICABLE") {
return {
applicable: false,
phase,
technicalReason: rule.technical_reason
};
}
const targets = requiredTargets(context, rule, digestKey);
const heads = [];
for (const target of targets) {
const head = await client.readCurrentHead(policy, target, phase);
await acceptMonotonic(
input.generationStore,
"mcp-disable:" + policy.authority + ":" + target.kind + ":{" + target.digest + "}:head",
head.generation,
head.stateDigest,
head.state
);
if (head.state === "DISABLE") {
throw new Error("mcp_operation_disabled:" + target.kind);
}
if (
phase === "pre_side_effect"
&& rule.require_commit_fence
&& !head.commit_token
) {
throw new Error("required_disable_commit_fence_missing");
}
heads.push(head);
}
return {
applicable: true,
phase,
requireCommitFence: rule.require_commit_fence,
heads
};
};
}
export async function runWithDisableEnforcement(input) {
const enforce = await createDisableEnforcer(input);
await enforce(input.operationKey, input.context, "pre_dispatch");
const prepared = await input.prepareSideEffectFree();
const current = await enforce(
input.operationKey, input.context, "pre_side_effect"
);
if (!current.applicable) {
// The signed complete registry explicitly excludes this operation.
// Keep the same commit adapter; its empty fence set is deliberate here.
return input.commitWithDisableFences(prepared, []);
}
const commitTokens = current.heads.map(head => ({
target: head.target,
generation: head.generation,
token: head.commit_token
}));
if (
current.requireCommitFence
&& commitTokens.some(item => !item.token)
) {
throw new Error("incomplete_disable_commit_fence_set");
}
return input.commitWithDisableFences(prepared, commitTokens);
}Commit-fence contract: For a policy entry with require_commit_fence, the backend must atomically redeem every authority-issued token at the protected commit point and reject a token invalidated by a newer DISABLE generation. Merely checking in the MCP process cannot close the race after the second hook. prepareSideEffectFree must not perform network writes, queue publication, durable file changes, or other protected effects.
Verify safely
Enumerate every exposed MCP method and tool against the signed operation registry. Test an omitted operation, justified NOT_APPLICABLE entry, signed ENABLE and DISABLE, wrong policy or target, replayed nonce, stale-but-signed ENABLE, lower and conflicting generations, authority timeout, cache partition, process restart, newer re-enable, and DISABLE arriving between the second hook and backend commit. Confirm all covered failures block and the backend rejects a fence superseded before commit.
Open-source tools
- Valkey
- Redis 8+ (AGPL-3.0)
- Open Policy Agent (OPA)
- Model Context Protocol TypeScript SDK v2 (@modelcontextprotocol/server and @modelcontextprotocol/client)
Threat mappings
MITRE ATLAS
- AML.T0053 AI Agent Tool Invocation
- AML.T0086 Exfiltration via AI Agent Tool Invocation
- AML.T0101 Data Destruction via AI Agent Tool Invocation
MAESTRO
- Agent Tool Misuse (L7)
OWASP LLM Top 10 2026
- LLM03:2026 Excessive Agency
OWASP ML Top 10 2023
- N/A
OWASP Top 10 for Agentic Applications 2026
- ASI02:2026 Tool Misuse and Exploitation
- ASI08:2026 Cascading Failures
- ASI10:2026 Rogue Agents
NIST Adversarial Machine Learning 2025
- NISTAML.039 Compromising connected resources
Cisco Integrated AI Security and Safety Framework
- AISubtech-14.2.1 Permission Escalation via Delegation
- AISubtech-2.1.2 Capability and Permission Overreach
- AISubtech-2.2.3 Authorization and Constraint Erosion (blocks agent continuation after authority is withdrawn, without proving semantic goal fidelity)
- AISubtech-4.3.4 Replay Exploitation
- AITech-12.1 Tool Exploitation
- AITech-14.2 Abuse of Delegated Authority
- AITech-2.1 Excessive Agency
- AITech-2.2 Goal Drift (blocks agent continuation after authority is withdrawn, without proving semantic goal fidelity)
- AITech-4.3 Protocol Manipulation
- AISubtech-14.1.2 Insufficient Access Controls (current disable-state admission and commit fences revoke operation access)
Google Secure AI Framework 2.0 - Risks
- RA: Rogue Actions
Databricks AI Security Framework 3.0
- Agents - Tools MCP Server 13.23: Data Exfiltration