AID-H-034.004: MCP Tool, Resource & Prompt Publication Governance
server/discover capabilities, tool definitions, resource templates, prompt templates, annotations, descriptions, JSON schemas, output schemas, deterministic list order, cache policy, and change notifications. The server must publish only reviewed and signed or review-gated contracts, preserve semantic honesty in model-visible metadata, detect hidden Unicode or schema-level descriptor poisoning, return truthful ttlMs/cacheScope values, and make descriptor changes auditable and reversible.Pillars: app, infra
Lifecycle: building, operation
Scope boundary
Owns the MCP server's signed or review-gated publication of capabilities, tools, resources, prompts, annotations, descriptions, schemas, cache hints, deterministic lists, and change notifications, including semantic honesty and rollback.
AID-H-024: AID-H-034.004 governs what descriptor and contract content the server may publish; AID-H-024 verifies which published identity, version, bytes, and semantics the consumer resolves. Publisher governance and consumer verification are separate controls.
AID-H-023: AID-H-034.004 governs MCP server capability publication and dynamic descriptor changes; AID-H-023 hardens software package publisher identity and trusted CI release workflows. Both concern publishing, but one governs live protocol contracts and the other package releases.
AID-H-018.009: AID-H-034.004 governs MCP tool, resource, prompt, schema, and UI publication; AID-H-018.009 confines a local filesystem object used by an applicable model-callable capability. Publication approval does not prove filesystem-object confinement, and a build-time administrator-selected source path is not automatically model-callable.
Implementation guidance
- AID-H-034.004-G001: Maintain a signed, versioned contract manifest for every server-published tool, resource, and prompt.
- AID-H-034.004-G002: Review model-visible tool, resource, and prompt text for semantic honesty, hidden instructions, and privilege-inflating descriptions before publication.
- AID-H-034.004-G003: Govern dynamic tool, resource, prompt, capability, and cache-policy changes as approved releases; publish deterministic results and auditable change notifications.
- AID-H-034.004-G004: Return deterministic cacheable server/discover, list, and resource results with policy-derived ttlMs and cacheScope values.
- AID-H-034.004-G005: Verify the server's own published identity and registry namespace ownership, and review the server.json manifest before publication.
- AID-H-034.004-G006: If the server publishes MCP App UI (ui://) resources, predeclare them, pin their content, and declare truthful CSP, permissions, and tool visibility.
AID-H-034.004-G001
Concept:
The MCP server's published contract is a security artifact. It tells clients and models what actions exist, which arguments are accepted, which operations are read-only or destructive, and what side effects can occur. Keep this contract in version control and require review for changes.
Source contract manifest
{
"schema_version": "aidefend.mcp-contract-source.v1",
"server_id": "payments-mcp",
"contract_version": "payments-contract-2026.07.28.1",
"protocol_version": "2026-07-28",
"owner": "platform-integrations",
"tools": [
{
"name": "read_customer_account",
"risk_tier": "medium",
"side_effects": "none",
"read_only": true,
"destructive": false,
"requires_human_approval": false,
"inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": { "customer_id": { "type": "string", "maxLength": 64 } },
"required": ["customer_id"],
"additionalProperties": false
},
"outputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": { "customer_id": { "type": "string" }, "status": { "type": "string" } },
"required": ["customer_id", "status"],
"additionalProperties": false
},
"annotations": { "readOnlyHint": true, "destructiveHint": false },
"model_visible_description": "Read the current account status for an authorized customer."
},
{
"name": "refund_payment",
"risk_tier": "high",
"side_effects": "creates payment refund",
"read_only": false,
"destructive": true,
"requires_human_approval": true,
"inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"payment_id": { "type": "string", "maxLength": 64 },
"amount_cents": { "type": "integer", "minimum": 1, "maximum": 500000 }
},
"required": ["payment_id", "amount_cents"],
"additionalProperties": false
},
"outputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": { "refund_id": { "type": "string" }, "status": { "type": "string" } },
"required": ["refund_id", "status"],
"additionalProperties": false
},
"annotations": { "readOnlyHint": false, "destructiveHint": true },
"model_visible_description": "Create a refund after policy and human approval."
}
],
"resources": [],
"prompts": []
}Build a reviewed manifest with derived hashes
// File: scripts/build-mcp-contract.mjs
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
function canonicalize(value) {
if (Array.isArray(value)) return value.map(canonicalize);
if (value && typeof value === "object") {
return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonicalize(value[key])]));
}
return value;
}
const bytes = value => Buffer.from(JSON.stringify(canonicalize(value)) + "\n");
const sha256 = value => "sha256:" + crypto.createHash("sha256").update(value).digest("hex");
function toolContract(tool) {
return {
name: tool.name,
inputSchema: tool.inputSchema,
outputSchema: tool.outputSchema || null,
annotations: tool.annotations || {},
model_visible_description: tool.model_visible_description || "",
risk_tier: tool.risk_tier,
side_effects: tool.side_effects,
read_only: tool.read_only,
destructive: tool.destructive,
requires_human_approval: Boolean(tool.requires_human_approval)
};
}
function validateSource(source) {
if (source.schema_version !== "aidefend.mcp-contract-source.v1" ||
!source.server_id || !source.contract_version ||
source.protocol_version !== "2026-07-28" || !source.owner ||
!Array.isArray(source.tools) || !Array.isArray(source.resources) ||
!Array.isArray(source.prompts)) throw new Error("contract_source_schema_mismatch");
const names = source.tools.map(tool => tool.name);
if (names.some(name => !/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(name)) ||
names.length !== new Set(names).size) throw new Error("invalid_or_duplicate_tool_name");
for (const tool of source.tools) {
if (tool.inputSchema?.type !== "object" || tool.inputSchema.additionalProperties !== false ||
tool.annotations?.readOnlyHint !== tool.read_only ||
tool.annotations?.destructiveHint !== tool.destructive) {
throw new Error("tool_contract_invariant_failed:" + tool.name);
}
}
}
function atomicWrite(outputPath, payload) {
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
const temporary = outputPath + "." + process.pid + ".tmp";
fs.writeFileSync(temporary, payload, { flag: "wx", mode: 0o600 });
fs.renameSync(temporary, outputPath);
}
const [mode, inputPath, outputPath] = process.argv.slice(2);
if (mode === "--build") {
const handlerCommit = process.env.HANDLER_COMMIT;
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(handlerCommit || "")) {
throw new Error("HANDLER_COMMIT must be the reviewed source revision");
}
const sourceBytes = fs.readFileSync(inputPath);
const source = JSON.parse(sourceBytes.toString("utf8"));
validateSource(source);
const reviewed = {
...source,
schema_version: "aidefend.mcp-contract-reviewed.v1",
source_sha256: sha256(sourceBytes),
tools: source.tools.map(tool => ({
...tool, contract_hash: sha256(bytes(toolContract(tool))), handler_commit: handlerCommit
}))
};
atomicWrite(outputPath, bytes(reviewed));
} else if (mode === "--check") {
const reviewedBytes = fs.readFileSync(inputPath);
const reviewed = JSON.parse(reviewedBytes.toString("utf8"));
if (reviewed.schema_version !== "aidefend.mcp-contract-reviewed.v1" ||
!Array.isArray(reviewed.tools) || !reviewed.source_sha256) {
throw new Error("reviewed_contract_schema_mismatch");
}
for (const tool of reviewed.tools || []) {
if (tool.contract_hash !== sha256(bytes(toolContract(tool))) ||
!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(tool.handler_commit || "")) {
throw new Error("reviewed_contract_binding_failed:" + tool.name);
}
}
process.stdout.write(sha256(reviewedBytes) + "\n");
} else {
throw new Error("usage: build-mcp-contract.mjs --build source.json reviewed.json | --check reviewed.json");
}Sign the reviewed contract
# File: scripts/sign-mcp-contract.sh
set -euo pipefail
: "$MCP_CONTRACT_OUTPUT"
export HANDLER_COMMIT="$(git rev-parse --verify HEAD)"
node scripts/build-mcp-contract.mjs --build mcp-contract-source.json "$MCP_CONTRACT_OUTPUT"
cosign sign-blob --yes --bundle "$MCP_CONTRACT_OUTPUT.sigstore.json" "$MCP_CONTRACT_OUTPUT"Verify before activation
# File: scripts/verify-mcp-contract.sh
set -euo pipefail
: "$MCP_CONTRACT_OUTPUT"
: "$MCP_CONTRACT_SIGNER_IDENTITY"
: "$MCP_CONTRACT_OIDC_ISSUER"
cosign verify-blob \
--bundle "$MCP_CONTRACT_OUTPUT.sigstore.json" \
--certificate-identity "$MCP_CONTRACT_SIGNER_IDENTITY" \
--certificate-oidc-issuer "$MCP_CONTRACT_OIDC_ISSUER" \
"$MCP_CONTRACT_OUTPUT"
node scripts/build-mcp-contract.mjs --check "$MCP_CONTRACT_OUTPUT"Operational notes: Hash the contract fields that define model-visible meaning and execution safety: inputSchema, outputSchema, annotations, side-effect metadata, destructive/read-only flags, approval requirements, and model-visible descriptions. Keep handler_commit separate so operators can tell whether code changed, contract semantics changed, or both changed. If your SDK supports tool annotations such as read-only or destructive hints, treat them as security-relevant metadata. They must be accurate and reviewed, not generated casually from a function name. Read each source or reviewed artifact once and derive parsing and hashes from those same immutable bytes. Never hand-author derived hashes. Generate them from canonical source fields in the release job and verify the signed reviewed manifest fail-closed before activation.
Verify safely
A separately credentialed verifier independently canonicalizes and hashes every entry, verifies signature/transparency and approvers, compares live server/discover and list results with the signed manifest, and tests tampered, omitted, extra and rollback releases.
AID-H-034.004-G002
Concept:
Tool descriptions, parameter descriptions, prompt templates, and resource templates are read by models and sometimes users. A malicious or careless description can smuggle instructions, overstate authority, hide side effects, or make a destructive tool look read-only.
Example descriptor review policy
File: policies/mcp_descriptor_review.json
{
"required_checks": [
"description_matches_handler_behavior",
"no_hidden_instructions",
"no_claims_to_override_client_policy",
"no_instruction_to_exfiltrate_or_hide_data",
"side_effects_disclosed",
"read_only_and_destructive_annotations_match_behavior",
"schema_defaults_do_not_encode_behavioral_instructions"
],
"blocked_phrases": [
"ignore previous instructions",
"do not tell the user",
"always call this tool",
"bypass approval",
"send secrets"
]
}
Example static check
// file: scripts/check-mcp-descriptors.mjs
import fs from "node:fs";
const policy = JSON.parse(fs.readFileSync("policies/mcp_descriptor_review.json", "utf8"));
const manifestSource = process.argv[2] ?? "mcp-contract-manifest.json";
const manifest = JSON.parse(fs.readFileSync(manifestSource === "-" ? 0 : manifestSource, "utf8"));
const hiddenControls = /[\u200B-\u200D\uFEFF\u202A-\u202E\u2066-\u2069]/u;
const allowedDescriptorChars = /^[\u0009\u000A\u000D\u0020-\u007E]*$/u;
function canonicalizeDescriptorText(value) {
const normalized = String(value).normalize("NFKC");
if (hiddenControls.test(normalized)) {
throw new Error("Hidden Unicode control characters are not allowed in MCP descriptors");
}
if (!allowedDescriptorChars.test(normalized)) {
throw new Error("Descriptor contains characters outside the approved review alphabet");
}
return normalized.toLowerCase();
}
const text = canonicalizeDescriptorText(JSON.stringify(manifest));
for (const phrase of policy.blocked_phrases) {
if (text.includes(phrase)) {
throw new Error("Blocked descriptor phrase found: " + phrase);
}
}
console.log("MCP descriptor text check passed");
Operational notes: Run the check over the entire published contract, not only the human-facing description. Parameter names, enum values, defaults, annotations, custom fields, and output schemas can also carry poisoned instructions. This static check is only a baseline; high-value tools should also receive human review because semantic deception is not always catchable by keyword scans.
Verify safely
A separately credentialed verifier independently renders all variants and tests Unicode/markup hiding, instruction smuggling, annotation contradiction, privilege inflation, locale drift and handler-side-effect mismatch fixtures.
AID-H-034.004-G003
Concept:
The ability to change the published tool list at runtime is powerful. Treat list_changed events as security-relevant changes, especially if a client or model may automatically discover and use newly exposed capabilities.
Example change gate
// File: src/publication/changeGate.js
import crypto from "node:crypto";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
function positiveInteger(name) {
const value = Number(process.env[name]);
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(name + " must come from the verified release profile");
return value;
}
function requiredProfileString(name) {
const value = process.env[name];
if (typeof value !== "string" || value.length === 0 || value !== value.trim()) {
throw new Error(name + " must be a non-empty exact value from the verified release profile");
}
return value;
}
const COMMAND_TIMEOUT_MS = positiveInteger("VERIFIED_MCP_RELEASE_COMMAND_TIMEOUT_MS");
const COMMAND_MAX_BUFFER_BYTES = positiveInteger("VERIFIED_MCP_RELEASE_COMMAND_MAX_BUFFER_BYTES");
const APPROVAL_MAX_AGE_MS = positiveInteger("VERIFIED_MCP_APPROVAL_MAX_AGE_MS");
const VERIFIED_MCP_CONTRACT_SIGNER_IDENTITY = requiredProfileString(
"VERIFIED_MCP_CONTRACT_SIGNER_IDENTITY"
);
const APPROVAL_PUBLIC_KEY = crypto.createPublicKey(
fs.readFileSync(process.env.MCP_APPROVAL_PUBLIC_KEY_PATH)
);
let activeContractHash = null;
function canonicalize(value) {
if (Array.isArray(value)) return value.map(canonicalize);
if (value && typeof value === "object") {
return Object.fromEntries(Object.keys(value).sort().map(function mapKey(key) {
return [key, canonicalize(value[key])];
}));
}
return value;
}
function canonicalBytes(value) {
return Buffer.from(JSON.stringify(canonicalize(value)) + "\n");
}
function sha256(value) {
return crypto.createHash("sha256").update(value).digest("hex");
}
function run(file, args, input) {
execFileSync(file, args, {
stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
input,
timeout: COMMAND_TIMEOUT_MS,
maxBuffer: COMMAND_MAX_BUFFER_BYTES,
windowsHide: true
});
}
function verifyApprovalReceipt(receipt, contractHash, nowMs) {
if (!receipt || Object.keys(receipt).sort().join(",") !== "payload,signature_b64") {
throw new Error("approval_receipt_schema_mismatch");
}
const payload = receipt.payload;
const expected = [
"approval_id", "approver_subject", "contract_sha256", "decision",
"expires_at", "issued_at"
];
if (!payload || Object.keys(payload).sort().join(",") !== expected.sort().join(",")) {
throw new Error("approval_payload_schema_mismatch");
}
const signature = Buffer.from(receipt.signature_b64, "base64");
if (!signature.length || !crypto.verify(null, canonicalBytes(payload), APPROVAL_PUBLIC_KEY, signature)) {
throw new Error("approval_signature_invalid");
}
const issuedAt = Date.parse(payload.issued_at);
const expiresAt = Date.parse(payload.expires_at);
if (payload.decision !== "approve" || payload.contract_sha256 !== contractHash ||
typeof payload.approval_id !== "string" || !payload.approval_id ||
typeof payload.approver_subject !== "string" || !payload.approver_subject ||
!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt) || issuedAt > nowMs ||
expiresAt <= nowMs || nowMs - issuedAt > APPROVAL_MAX_AGE_MS) {
throw new Error("approval_not_current_or_not_bound");
}
return { approvalId: payload.approval_id, subject: payload.approver_subject };
}
export function verifyContractArtifact({ manifestPath, bundlePath, hashPath }) {
const manifestBytes = fs.readFileSync(manifestPath);
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-contract-"));
const snapshotPath = path.join(temporaryRoot, "contract.json");
try {
fs.writeFileSync(snapshotPath, manifestBytes, { flag: "wx", mode: 0o600 });
run("node", ["scripts/check-mcp-descriptors.mjs", "-"], manifestBytes);
run("node", ["scripts/build-mcp-contract.mjs", "--check", snapshotPath]);
const expectedHash = fs.readFileSync(hashPath, "utf8").trim();
if (!/^[a-f0-9]{64}$/.test(expectedHash) || expectedHash !== sha256(manifestBytes)) {
throw new Error("contract_release_digest_mismatch");
}
run("cosign", [
"verify-blob", "--bundle", bundlePath,
"--certificate-identity", VERIFIED_MCP_CONTRACT_SIGNER_IDENTITY,
"--certificate-oidc-issuer", "https://token.actions.githubusercontent.com",
snapshotPath
]);
const contract = JSON.parse(manifestBytes.toString("utf8"));
if (!contract || typeof contract !== "object" || Array.isArray(contract) ||
typeof contract.contract_version !== "string" || !Array.isArray(contract.tools)) {
throw new Error("verified_contract_schema_mismatch");
}
return { contract: Object.freeze(contract), contractHash: sha256(manifestBytes) };
} finally {
fs.rmSync(temporaryRoot, { recursive: true, force: true });
}
}
export function activateContract({
approvalReceipts, logger, manifestPath, bundlePath, hashPath
}) {
const verified = verifyContractArtifact({ manifestPath, bundlePath, hashPath });
if (!Array.isArray(approvalReceipts) || approvalReceipts.length < 2) {
throw new Error("MCP contract activation requires two authenticated approvals");
}
const nowMs = Date.now();
const approvals = approvalReceipts.map(function verifyReceipt(receipt) {
return verifyApprovalReceipt(receipt, verified.contractHash, nowMs);
});
if (new Set(approvals.map(item => item.subject)).size < 2 ||
new Set(approvals.map(item => item.approvalId)).size !== approvals.length) {
throw new Error("MCP contract approvals must be distinct");
}
activeContractHash = verified.contractHash;
logger.info({
event_type: "mcp_contract_activated",
contract_version: verified.contract.contract_version,
contract_hash: verified.contractHash,
approver_subject_hashes: approvals.map(item => sha256(item.subject)),
tool_count: verified.contract.tools.length
});
return verified.contractHash;
}
export function getActiveContractHash() {
return activeContractHash;
}Operational notes: Do not let individual tool handlers mutate the server's public contract as a side effect of user input. Dynamic discovery should still be backed by an approved manifest, feature flag, or release artifact. Before emitting notifications/tools/list_changed, rerun descriptor scanning, compare every tool's contract hash against the approved contract, verify the manifest signature over the immutable bytes that will be activated, validate two distinct approval-service signatures bound to that exact artifact hash, and emit the new contract hash in telemetry. The command limits, maximum approval age, and exact signer identity must come from the signed, versioned release profile exported as the VERIFIED_MCP_* values. The signed profile must pin the complete expected GitHub Actions workflow/ref identity; a repository-prefix pattern is not sufficient.
Verify safely
A separately credentialed verifier recomputes the semantic diff, repeats list/discover calls for determinism, tests supported 2026-07-28 client-capability combinations, cursor/cache invalidation, duplicate/out-of-order/missed notifications, partial rollout, restart and rollback across every instance.
AID-H-034.004-G004
Concept
MCP 2026-07-28 requires cache hints on complete results from server/discover, tools/list, prompts/list, resources/list, resources/templates/list, and resources/read. Include integer ttlMs >= 0 and cacheScope of public or private. Each page is independently cacheable and all pages in one logical result use the same scope. MRTR input_required results are not cacheable.
// File: src/publication/cacheableResult.js
const CACHEABLE = new Set([
"server/discover", "tools/list", "prompts/list", "resources/list",
"resources/templates/list", "resources/read"
]);
export function attachCachePolicy({ method, result, containsPrivateData, policy, pageScope }) {
if (!CACHEABLE.has(method) || result?.resultType !== "complete") {
throw new Error("method_or_result_not_cacheable");
}
const rule = policy.cache?.[method];
if (!rule || !Number.isSafeInteger(rule.ttlMs) || rule.ttlMs < 0 ||
!["public", "private"].includes(rule.cacheScope)) {
throw new Error("exact_rc_cache_policy_required");
}
if (pageScope !== undefined && pageScope !== rule.cacheScope) {
throw new Error("pagination_cache_scope_changed");
}
if (rule.cacheScope === "public" && containsPrivateData) {
throw new Error("private_result_marked_public");
}
return { ...result, ttlMs: rule.ttlMs, cacheScope: rule.cacheScope };
}
export function stableByIdentity(items) {
return [...items].sort((a, b) =>
String(a.name ?? a.uri).localeCompare(String(b.name ?? b.uri), "en")
);
}Action and evidence: make server/discover itself a deterministic cacheable result; do not invent a separate cache capability. Block publication when signed policy lacks valid hints. Bind private content to authorization context at the client/cache layer, allow public scope only after classification review, and invalidate on contract, server-build, policy, authorization, or content change. Preserve method, cursor, ordered digest, TTL, scope, policy, and invalidation reason.
Verify safely
Repeat every cacheable request, permute order, traverse pages, cross identities and scopes, change contracts, expire TTLs, inject missing/malformed hints, return input_required, and compare live fields—including server/discover—with signed policy.
AID-H-034.004-G005
Concept:
When you publish an MCP server to the official registry or any catalog, the server name and server.json manifest become attacker-influenceable trust signals. Prove namespace ownership before publishing, treat the manifest as a reviewed security artifact, and remember that registry presence proves namespace ownership only, not that the package is safe.
Step 1: Prove namespace ownership in CI
- For
io.github.<org>/*, publish only from a workflow tied to the owning GitHub account and repository. - For reverse-DNS names such as
com.example/*, complete the registry-supported DNS or HTTP ownership challenge. - For npm, require
package.jsonmcpNameto exactly matchserver.jsonname. - For PyPI or NuGet, require the package metadata or README to carry the exact MCP name.
- For OCI, require the
io.modelcontextprotocol.server.nameimage annotation to match.
Reject publication when the selected registry method cannot prove namespace ownership.
Step 2: Review and pin server.json
// file: scripts/check-server-json.mjs
import fs from "node:fs";
const manifest = JSON.parse(fs.readFileSync("server.json", "utf8"));
const NAME_OK = new RegExp("^[a-z0-9._/-]+$");
const PACKAGE_TYPES = new Set(["npm", "pypi", "nuget", "oci", "mcpb"]);
const SHELL_META = [";", "&", "|", "$", "(", ")", "{", "}", "<", ">"];
if (!NAME_OK.test(manifest.name)) {
throw new Error("Invalid server name");
}
for (const pkg of manifest.packages || []) {
if (!PACKAGE_TYPES.has(pkg.registryType)) {
throw new Error("Unsupported MCP Registry package type: " + pkg.registryType);
}
if (!pkg.identifier || typeof pkg.identifier !== "string") {
throw new Error("Package identifier is required");
}
if (pkg.runtime_hint && SHELL_META.some(ch => pkg.runtime_hint.includes(ch))) {
throw new Error("Install command must not embed shell metacharacters");
}
if (!pkg.version || /latest/i.test(pkg.version)) {
throw new Error("Pin packages by exact version and digest, not 'latest'");
}
if (pkg.registryType === "mcpb" && !/^[a-f0-9]{64}$/i.test(pkg.fileSha256 || "")) {
throw new Error("MCPB packages require fileSha256");
}
if (pkg.registryType === "oci" && !/(@sha256:[a-f0-9]{64}|:[^/]+)$/i.test(pkg.identifier)) {
throw new Error("OCI identifiers must include an exact tag or digest");
}
}
console.log("server.json accepted");
Operational notes: This script is an internal publisher review gate, not a replacement for the official server.json schema validator. Put the name, version, package and remote endpoints, install command, and declared environment variables under the same two-approver, contract-hash gate as your tools. Monitor for typo or affix squatting of your own server name. This is the publisher-side complement to AID-H-030.001 (consumer-side impersonation detection) and AID-H-024 (consumer resolution integrity).
Verify safely
A separately credentialed verifier independently repeats namespace proof, schema validation and artifact download/hash/signature checks; tests lookalike names, unauthorized owner, wrong package type/id/digest, floating tag, downgrade and mutable-republish cases.
AID-H-034.004-G006
Concept:
MCP Apps let a server publish interactive HTML UI as ui:// resources that the host renders in a sandboxed iframe. The server is the publisher of record for the UI resource's _meta.ui.csp and _meta.ui.permissions, and for the tool's _meta.ui.resourceUri and visibility metadata. A poisoned UI resource can exfiltrate via out-of-policy network calls or smuggle instructions, so govern it like any other published surface. If your server has no UI, do not declare this capability.
Step 1: Declare ui:// resources in the internal contract manifest
{
"schema_version": "aidefend.mcp-ui-source.v1",
"ui_resources": [
{
"uri": "ui://payments/refund-confirm",
"mime": "text/html;profile=mcp-app",
"content_path": "refund-confirm/index.html",
"linked_tools": ["refund_payment"],
"tool_meta": {
"_meta": {
"ui": {
"resourceUri": "ui://payments/refund-confirm",
"visibility": ["app"]
}
}
},
"resource_meta": {
"_meta": {
"ui": {
"csp": {
"connectDomains": [],
"resourceDomains": [],
"frameDomains": []
},
"permissions": []
}
}
},
"sensitive_permission_approval": null
}
]
}Step 2: Enforce at publication
// File: scripts/build-ui-resources.mjs
import crypto from "node:crypto";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const SENSITIVE = new Set(["camera", "microphone", "geolocation", "clipboard-write"]);
const VISIBILITY = new Set(["app", "model"]);
const sha256 = value => "sha256:" + crypto.createHash("sha256").update(value).digest("hex");
function canonicalize(value) {
if (Array.isArray(value)) return value.map(canonicalize);
if (value && typeof value === "object") {
return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonicalize(value[key])]));
}
return value;
}
function positiveEnvironmentInteger(name) {
const value = Number(process.env[name]);
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(name + " is invalid");
return value;
}
function verifyAndLoadPolicy(policyPath) {
const bytes = fs.readFileSync(policyPath);
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-ui-policy-"));
const snapshotPath = path.join(temporaryRoot, "policy.json");
try {
fs.writeFileSync(snapshotPath, bytes, { flag: "wx", mode: 0o600 });
execFileSync("cosign", [
"verify-blob", "--key", process.env.MCP_UI_POLICY_PUBLIC_KEY,
"--bundle", process.env.MCP_UI_POLICY_SIGNATURE, snapshotPath
], {
stdio: ["ignore", "pipe", "pipe"],
timeout: positiveEnvironmentInteger("VERIFIED_MCP_UI_POLICY_VERIFY_TIMEOUT_MS"),
maxBuffer: positiveEnvironmentInteger("VERIFIED_MCP_UI_POLICY_VERIFY_MAX_BUFFER_BYTES"),
windowsHide: true
});
} finally {
fs.rmSync(temporaryRoot, { recursive: true, force: true });
}
const policy = JSON.parse(bytes);
const canonical = Buffer.from(JSON.stringify(canonicalize(policy)) + "\n");
if (!bytes.equals(canonical) ||
Object.keys(policy).sort().join(",") !==
"allowedCspOrigins,allowedSensitivePermissions,maxUiBytes,version" ||
typeof policy.version !== "string" || !policy.version ||
!Number.isSafeInteger(policy.maxUiBytes) || policy.maxUiBytes <= 0 ||
!Array.isArray(policy.allowedCspOrigins) ||
policy.allowedCspOrigins.length !== new Set(policy.allowedCspOrigins).size ||
!Array.isArray(policy.allowedSensitivePermissions) ||
policy.allowedSensitivePermissions.length !== new Set(policy.allowedSensitivePermissions).size) {
throw new Error("signed_ui_policy_schema_or_canonicalization_mismatch");
}
return { policy, policySha256: sha256(bytes) };
}
function canonicalOrigin(value) {
const parsed = new URL(value);
if (parsed.protocol !== "https:" || parsed.username || parsed.password ||
parsed.pathname !== "/" || parsed.search || parsed.hash || parsed.origin !== value) {
throw new Error("CSP domains must be canonical HTTPS origins: " + value);
}
return parsed.origin;
}
function contentFile(root, relativePath, maximumBytes) {
if (path.isAbsolute(relativePath)) throw new Error("UI content_path must be relative");
const realRoot = fs.realpathSync(root);
const realFile = fs.realpathSync(path.resolve(realRoot, relativePath));
const relative = path.relative(realRoot, realFile);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error("UI content_path escapes MCP_UI_SOURCE_ROOT");
}
const descriptor = fs.openSync(
realFile, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)
);
try {
const stat = fs.fstatSync(descriptor);
if (!stat.isFile() || stat.size > maximumBytes) {
throw new Error("UI content is not a regular in-policy file");
}
const buffer = Buffer.alloc(maximumBytes + 1);
let length = 0;
while (length < buffer.length) {
const count = fs.readSync(descriptor, buffer, length, buffer.length - length, null);
if (count === 0) break;
length += count;
}
if (length > maximumBytes) throw new Error("UI resource exceeds signed size policy");
return buffer.subarray(0, length);
} finally {
fs.closeSync(descriptor);
}
}
const [sourcePath, policyPath, outputPath] = process.argv.slice(2);
if (!sourcePath || !policyPath || !outputPath || !process.env.MCP_UI_SOURCE_ROOT) {
throw new Error("usage: build-ui-resources.mjs source.json signed-policy.json reviewed.json");
}
const sourceBytes = fs.readFileSync(sourcePath);
const source = JSON.parse(sourceBytes.toString("utf8"));
if (source.schema_version !== "aidefend.mcp-ui-source.v1" || !Array.isArray(source.ui_resources)) {
throw new Error("UI source manifest schema mismatch");
}
const { policy, policySha256 } = verifyAndLoadPolicy(policyPath);
const allowedOrigins = new Set(policy.allowedCspOrigins.map(canonicalOrigin));
const allowedSensitive = new Set(policy.allowedSensitivePermissions);
const seenUris = new Set();
const reviewedResources = source.ui_resources.map(ui => {
if (typeof ui.uri !== "string" || !ui.uri.startsWith("ui://") || seenUris.has(ui.uri)) {
throw new Error("invalid_or_duplicate_ui_uri");
}
seenUris.add(ui.uri);
if (ui.mime !== "text/html;profile=mcp-app" ||
ui.tool_meta?._meta?.ui?.resourceUri !== ui.uri ||
!Array.isArray(ui.linked_tools) || !ui.linked_tools.length) {
throw new Error("UI linkage or MIME mismatch: " + ui.uri);
}
const visibility = ui.tool_meta?._meta?.ui?.visibility;
if (!Array.isArray(visibility) || !visibility.length ||
visibility.some(value => !VISIBILITY.has(value))) throw new Error("invalid UI visibility");
const csp = ui.resource_meta?._meta?.ui?.csp;
if (!csp) throw new Error("UI resource must declare CSP");
for (const value of [
...(csp.connectDomains || []), ...(csp.resourceDomains || []), ...(csp.frameDomains || [])
]) {
if (!allowedOrigins.has(canonicalOrigin(value))) throw new Error("CSP origin outside signed policy: " + value);
}
const permissions = ui.resource_meta?._meta?.ui?.permissions || [];
for (const permission of permissions) {
if (SENSITIVE.has(permission) &&
(!allowedSensitive.has(permission) || !ui.sensitive_permission_approval)) {
throw new Error("sensitive UI permission lacks signed human approval: " + permission);
}
}
const content = contentFile(
process.env.MCP_UI_SOURCE_ROOT, ui.content_path, policy.maxUiBytes
);
return {
...ui,
content_hash: sha256(content),
content_bytes: content.length,
policy_version: policy.version,
policy_sha256: policySha256
};
});
const reviewed = Buffer.from(JSON.stringify({
schema_version: "aidefend.mcp-ui-reviewed.v1",
source_sha256: sha256(sourceBytes),
ui_resources: reviewedResources
}, null, 2) + "\n");
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
const temporary = outputPath + "." + process.pid + ".tmp";
fs.writeFileSync(temporary, reviewed, { flag: "wx", mode: 0o600 });
fs.renameSync(temporary, outputPath);The signed UI policy must be emitted as canonical JSON (sorted object keys, compact encoding, one trailing newline); this makes duplicate-key or alternate-parser representations fail before publication. Verification runs over an immutable snapshot with command limits supplied by the verified release profile.
Operational notes: The ui_resources source array is compiled into a hash-bound reviewed manifest and then signed under the contract-release workflow. The actual MCP Apps metadata is nested under _meta.ui: resourceUri and visibility belong on the tool, while csp and permissions belong on the UI resource returned by resources/list or resources/read. Extend the descriptor-honesty review to the served HTML (no hidden instructions, no out-of-CSP network or frame access, declared CSP and permissions match actual use). Treat ui:// list changes as two-approver releases. The host (AID-H-028) enforces the iframe sandbox and CSP; the server only DECLARES them, so keep the server-declares / host-enforces split explicit.
Verify safely
A separately credentialed verifier independently fetches list/read bytes and renders the UI in a test host while exercising content drift, wrong linkage, wildcard/undeclared exfiltration, frame, hidden instruction, sensitive permission, dynamic resource and visibility-confusion fixtures.
Open-source tools
- Git
- Cosign
- Open Policy Agent (OPA)
- Semgrep
- python-jsonschema
- Snyk Agent Scan (Snyk verification API required; sandbox untrusted MCP stdio discovery)
Commercial / hosted tools
- GitHub Enterprise Cloud
- GitLab Ultimate
Threat mappings
MITRE ATLAS
- AML.T0011 User Execution
- AML.T0115 Publish Poisoned AI Artifacts
- AML.T0110.000 AI Agent Tool Poisoning: Definition and Instructions
- AML.T0074 Masquerading
- AML.T0011.002 User Execution: Poisoned AI Agent Tool
- AML.T0115.002 Publish Poisoned AI Artifacts: AI Agent Tools
- AML.T0110 AI Agent Tool Poisoning
- AML.T0010 AI Supply Chain Compromise
- AML.T0010.005 AI Supply Chain Compromise: AI Agent Tool
MAESTRO
- Compromised Agent Registry (L7)
- Supply Chain Attacks (Cross-Layer)
OWASP LLM Top 10 2026
- LLM01:2026 Prompt Injection
OWASP ML Top 10 2023
- ML06:2023 AI Supply Chain Attacks
OWASP Top 10 for Agentic Applications 2026
- ASI04:2026 Agentic Supply Chain Vulnerabilities
NIST Adversarial Machine Learning 2025
- N/A
Cisco Integrated AI Security and Safety Framework
- AISubtech-12.1.2 Tool Poisoning (gates MCP publication and replacement on reviewed signed contracts)
- AISubtech-4.3.1 Schema Inconsistencies (rejects poisoned schema fields and privilege-inflating descriptors before publication)
- AISubtech-4.3.5 Capability Inflation (rejects poisoned schema fields and privilege-inflating descriptors before publication)
- AISubtech-9.3.1 Malicious Package / Tool Injection (Gates published MCP tools and server contracts before downstream discovery or invocation)
- AISubtech-9.3.2 Dependency Name Squatting (Tools / Servers) (Gates published MCP tools and server contracts before downstream discovery or invocation)
- AISubtech-9.3.3 Dependency Replacement / Rug Pull (Gates published MCP tools and server contracts before downstream discovery or invocation)
- AITech-1.2 Indirect Prompt Injection
- AITech-12.1 Tool Exploitation (gates MCP publication and replacement on reviewed signed contracts)
- AITech-4.3 Protocol Manipulation (rejects poisoned schema fields and privilege-inflating descriptors before publication)
- AITech-9.3 Dependency / Plugin Compromise (Gates published MCP tools and server contracts before downstream discovery or invocation)
Google Secure AI Framework 2.0 - Risks
- IIC: Insecure Integrated Component
- PIJ: Prompt Injection
Databricks AI Security Framework 3.0
- Agents - Tools MCP Server 13.18: Tool Poisoning
- Agents - Tools MCP Server 13.21: Supply Chain Attacks
- Agents - Tools MCP Server 13.24: Context Spoofing and Manipulation