A June 2026 audit of 32,820 MCP servers and 517,973 tools found that 43% expose tools that destroy data or execute commands, and that 96.4% of MCP tools don't warn the agent about destructive behaviour at all. Stack five servers into one agent config and the odds that at least one dangerous tool is loaded reach 94.1%.
Then, on 2026-07-28, the protocol deleted the one mechanism those servers had to stop and ask a human. Server-initiated requests — elicitation/create, sampling/createMessage, roots/list — are gone. There is no back-channel. A tool can no longer pause mid-execution and push a question down an open socket, because there is no open socket to push it down.
The replacement is better, and it is the primitive an infrastructure MCP server always wanted: Multi Round-Trip Requests (MRTR). Your delete_service tool returns resultType: "input_required" with the question attached, the call ends, the client asks the human, and the client comes back with a new, self-contained request carrying the answer. No sticky sessions, no prepare/commit dance you invented yourself, no held connection to lose.
Here is the whole gate, on the wire, then the parts the spec makes your problem.
The gate, in one round trip
A delete_service tool on a platform MCP server. The agent calls it; the human confirms it; the service goes away. Four messages.
1. The agent calls the tool. Every request now carries its own protocol version and client capabilities in _meta, because the initialize handshake was removed too:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "delete_service",
"arguments": { "serviceId": "svc_8f21c", "name": "api-prod" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": { "elicitation": {} }
}
}
}2. The server refuses to act, and says what it needs. This is the new shape — an InputRequiredResult:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "input_required",
"inputRequests": {
"confirm_delete": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Delete api-prod (svc_8f21c)? This removes 3 running instances, the DNS record api.example.com, and detaches 1 volume (48 GB). It cannot be undone. Type the service name to confirm.",
"requestedSchema": {
"type": "object",
"properties": {
"serviceName": { "type": "string", "title": "Service name" },
"deleteVolumes": {
"type": "boolean",
"title": "Also destroy the attached volume",
"default": false
}
},
"required": ["serviceName"]
}
}
}
},
"requestState": "v1.KGFlYWQtc2VhbGVkLXRpY2tldCk"
}
}Two fields carry everything. inputRequests is a map of server-assigned keys to the requests the client must fulfil — an ElicitRequest, CreateMessageRequest, or ListRootsRequest. requestState is an opaque blob that clients MUST NOT inspect, parse, or modify. The server must include at least one of the two.
3. The client asks the human, then retries the original call. Not a continuation — a brand-new request, and the spec requires a different JSON-RPC id:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "delete_service",
"arguments": { "serviceId": "svc_8f21c", "name": "api-prod" },
"inputResponses": {
"confirm_delete": {
"action": "accept",
"content": { "serviceName": "api-prod", "deleteVolumes": false }
}
},
"requestState": "v1.KGFlYWQtc2VhbGVkLXRpY2tldCk"
}
}4. The server completes. Every result now carries a required resultType; ordinary results say "complete":
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "complete",
"content": [
{ "type": "text", "text": "Deleted api-prod (svc_8f21c). Volume vol_31ab retained." }
],
"isError": false
}
}That is the entire mechanism. Any instance behind a round-robin load balancer can serve message 4 without having seen message 2, because everything it needs arrived in the payload.
One compatibility note worth writing into your client code today: results from earlier-protocol servers omit resultType entirely, and clients MUST treat a missing field as "complete". Get that default backwards and every legacy tool call looks like a pending confirmation.
What actually died, and why a socket couldn't survive
The 2026-07-28 revision is the largest breaking change since MCP launched. The pieces relevant to a confirmation gate:
- Server-initiated requests are removed. Servers MUST now send
roots/list,sampling/createMessage, andelicitation/createvia MRTR. The old pattern is not deprecated — it is unsupported. initialize/notifications/initializedare gone, along with theMcp-Session-Idheader. Capabilities and protocol version ride in_metaon every request; a newserver/discoverRPC covers up-front negotiation for clients that want it.- SSE resumability is gone. No
Last-Event-ID, no event IDs, no redelivery. A broken response stream loses the in-flight request, and the client MUST re-issue it as a new request with a new ID. notifications/elicitation/completeandelicitationId— both added only in 2025-11-25 — were removed. A server that needs to correlate an out-of-band interaction across retries now encodes its own identifier insiderequestState.- Roots, Sampling, and Logging are formally deprecated under a new twelve-month deprecation policy, so building a gate on Sampling is building on a countdown.
The reason is unglamorous and correct: the old model required the same server process that started a tool call to still be alive, still be connected, and still be the one the load balancer picks. That meant sticky sessions or shared session storage for every deployment of every MCP server on earth — a tax paid by all so that a minority of tools could ask a question. MRTR moves the cost to the tools that actually need it, in the form of a token they mint themselves.
Which deploy operations deserve a gate
The temptation is to gate everything with "delete" in the name. That is the wrong axis. The right one is irreversibility × blast radius — gate what you cannot undo and what a human would notice, and leave the rest alone.
| Tool | Blast radius | Reversible? | Gate? | What the prompt asks for |
|---|---|---|---|---|
list_services, get_logs, get_metrics | none | n/a | No | — |
trigger_deploy (preview / staging) | one ephemeral env | yes | No | — |
trigger_deploy (production) | live traffic | yes, via rollback | No — but annotate the tool as non-read-only | — |
set_env_var (production) | restart, possible outage | yes, though secrets churn | Yes | Show the key names changing and that a restart follows |
scale up | cost only | yes | Only above a spend cap | Confirm the projected monthly delta |
scale_to_zero (production) | full outage of that service | yes | Yes | Type the service name |
rollback | swaps the live release | yes | Yes | Show target release SHA + age, confirm |
delete_service | config, DNS, instances | no | Yes | Type the name; separate opt-in for volumes |
delete_volume | tenant data | no | Yes | Type the volume ID; confirm no snapshot exists |
The two failure modes sit on either side of that table. Gate nothing and you are part of the 96.4%. Gate everything and you train the operator to click accept without reading — and you train the agent to route around the gated tool toward whatever unguarded path reaches the same state, which on a platform is usually a raw apply_manifest or a shell tool.
Notice the middle rows are where the judgement lives. scale_to_zero has no scary verb and takes production offline. trigger_deploy on prod sounds terrifying and is a routine, reversible action your team does forty times a day; gate it and you have built a nuisance, not a control.
The state the protocol will not hold for you
requestState is where a naive implementation gets exploited. The spec is blunt: servers MUST treat it as attacker-controlled input, MUST protect its integrity with an HMAC or AEAD when it influences authorization or business logic, and MUST reject state that fails verification.
A ticket that carries what a delete actually needs:
{
"v": 1,
"sub": "user_5f2a",
"op": "tools/call:delete_service",
"argsDigest": "sha256:1f9c4d…",
"nonce": "01K9ZC7T4Q3F8N2XW",
"iat": 1786312800,
"exp": 1786313100
}Sealed with AEAD under a server-held key, that is roughly 200 bytes on the wire and readable only by you. The spec's replay guidance maps directly onto those fields: bind the authenticated principal, a short TTL, and an identifier for the originating request — the method name plus a digest of its salient parameters.
The argsDigest is the field people skip, and it is the one that matters. Without it, a client that obtained a valid confirmation ticket for svc_staging can present it on a retry whose arguments say svc_prod. The signature verifies. The principal matches. The TTL is fine. And your server deletes a production service against a confirmation dialog that named a staging one.
Then read the spec's own warning carefully: those measures bound the replay window and prevent cross-user reuse, but do not guarantee single-use. For a destructive operation, "at most once" is your job:
async function deleteService(args, ctx) {
const state = ctx.requestState ? openSealed(ctx.requestState) : null;
// First pass: no ticket. Ask, and mint one.
if (!state) {
return inputRequired({
inputRequests: { confirm_delete: elicitDeleteForm(args) },
requestState: seal({
v: 1,
sub: ctx.principal,
op: "tools/call:delete_service",
argsDigest: sha256(canonicalize(args)),
nonce: ulid(),
iat: now(),
exp: now() + 300,
}),
});
}
// Second pass: the ticket must describe *this* call, from *this* caller.
requireEqual(state.sub, ctx.principal, "principal mismatch");
requireEqual(state.op, "tools/call:delete_service", "wrong operation");
requireEqual(state.argsDigest, sha256(canonicalize(args)), "arguments changed after confirmation");
require(state.exp > now(), "confirmation expired");
// And the human must actually have said yes, in the exact words asked for.
const answer = ctx.inputResponses?.confirm_delete;
if (answer?.action !== "accept" || answer.content.serviceName !== args.name) {
return inputRequired({
inputRequests: { confirm_delete: elicitDeleteForm(args, "Name did not match.") },
requestState: ctx.requestState,
});
}
// Single-use: unique index on nonce. Claim BEFORE acting, then act idempotently.
const claimed = await tickets.claim({ nonce: state.nonce, expiresAt: state.exp });
if (!claimed) return text("That confirmation was already used.");
return performDelete(args, { confirmedBy: ctx.principal, ticket: state.nonce });
}Three details in there earn their lines. Claim before acting, so a crash between the two leaves a burned ticket rather than a second delete. Key the delete itself on the nonce, so a client that re-issues after a dropped stream — which the spec now explicitly requires it to do — converges instead of double-firing. And when the typed name doesn't match, return another input_required rather than an error: the spec says a server missing information it needs SHOULD re-ask, and it lets a fat-fingered operator correct without the agent inventing a new plan.
The gotchas that will bite you
The client may not be able to ask. Servers MUST NOT send an inputRequest the client hasn't declared support for. With initialize gone, you read io.modelcontextprotocol/clientCapabilities from _meta on every call. If elicitation is absent you have two honest options: refuse the tool (the revision allocated MissingRequiredClientCapability, renumbered to -32021), or fall back to an explicit confirm: "api-prod" tool argument. Be clear-eyed that the fallback is weaker — the model can fill that argument in itself, so it proves intent, not human consent. For delete_volume, refuse.
Nobody has to come back. Servers MUST NOT assume clients will fulfil the requests or retry at all. Never hold a lock, a reservation, or a half-applied change waiting for a retry that may never arrive. The TTL in your ticket is the whole cleanup story, which is precisely why the pending operation has to be a signed token rather than a row in a pending_operations table you then have to garbage-collect.
MRTR gates the start, not the middle. Once the delete or deploy is running, MRTR is finished — it is a pre-execution gate. Mid-flight interaction moved to the tasks extension (io.modelcontextprotocol/tasks), pulled out of core in this same revision, with polling via tasks/get and a new tasks/update for client-to-server input.
You are building on betas. SDKs for 2026-07-28 shipped in beta across Python (mcp[cli]==2.0.0b1), TypeScript v2 (now split into @modelcontextprotocol/server and @modelcontextprotocol/client), Go, and C#. The pattern is stable; the APIs around it are not yet.
"The agent asked first" is the audit line that matters
The reason to do any of this isn't the protocol. It's that agent-operated infrastructure has to be defensible to whoever owns the machines, and the defensible artifact is a record showing that a specific human saw a specific sentence and typed a specific answer before anything was destroyed.
Log both round trips as one operation:
- the authenticated principal and the client identity from
io.modelcontextprotocol/clientInfo - the tool, its canonicalized arguments, and the
argsDigest - the prompt text verbatim — what the human was actually shown, not a template ID that has since changed
- the
action(accept/decline/cancel) and the typed content - the ticket nonce, and the timestamps of both requests
The revision also documented OpenTelemetry trace-context conventions for _meta (traceparent, tracestate, baggage), which is what stitches two independent JSON-RPC IDs back into one span in your tracing backend. Use it — otherwise the confirmation and the deletion look like unrelated events forever.
And log the declines. The most valuable line in an agent-ops audit trail isn't the delete that happened. It's the delete a human said no to, with the agent's arguments preserved next to it, showing exactly what your platform was one keystroke away from doing.
Going stateless cost MCP a channel and handed back a primitive. A destructive tool can now demand explicit human confirmation without holding a socket, without sticky load balancing, and without every platform inventing an incompatible two-call prepare/commit API of its own. The protocol carries the round trip; you carry the ticket, the digest, and the once-only guarantee. That is a fair split — and considerably better than the 96.4% of tools that currently ask nothing at all.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with agents as first-class operators and every destructive action gated and logged. Star the repo on GitHub or deploy your first app today.
Sources
- Model Context Protocol — Key Changes, 2026-07-28 specification
- Model Context Protocol — Multi Round-Trip Requests pattern
- Model Context Protocol Blog — The 2026-07-28 Specification
- Model Context Protocol Blog — Beta SDKs for the 2026-07-28 Spec Release Candidate
- PolicyLayer — MCP Security Audit, June 2026
- Appwrite — What's new in the MCP 2026-07-28 specification
- 4sysops — MCP 2026-07-28: stateless, multi-round-trip, routable headers, authorization hardening
- Vindler — MCP Went Stateless: What the 2026-07-28 Spec Breaks
- Render Docs — MCP Server
- Pydantic — MCP Python SDK v2 beta



