AID-H-028.007: MCP input_required Multi-Round-Trip Request Integrity

Version 1.20261015 · Actionable sub-technique

Constrain every MCP InputRequiredResult and retry to one active client request. Validate named inputRequests, optional opaque requestState, user consent, and inputResponses; bind method, server, user, tenant, schema, and single-use state so a server cannot prompt out of band or resume an unrelated request.

Pillars: app

Lifecycle: building, operation

Scope boundary

Owns client-side binding of each InputRequiredResult and retry to one active request, verified server, user, tenant, schema, single-use state, visible consent flow, and validated input response. It does not construct or authorize the server-side input request.

AID-H-034.005: AID-H-028.007 validates and presents a server's multi-round-trip input request at the client; AID-H-034.005 constructs, binds, and revalidates that request and response at the server. Both sides must bind the same original operation without creating an out-of-band session.

AID-H-028.008: AID-H-028.007 owns user-mediated input_required multi-round-trip requests; AID-H-028.008 isolates the deprecated sampling/createMessage capability as an explicit exception. An input request that continues an active client operation is not permission for a server to initiate model sampling.

Implementation guidance

AID-H-028.007-G001

Bind every InputRequiredResult and inputResponses retry to one live client request, verified server, method/name, user, tenant, schema, and single-use requestState.
Concept

Multi Round-Trip Requests keep server-to-client input inside an active client request. The authoritative wire value is resultType: input_required. The result contains at least one of inputRequests or opaque requestState; either may be absent. A retry uses a new JSON-RPC ID and carries inputResponses and/or exact state on the original method.

Fail-closed MRTR gate
// File: src/mrtr/inputRequiredGate.js
import crypto from "node:crypto";
import Ajv2020 from "ajv/dist/2020.js";

const ajv = new Ajv2020({ strict: true, allErrors: true, coerceTypes: false });
const active = new Map();
const hash = value => crypto.createHash("sha256")
  .update(typeof value === "string" ? value : JSON.stringify(value)).digest("hex");
const plain = value => value && Object.getPrototypeOf(value) === Object.prototype;
const requestKey = value => typeof value + ":" + String(value);
const ACTIONS = new Set(["accept", "decline", "cancel"]);

function hasExternalRef(value) {
  if (!value || typeof value !== "object") return false;
  if (typeof value.$ref === "string" && !value.$ref.startsWith("#")) return true;
  return Object.values(value).some(hasExternalRef);
}

export function registerActiveRequest(context) {
  if (!["string", "number"].includes(typeof context.requestId)) {
    throw new Error("invalid_jsonrpc_request_id");
  }
  for (const key of ["requestId", "serverOrigin", "subject", "tenantId", "method", "params"]) {
    if (context[key] === undefined || context[key] === null) {
      throw new Error("active_request_binding_missing:" + key);
    }
  }
  if (!Number.isSafeInteger(context.maximumRoundTripMs) ||
      context.maximumRoundTripMs <= 0) throw new Error("invalid_mrtr_lifetime");
  if (!Number.isSafeInteger(context.maximumRequestStateBytes) ||
      context.maximumRequestStateBytes <= 0) throw new Error("invalid_requestState_policy");
  active.set(requestKey(context.requestId), {
    ...context,
    paramsDigest: hash(context.params),
    expiresAt: Date.now() + context.maximumRoundTripMs,
    maximumRequestStateBytes: context.maximumRequestStateBytes,
    used: false
  });
}

export function acceptInputRequired({ requestId, serverOrigin, result, allowDeprecatedRequest }) {
  const record = active.get(requestKey(requestId));
  if (!record || record.used || record.expiresAt <= Date.now()) {
    throw new Error("no_live_request");
  }
  if (record.serverOrigin !== serverOrigin) throw new Error("server_origin_mismatch");
  if (!plain(result) || result.resultType !== "input_required") {
    throw new Error("invalid_result_type");
  }
  const hasRequests = plain(result.inputRequests) &&
    Object.keys(result.inputRequests).length > 0;
  const hasState = typeof result.requestState === "string" &&
    result.requestState.length > 0;
  if (!hasRequests && !hasState) throw new Error("input_required_payload_empty");
  if (result.requestState !== undefined &&
      (!hasState || Buffer.byteLength(result.requestState, "utf8") > record.maximumRequestStateBytes)) {
    throw new Error("invalid_requestState");
  }

  const validators = new Map();
  for (const [key, request] of Object.entries(result.inputRequests || {})) {
    if (!/^[A-Za-z0-9._-]{1,64}$/.test(key) || !plain(request) || !plain(request.params)) {
      throw new Error("invalid_named_input_request");
    }
    if (request.method === "elicitation/create") {
      const mode = request.params.mode ?? "form";
      if (mode === "form") {
        const schema = request.params.requestedSchema;
        if (!plain(schema) || schema.type !== "object" || hasExternalRef(schema)) {
          throw new Error("invalid_form_requestedSchema");
        }
        validators.set(key, { mode, validate: ajv.compile(schema) });
      } else if (mode === "url") {
        const url = new URL(request.params.url);
        if (url.protocol !== "https:" || url.username || url.password) {
          throw new Error("unsafe_url_elicitation");
        }
        validators.set(key, { mode });
      } else throw new Error("unknown_elicitation_mode");
    } else {
      if (!["roots/list", "sampling/createMessage"].includes(request.method) ||
          !allowDeprecatedRequest?.(request.method, request)) {
        throw new Error("unapproved_deprecated_client_request");
      }
      validators.set(key, { mode: "deprecated" });
    }
  }
  record.round = {
    state: hasState ? result.requestState : undefined,
    stateSha256: hasState ? hash(result.requestState) : null,
    validators
  };
  return { inputKeys: [...validators.keys()], requestStateSha256: record.round.stateSha256 };
}

export function buildRetry({ requestId, retryId, subject, tenantId, inputResponses = {} }) {
  const record = active.get(requestKey(requestId));
  if (!record?.round || record.used || record.expiresAt <= Date.now()) {
    throw new Error("inactive_mrtr_round");
  }
  if (retryId === requestId || !["string", "number"].includes(typeof retryId)) {
    throw new Error("retry_requires_new_jsonrpc_id");
  }
  if (record.subject !== subject || record.tenantId !== tenantId ||
      hash(record.params) !== record.paramsDigest) {
    throw new Error("mrtr_identity_or_argument_mismatch");
  }
  const expected = [...record.round.validators.keys()].sort();
  if (JSON.stringify(expected) !== JSON.stringify(Object.keys(inputResponses).sort())) {
    throw new Error("input_response_population_mismatch");
  }
  for (const [key, rule] of record.round.validators) {
    const response = inputResponses[key];
    if (!plain(response) || !ACTIONS.has(response.action)) {
      throw new Error("invalid_input_response");
    }
    if (response.action !== "accept" && response.content !== undefined) {
      throw new Error("non_accept_response_must_not_have_content");
    }
    if (rule.mode === "form" && response.action === "accept" &&
        !rule.validate(response.content)) {
      throw new Error("input_response_schema_mismatch");
    }
    if (rule.mode === "url" && response.content !== undefined) {
      throw new Error("url_elicitation_response_must_not_have_content");
    }
  }
  const params = { ...record.params };
  if (expected.length) params.inputResponses = inputResponses;
  if (record.round.state !== undefined) params.requestState = record.round.state;
  record.used = true;
  return { jsonrpc: "2.0", id: retryId, method: record.method, params };
}

Action: display the verified server, original method/name, requested fields, URL destination, and effect before consent. Keep live bindings in protected short-lived storage and log only request/state/schema digests. Reject unsolicited, expired, replayed, cross-origin, cross-user, cross-tenant, changed-argument, extra/missing-key, external-reference, or schema-invalid rounds.

Independent verification

Test form mode with omitted and explicit mode, URL mode, requestState-only results, combined requests and state, a new retry ID, exact wire casing, changed arguments, replay, expiry, and unapproved roots/sampling requests against the deployed client gate.

AID-H-028.007-G002

Audit each input_required round with active-request, server, user, tenant, schema, consent, state-digest, retry, and terminal-outcome bindings.
MRTR audit boundary

Emit received, submitted, and rejected events bound to the originating request, verified server, method, user and tenant digests, request-state and input-schema digests, consent receipt, policy version, retry ID, and final status. Never log secrets, access tokens, opaque state, or complete values. Cancellation, timeout, missing consent, duplicate state, schema mismatch, server change, or sink/readback failure denies the retry. Reconcile every input-required result and retry, including cross-user replay and late or duplicate submissions.

Open-source 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-028.007: MCP input_required Multi-Round-Trip Request Integrity | AIDEFEND

Loading interactive view…

A Practical Knowledge Base for AI Security Defenses

Version