AID-H-028.007: MCP input_required Multi-Round-Trip Request Integrity
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.
- 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.
AID-H-028.007-G001
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
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
- Ajv
- Pydantic
- Model Context Protocol TypeScript SDK v2 (@modelcontextprotocol/server and @modelcontextprotocol/client)
- Model Context Protocol Python SDK (mcp)
Threat mappings
MITRE ATLAS
- AML.T0012 Valid Accounts (misbound input responses can exercise another principal's authority)
- AML.T0073 Impersonation (replayed requestState can impersonate the active user)
MAESTRO
- Agent Identity Attack (L7) (request-state replay or principal misbinding)
- Integration Risks (L7) (server-initiated input crosses the client trust boundary)
OWASP LLM Top 10 2026
- N/A
OWASP ML Top 10 2023
- N/A
OWASP Top 10 for Agentic Applications 2026
- ASI03:2026 Identity and Privilege Abuse (misbound input responses exercise the wrong principal)
NIST Adversarial Machine Learning 2025
- N/A
Cisco Integrated AI Security and Safety Framework
- AISubtech-2.1.1 Execution Approval Bypass
- AISubtech-4.2.2 Session Boundary Violation
- AISubtech-4.3.1 Schema Inconsistencies
- AISubtech-4.3.4 Replay Exploitation
- AITech-2.1 Excessive Agency
- AITech-4.2 Context Boundary Attacks
- AITech-4.3 Protocol Manipulation
- AITech-8.2 Data Exfiltration / Exposure
- AITech-8.3 Information Disclosure
Google Secure AI Framework 2.0 - Risks
- SDD: Sensitive Data Disclosure (cross-request state contamination leaks data)
- RA: Rogue Actions (misbound input consent can authorize unintended actions)
Databricks AI Security Framework 3.0
- Agents - Tools MCP Client 13.34: Session and State Management Failures
- Agents - Core 13.9: Identity Spoofing & Impersonation (request-state replay enables impersonation)