AID-H-034.005: MCP Server Output, Resource URI & Elicitation Boundary Controls

Version 1.20261015 · Actionable sub-technique

Constrain the data and instructions that leave the MCP server. This includes validating, neutralizing, and labeling tool outputs, validating structuredContent against outputSchema, enforcing resource URI and template boundaries, preventing prompt/resource/completion output from smuggling privileged instructions, and handling multi-round-trip input requests securely from the server side.

Pillars: app, data

Lifecycle: building, operation

Scope boundary

Owns server-side validation, bounding, redaction, neutralization, and labeling of tool outputs, structured content, resource URIs, prompts, completions, and InputRequiredResult construction and response revalidation.

AID-H-028.007: AID-H-034.005 constructs and validates multi-round-trip input requests at the server; AID-H-028.007 binds, presents, and validates the corresponding active request and user response at the client. Both sides preserve original-operation binding without creating a protocol session.

AID-H-028.004: AID-H-034.005 constrains content before it leaves the MCP server; AID-H-028.004 safely parses and renders all received content at the client. Server sanitization does not justify an unsafe renderer.

AID-H-018.009: AID-H-034.005 owns MCP resource-URI, output, prompt, completion, and elicitation semantics; AID-H-018.009 owns the generic descriptor- or handle-safe opened-object result for an applicable local-file handler. The MCP handler consumes the broker receipt and must not claim a second PASS for the same filesystem confinement.

Implementation guidance

AID-H-034.005-G001

Validate, bound, label, and redact MCP tool outputs before returning them to the client or model context.
Concept:

Tool output is not automatically trustworthy. It may contain secrets, prompt injection text, hostile markup, oversized binary data, or content that looks like a system instruction. The server should enforce output schemas, size limits, MIME limits, redaction, and untrusted-content labels before sending the result.

Example output guard
// file: src/output/toolOutputGuard.js
    const MAX_TEXT_CHARS = 12000;
    const CONTROL_CHARS = /[\u200B-\u200D\uFEFF\u202A-\u202E\u2066-\u2069]/g;
    const PSEUDO_TAGS = /<\/?(system|developer|instructions|tool|assistant|user|important)[^>]*>/gi;
    const secretPatterns = [
      /sk-[A-Za-z0-9_-]{20,}/g,
      /ghp_[A-Za-z0-9_]{20,}/g,
      /-----BEGIN PRIVATE KEY-----[\s\S]+?-----END PRIVATE KEY-----/g
    ];

    export function guardToolTextOutput({ toolName, text }) {
      let safe = String(text || "")
        .normalize("NFKC")
        .replace(CONTROL_CHARS, "")
        .replace(PSEUDO_TAGS, "[REMOVED_PSEUDO_INSTRUCTION_TAG]")
        .slice(0, MAX_TEXT_CHARS);

      for (const pattern of secretPatterns) {
        safe = safe.replace(pattern, "[REDACTED_SECRET]");
      }

      return {
        type: "text",
        text: "Untrusted tool output from " + toolName + ":\n" + safe,
        _meta: {
          aidefend_untrusted_output: true,
          output_guard: "h035_005",
          truncated: String(text || "").length > MAX_TEXT_CHARS
        }
      };
    }
    

Operational notes: Prefer output-schema projection that returns only the fields the model needs. Labels are auxiliary; normalization, redaction, tag neutralization, schema projection, and size bounds are the load-bearing controls. The client still needs its own parser and renderer protections in AID-H-028.

Verify safely

A separately credentialed verifier replays valid, oversized, many-block, mixed-type, malformed encoding, embedded instruction, bidi/control, known/novel secret, PII, binary/polyglot, partial and scanner-outage fixtures through the deployed serializer.

AID-H-034.005-G002

Validate structuredContent against each tool's outputSchema, recursively bound and redact structured fields, and return sanitized tool-execution errors.
Concept:

MCP tool results can contain both model-visible content and machine-readable structuredContent. If a tool declares an outputSchema, the server must provide structured results that conform to it. Treat structured output as untrusted until it is schema-validated, projected to allowed fields, size-bounded, and redacted. Tool execution errors should help model self-correction without echoing raw inputs, SQL errors, stack traces, stderr, tokens, or secrets.

Example structured output gate
// File: src/output/structuredOutputGate.js
import crypto from "node:crypto";
import Ajv2020 from "ajv/dist/2020.js";

const ajv = new Ajv2020({ allErrors: true, strict: true, coerceTypes: false, useDefaults: false });
const validators = new Map();
const sha256 = value => 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 checkedPolicy(policy) {
  const names = ["maxStringChars", "maxArrayItems", "maxObjectEntries", "maxDepth", "maxNodes", "maxBytes"];
  if (!policy?.version || names.some(name => !Number.isSafeInteger(policy[name]) || policy[name] <= 0)) {
    throw new Error("invalid_signed_structured_output_policy");
  }
  return policy;
}

function validatorFor(toolName, outputSchema) {
  const schemaBytes = JSON.stringify(canonicalize(outputSchema));
  const key = toolName + ":" + sha256(schemaBytes);
  if (!validators.has(key)) validators.set(key, ajv.compile(outputSchema));
  return { validate: validators.get(key), schemaSha256: key.slice(toolName.length + 1) };
}

function boundedRedact(root, policy, redactString) {
  const seen = new WeakSet();
  let nodes = 0;
  function visit(value, path, depth) {
    nodes += 1;
    if (nodes > policy.maxNodes || depth > policy.maxDepth) throw new Error("structured_output_complexity_exceeded");
    if (typeof value === "string") {
      if (value.length > policy.maxStringChars) throw new Error("structured_output_string_too_long");
      const redacted = redactString(path, value);
      if (typeof redacted !== "string" || redacted.length > policy.maxStringChars) {
        throw new Error("structured_output_redactor_contract_failed");
      }
      return redacted;
    }
    if (!value || typeof value !== "object") return value;
    if (seen.has(value)) throw new Error("structured_output_cycle");
    seen.add(value);
    if (Array.isArray(value)) {
      if (value.length > policy.maxArrayItems) throw new Error("structured_output_array_too_large");
      return value.map((item, index) => visit(item, path + "/" + index, depth + 1));
    }
    if (Object.getPrototypeOf(value) !== Object.prototype) throw new Error("structured_output_non_plain_object");
    const entries = Object.entries(value);
    if (entries.length > policy.maxObjectEntries) throw new Error("structured_output_object_too_large");
    const result = Object.create(null);
    for (const [key, nested] of entries) {
      if (["__proto__", "prototype", "constructor"].includes(key)) {
        throw new Error("structured_output_prototype_key");
      }
      result[key] = visit(nested, path + "/" + key.replaceAll("~", "~0").replaceAll("/", "~1"), depth + 1);
    }
    return result;
  }
  const redacted = visit(root, "", 0);
  const serialized = JSON.stringify(redacted);
  if (Buffer.byteLength(serialized) > policy.maxBytes) throw new Error("structured_output_bytes_exceeded");
  return { redacted, nodes, bytes: Buffer.byteLength(serialized) };
}

function failOutputConformance(toolName, errors, reason) {
  const error = new Error(reason + ":" + toolName + ":" +
    (errors || []).map(item => item.keyword).join(","));
  error.code = -32603;
  throw error;
}

export function validateStructuredOutput(input) {
  const policy = checkedPolicy(input.policy);
  const { validate, schemaSha256 } = validatorFor(input.toolName, input.outputSchema);
  if (!validate(input.structuredContent)) {
    return failOutputConformance(input.toolName, validate.errors, "output_schema_mismatch");
  }
  const bounded = boundedRedact(input.structuredContent, policy, input.redactString);
  if (!validate(bounded.redacted)) {
    return failOutputConformance(input.toolName, validate.errors, "redacted_output_schema_mismatch");
  }
  const mirror = JSON.stringify(bounded.redacted);
  return {
    resultType: "complete",
    content: [{ type: "text", text: mirror }],
    structuredContent: bounded.redacted, isError: false,
    _meta: { aidefend_output_schema_sha256: schemaSha256,
      aidefend_output_policy_version: policy.version,
      aidefend_output_nodes: bounded.nodes, aidefend_output_bytes: bounded.bytes }
  };
}

export function safeToolExecutionError(input) {
  const publicMessage = input.publicErrorPolicy.codes?.[input.code];
  if (typeof publicMessage !== "string" || !publicMessage) {
    throw new Error("unmapped_internal_tool_error");
  }
  if (input.outputSchema !== undefined) {
    if (input.errorStructuredContent === undefined) {
      return failOutputConformance(input.toolName, [],
        "schema_safe_error_output_required");
    }
    const checked = validateStructuredOutput({
      ...input, structuredContent: input.errorStructuredContent
    });
    return {
      ...checked, isError: true,
      content: [
        { type: "text", text: publicMessage },
        { type: "text", text: JSON.stringify(checked.structuredContent) }
      ]
    };
  }
  return {
    resultType: "complete",
    content: [{ type: "text", text: publicMessage }],
    isError: true
  };
}

Operational notes: Use JSON Schema 2020-12 unless the tool explicitly declares a different supported dialect. Do not put raw database errors, backend stderr, stack traces, or rejected argument values into model-visible error text.

Verify safely

A separately credentialed verifier independently validates and traverses deep/wide/cyclic-like inputs, schema drift, coercion, extra properties, redaction-induced invalidity, secret nesting, prototype keys, content contradiction, verbose exception and validator/scanner outage fixtures.

AID-H-034.005-G003

Validate resource URIs and templates, authorize the canonical resource, and delegate every applicable local-file resolution and read to the AID-H-018.009 filesystem broker without rescoring its opened-object result.
Concept and ownership

MCP owns resource-URI and template semantics, output shape, and release behavior. Treat the URI as untrusted, map it to a signed root alias and canonical relative object, and obtain the authoritative resource decision from the normal authorization service. Delegate filesystem resolution and bounded reading to the canonical AID-H-018.009 broker. The MCP handler consumes that signed opened-object receipt; it does not implement or score a second path-confinement result.

Broker-backed local-resource handler

This example uses an application-specific aidefend-file:// scheme so model-visible URIs contain a root alias rather than a host path. The mutually authenticated broker client must verify receipt signatures before returning from verifySignedReceipt.

// File: src/resources/fileResourceGate.js
import { createHash } from "node:crypto";

const RECEIPT_KEYS = [
  "adapterBuildSha256", "bytesSha256", "control", "expiresAt", "issuedAt",
  "objectIdentity", "operation", "outcome", "platformProfile", "policyVersion",
  "receiptSha256", "relativePath", "rootId", "schemaVersion"
].sort();

function exactKeys(value, expected) {
  return value !== null && typeof value === "object" && !Array.isArray(value) &&
    JSON.stringify(Object.keys(value).sort()) === JSON.stringify(expected);
}

function parseResourceUri(uri) {
  if (typeof uri !== "string" || uri.length > 4096 || uri.includes("\0")) {
    throw new Error("invalid_resource_uri");
  }
  const match = /^aidefend-file:\/\/([a-z0-9][a-z0-9-]{0,62})\/([^?#]+)$/i.exec(uri);
  if (!match) throw new Error("unsupported_or_ambiguous_resource_uri");

  const rootAlias = match[1].toLowerCase();
  const segments = match[2].split("/").map((raw) => {
    if (!raw || /%2f|%5c|%00/i.test(raw)) {
      throw new Error("encoded_separator_or_nul_in_resource_uri");
    }
    let decoded;
    try {
      decoded = decodeURIComponent(raw);
    } catch {
      throw new Error("invalid_resource_uri_encoding");
    }
    if (!decoded || decoded === "." || decoded === ".." ||
        decoded.includes("/") || decoded.includes("\\") || decoded.includes("\0") ||
        /%[0-9a-f]{2}/i.test(decoded) || decoded.normalize("NFC") !== decoded) {
      throw new Error("ambiguous_resource_path_component");
    }
    return decoded;
  });
  return {
    rootAlias,
    relativePath: segments.join("/"),
    canonicalUri:
      "aidefend-file://" + rootAlias + "/" +
      segments.map((part) => encodeURIComponent(part)).join("/")
  };
}

function requireAllow(decision) {
  if (!decision || decision.outcome !== "ALLOW" ||
      typeof decision.receiptSha256 !== "string" ||
      !/^[a-f0-9]{64}$/.test(decision.receiptSha256)) {
    throw new Error("resource_unavailable_or_unauthorized");
  }
  return decision.receiptSha256;
}

async function verifyBrokerResult(input, expected) {
  const { bytes, receipt } = input.brokerResult;
  if (!(bytes instanceof Uint8Array) || !exactKeys(receipt, RECEIPT_KEYS)) {
    throw new Error("invalid_path_broker_result");
  }
  await input.pathBroker.verifySignedReceipt(receipt);
  if (receipt.schemaVersion !== "aidefend.path-broker-receipt.v1" ||
      receipt.control !== "AID-H-018.009" || receipt.outcome !== "PASS" ||
      receipt.operation !== "read" || receipt.rootId !== expected.rootId ||
      receipt.relativePath !== expected.relativePath ||
      receipt.policyVersion !== expected.policyVersion ||
      receipt.adapterBuildSha256 !== expected.adapterBuildSha256 ||
      !receipt.platformProfile || bytes.length > expected.maximumBytes) {
    throw new Error("path_broker_receipt_binding_mismatch");
  }
  const issuedAt = Date.parse(receipt.issuedAt);
  const expiresAt = Date.parse(receipt.expiresAt);
  if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt) ||
      issuedAt > Date.now() || Date.now() >= expiresAt) {
    throw new Error("stale_path_broker_receipt");
  }
  const bytesSha256 = createHash("sha256").update(bytes).digest("hex");
  if (bytesSha256 !== receipt.bytesSha256 ||
      !/^[a-f0-9]{64}$/.test(receipt.receiptSha256) ||
      !exactKeys(receipt.objectIdentity, ["fileId", "linkCount", "size", "type", "volumeId"]) ||
      receipt.objectIdentity.type !== "regular" ||
      receipt.objectIdentity.size !== bytes.length) {
    throw new Error("path_broker_object_or_content_mismatch");
  }
  return { bytes, receipt };
}

export async function readAllowedFileResource(input) {
  if (!input?.policy || !input.pathBroker ||
      !Number.isSafeInteger(input.policy.maxResourceBytes) ||
      input.policy.maxResourceBytes <= 0 ||
      typeof input.policy.version !== "string" ||
      typeof input.policy.adapterBuildSha256 !== "string" ||
      !/^[a-f0-9]{64}$/.test(input.policy.adapterBuildSha256)) {
    throw new Error("invalid_signed_resource_policy");
  }

  const resource = parseResourceUri(input.uri);
  if (!Object.hasOwn(input.policy.roots, resource.rootAlias)) {
    throw new Error("resource_unavailable_or_unauthorized");
  }
  const rootPolicy = input.policy.roots[resource.rootAlias];
  if (!rootPolicy || typeof rootPolicy.rootId !== "string" ||
      typeof rootPolicy.mimeType !== "string") {
    throw new Error("invalid_signed_resource_root_policy");
  }

  const requestedAuthorization = requireAllow(await input.authorizeResource({
    auth: input.auth,
    action: "read",
    canonicalUri: resource.canonicalUri,
    rootId: rootPolicy.rootId,
    relativePath: resource.relativePath,
    policyVersion: input.policy.version,
    phase: "requested_resource"
  }));

  const brokerResult = await input.pathBroker.readBounded({
    authorizationReceiptSha256: requestedAuthorization,
    rootId: rootPolicy.rootId,
    relativePath: resource.relativePath,
    maximumBytes: input.policy.maxResourceBytes,
    policyVersion: input.policy.version,
    adapterBuildSha256: input.policy.adapterBuildSha256
  });
  const verified = await verifyBrokerResult({
    brokerResult, pathBroker: input.pathBroker
  }, {
    rootId: rootPolicy.rootId,
    relativePath: resource.relativePath,
    policyVersion: input.policy.version,
    adapterBuildSha256: input.policy.adapterBuildSha256,
    maximumBytes: input.policy.maxResourceBytes
  });

  requireAllow(await input.authorizeResource({
    auth: input.auth,
    action: "read",
    canonicalUri: resource.canonicalUri,
    rootId: rootPolicy.rootId,
    relativePath: resource.relativePath,
    openedObject: verified.receipt.objectIdentity,
    pathBrokerReceiptSha256: verified.receipt.receiptSha256,
    policyVersion: input.policy.version,
    phase: "opened_resource"
  }));

  return {
    resultType: "complete",
    uri: resource.canonicalUri,
    mimeType: rootPolicy.mimeType,
    blob: Buffer.from(verified.bytes).toString("base64"),
    _meta: {
      aidefend_path_broker_receipt_sha256: verified.receipt.receiptSha256,
      aidefend_resource_policy_version: input.policy.version
    }
  };
}

Operational notes: Keep the alias-to-root-ID map, MIME type, byte cap, adapter digest, and policy version in signed configuration. Map every authorization denial, broker denial, and unsupported platform profile to one non-enumerating MCP error while retaining detailed operator evidence. If the canonical broker or receipt verifier is unavailable, disable the local-file resource class and return ERROR; never fall back to realpath, raw prefix checks, or reopening a path by name. For externally fetched resources, apply the SSRF-safe fetch pattern in AID-H-034.003 and network isolation in AID-I-002.

Verify safely

A separately credentialed verifier tests traversal and double encoding, separator/Unicode/case variants, wrong root alias, scheme, tenant, object, and action, symlink/junction/reparse, hard-link profiles, mount and rename/replacement races, special files, oversized or changing content, stale/mismatched receipts, direct-handler bypass, and authorization, broker, signature, or evidence-store outages. Reconcile every published local-resource adapter with an AID-H-018.009 receipt; this guidance PASS covers MCP resource semantics only and cannot substitute for the broker control's independent PASS.

AID-H-034.005-G004

Constrain server-generated prompts, prompt arguments, resource text, and completion inputs so they cannot smuggle privileged instructions or unauthorized values.
Concept:

Prompt and completion surfaces can carry model-visible instructions. The MCP server should validate prompt arguments, restrict completion choices to authorized values, and avoid embedding hidden policy overrides in generated prompt text.

Example prompt argument validator
// file: src/prompts/promptGate.js
    const allowedPromptNames = new Set(["summarize_ticket", "draft_customer_reply"]);

    export function validatePromptRequest({ auth, name, args }) {
      if (!allowedPromptNames.has(name)) {
        throw new Error("Unknown prompt");
      }

      if (name === "summarize_ticket") {
        if (!auth.scopes.includes("tickets:read")) {
          throw new Error("Missing tickets:read scope");
        }
        if (!/^TICKET-[0-9]{5,12}$/.test(args.ticket_id)) {
          throw new Error("Invalid ticket_id");
        }
      }

      return true;
    }

    export async function completeTicketArgument({ auth, argument, db, budget }) {
      await budget.consume({
        subject: auth.subject,
        tenantId: auth.activeTenantId,
        feature: "completion",
        costUnits: 1
      });

      const rows = await db.query(
        "select ticket_id from tickets where tenant_id = ? and ticket_id like ? order by updated_at desc limit 100",
        [auth.activeTenantId, String(argument.value || "") + "%"]
      );

      return {
        completion: {
          values: rows.map(row => row.ticket_id).slice(0, 100),
          total: Math.min(rows.length, 100),
          hasMore: rows.length >= 100
        }
      };
    }
    

Operational notes: If the server supports completion or autocomplete flows for prompt arguments, only return values the current user may see, cap each response at 100 suggestions, and charge completion requests against per-principal budgets. Do not use completion as an enumeration oracle for projects, tenants, customer names, files, or tickets.

Verify safely

A separately credentialed verifier replays unknown names/args, extra fields, delimiter/tag/instruction/Unicode injection, forged identity/tenant, wildcard and enumeration, cross-object completion, oversized text/candidates and dependency-outage fixtures.

AID-H-034.005-G005

For form-mode elicitation, never request sensitive information and validate returned data against the requested schema.
Concept:

Form-mode elicitation sends user-provided structured data through the MCP client. It is appropriate for non-secret data such as selecting an option, confirming a non-sensitive preference, or providing ordinary profile information. It must not be used for passwords, API keys, access tokens, payment credentials, or other secrets.

Example form-mode guard
// File: src/elicitation/formModePolicy.js
import crypto from "node:crypto";
import Ajv2020 from "ajv/dist/2020.js";

const ajv = new Ajv2020({ strict: true, allErrors: true, coerceTypes: false, useDefaults: false });
const sha256 = value => 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;
}
const canonical = value => JSON.stringify(canonicalize(value));
const ACTIONS = new Set(["accept", "decline", "cancel"]);
const PRIMITIVE_TYPES = new Set(["string", "number", "integer", "boolean", "array"]);
const PROHIBITED_CLASSES = new Set([
  "authentication_secret", "access_token", "refresh_token", "private_key",
  "payment_card", "bank_account", "government_identifier", "biometric",
  "health_record", "high_sensitivity_personal_data"
]);

function requireFormCapability(clientCapabilities) {
  const form = clientCapabilities?.elicitation?.form;
  if (!form || typeof form !== "object" || Array.isArray(form)) {
    const error = new Error("missing_required_client_capability:elicitation.form");
    error.code = -32021;
    throw error;
  }
}

function compileReviewedForm({ formId, requestedSchema, policy }) {
  const rule = policy.forms?.[formId];
  if (!rule || rule.schemaSha256 !== sha256(canonical(requestedSchema))) {
    throw new Error("form_schema_not_bound_to_signed_policy");
  }
  const expiresAtMs = Date.parse(rule.expiresAt);
  if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now() ||
      !Number.isSafeInteger(rule.maxResponseBytes) || rule.maxResponseBytes <= 0) {
    throw new Error("form_policy_expired_or_unbounded");
  }
  if (requestedSchema.type !== "object" || !requestedSchema.properties ||
      Object.getPrototypeOf(requestedSchema.properties) !== Object.prototype) {
    throw new Error("form_schema_must_have_top_level_properties");
  }
  const fields = Object.keys(requestedSchema.properties);
  if (!fields.length || fields.length > rule.maxFields ||
      fields.some(name => !/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(name))) {
    throw new Error("form_field_population_outside_policy");
  }
  for (const name of fields) {
    const schema = requestedSchema.properties[name];
    const fieldRule = rule.fields?.[name];
    if (!schema || !PRIMITIVE_TYPES.has(schema.type) || !fieldRule ||
        PROHIBITED_CLASSES.has(fieldRule.dataClass) || !fieldRule.purpose) {
      throw new Error("unreviewed_or_sensitive_form_field:" + name);
    }
    if (schema.type === "array" &&
        (schema.items?.type !== "string" || !Array.isArray(schema.items.enum))) {
      throw new Error("form_arrays_must_be_bounded_string_enums");
    }
  }
  const validationSchema = {
    ...requestedSchema,
    additionalProperties: false,
    maxProperties: rule.maxFields
  };
  return { validate: ajv.compile(validationSchema), rule };
}

export function validateFormElicitationRequest({ request, formId, clientCapabilities, policy }) {
  requireFormCapability(clientCapabilities);
  if (request.method !== "elicitation/create" ||
      (request.params?.mode !== undefined && request.params.mode !== "form") ||
      typeof request.params.message !== "string" || !request.params.message.trim()) {
    throw new Error("invalid_form_elicitation_request");
  }
  const compiled = compileReviewedForm({
    formId, requestedSchema: request.params.requestedSchema, policy
  });
  return {
    formId,
    schemaSha256: sha256(canonical(request.params.requestedSchema)),
    policyVersion: policy.version,
    expiresAt: compiled.rule.expiresAt
  };
}

export function validateFormElicitationResponse({ response, request, formId, policy }) {
  if (!response || !ACTIONS.has(response.action)) throw new Error("invalid_elicitation_action");
  if (response.action !== "accept") {
    if (response.content !== undefined) throw new Error("non_accept_response_must_not_include_content");
    return Object.freeze({ action: response.action });
  }
  if (!response.content || Object.getPrototypeOf(response.content) !== Object.prototype) {
    throw new Error("accepted_form_requires_plain_object_content");
  }
  const { validate, rule } = compileReviewedForm({
    formId, requestedSchema: request.params.requestedSchema, policy
  });
  if (Buffer.byteLength(canonical(response.content), "utf8") > rule.maxResponseBytes) {
    throw new Error("form_response_exceeds_signed_byte_cap");
  }
  if (!validate(response.content)) {
    throw new Error("form_response_schema_mismatch:" +
      (validate.errors || []).map(error => error.keyword).join(","));
  }
  return Object.freeze({ action: "accept", content: Object.freeze({ ...response.content }) });
}

Operational notes: Each signed form rule must include a concrete expiresAt and maxResponseBytes; both are enforced again when the response returns. The client may also validate elicitation responses, but the server should validate the returned data before using it. Do not treat user identity written into a form field as authoritative.

Verify safely

A separately credentialed verifier independently inspects every schema and tests disguised sensitive names/descriptions/formats, extra/coerced/deep/oversized fields, forged identity/client, expired/replayed request, accept/decline/cancel and capability-missing fixtures.

AID-H-034.005-G006

Return MRTR InputRequiredResult values with integrity-protected requestState bound to the initiating identity, original method/name/arguments, input schema, and expiry.
Concept

For multi-round-trip input, the server returns resultType: input_required; it does not initiate an unrelated client request. The client gathers form or URL-mode input and retries the original method with inputResponses and the exact requestState. Treat both fields as untrusted and reject cross-user, cross-client, cross-tenant, cross-method, changed-argument, expired, replayed, or schema-invalid retries.

AEAD-bound URL input flow
// File: src/mrtr/urlInputBoundary.js
import crypto from "node:crypto";

const KEY = Buffer.from(process.env.MCP_REQUEST_STATE_KEY_B64, "base64");
if (KEY.length !== 32) throw new Error("MCP_REQUEST_STATE_KEY_B64 must decode to 32 bytes");

const b64 = value => Buffer.from(value).toString("base64url");
const unb64 = value => Buffer.from(value, "base64url");
const sha256 = value => crypto.createHash("sha256").update(value).digest("hex");

function seal(payload) {
  const iv = crypto.randomBytes(12);
  const cipher = crypto.createCipheriv("aes-256-gcm", KEY, iv);
  cipher.setAAD(Buffer.from("aidefend:mcp:request-state"));
  const plaintext = Buffer.from(JSON.stringify(payload));
  const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
  return [b64(iv), b64(ciphertext), b64(cipher.getAuthTag())].join(".");
}

function open(token) {
  const [iv, ciphertext, tag] = token.split(".").map(unb64);
  const decipher = crypto.createDecipheriv("aes-256-gcm", KEY, iv);
  decipher.setAAD(Buffer.from("aidefend:mcp:request-state"));
  decipher.setAuthTag(tag);
  return JSON.parse(Buffer.concat([
    decipher.update(ciphertext), decipher.final()
  ]).toString("utf8"));
}

export function requireExternalAuthorization({
  auth, clientId, method, name, canonicalArguments, authorizationUrl, policy
}) {
  const flowId = crypto.randomUUID();
  const requestState = seal({
    v: 1,
    flowId,
    subjectHash: sha256(auth.subject),
    tenantId: auth.activeTenantId,
    clientId,
    method,
    name,
    argumentsHash: sha256(canonicalArguments),
    expiresAt: Date.now() + policy.requestStateTtlMs,
    nonce: crypto.randomBytes(16).toString("base64url")
  });
  return {
    resultType: "input_required",
    inputRequests: {
      authorization: {
        method: "elicitation/create",
        params: {
          mode: "url",
          message: "Complete authorization in the browser.",
          url: authorizationUrl
        }
      }
    },
    requestState
  };
}

export async function verifyAuthorizationRetry({
  requestState, inputResponses, auth, clientId, method, name,
  canonicalArguments, flowStore
}) {
  const state = open(requestState);
  if (
    state.expiresAt <= Date.now() ||
    state.subjectHash !== sha256(auth.subject) ||
    state.tenantId !== auth.activeTenantId ||
    state.clientId !== clientId ||
    state.method !== method ||
    state.name !== name ||
    state.argumentsHash !== sha256(canonicalArguments)
  ) throw new Error("mrtr_state_binding_failed");

  const response = inputResponses?.authorization;
  if (!response || response.action !== "accept") throw new Error("authorization_not_accepted");
  const flow = await flowStore.consumeOnce(state.flowId);
  if (!flow || flow.subjectHash !== state.subjectHash ||
      flow.tenantId !== state.tenantId || flow.clientId !== state.clientId) {
    throw new Error("authorization_flow_not_completed");
  }
  return flow.credentialReference;
}

Action and evidence: keep form-mode requests non-sensitive and validate accepted content against the exact requested JSON Schema. For URL mode, send only a non-preauthenticated HTTPS URL, authenticate the browser callback independently, bind it to the same flow/identity, store third-party credentials server-side, and consume the flow and nonce once. Preserve state/key version, request and argument digests, input-key/schema digest, callback identity decision, expiry/replay decision, credential-reference digest, and trace ID; never log raw state, raw responses, URLs containing secrets, or credentials.

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.005: MCP Server Output, Resource URI & Elicitation Boundary Controls | AIDEFEND

Loading interactive view…

A Practical Knowledge Base for AI Security Defenses

Version