A confirmation step is only worth something if nobody but the human can produce the "yes". That sounds obvious, and it's easy to get wrong once the "yes" travels over a protocol.
I built a small refund desk as an MCP server on ASP.NET Core with the official C# SDK (ModelContextProtocol 2.3.0). The issue_refund tool doesn't trust the model to confirm anything. It pauses and asks the person through MCP elicitation: a form with a checkbox, a reason, and for amounts over 100 EUR a field where you type the amount back. Since the 2026-07-28 spec this works on stateless servers too, through Multi Round-Trip Requests (MRTR).
Then I switched sides and tried to fake that "yes". These are the six attacks I ran, what happened, and what the server does now. The code is cut down to the shape, and the cut parts are marked.
Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. The complete, working, tested version of this MCP refund desk is inside, with full source and an in-depth PDF guide. Join Tech Skill Builder
First, the flow I was attacking
On a stateless server the tool can't wait for an answer. It ends the call with input_required, a form to show, and an opaque requestState. The client shows the form, then calls the same tool again with the same arguments, the answers in inputResponses, and requestState echoed back. That second call can land on any instance.
In C# a tool asks by throwing. The shape of my tool:
[McpServerTool(Name = "issue_refund", Destructive = true, Idempotent = false)]
public async Task<CallToolResult> IssueRefund(McpServer server, RequestContext<CallToolRequestParams> context,
ClaimsPrincipal? user, string orderId, decimal amount, CancellationToken cancellationToken)
{
var ix = Begin(context, server, user); // reads and verifies requestState on a retry (attacks 1 and 2)
// ... order checks, the refusal for clients that can't show forms, building the form (omitted)
if (ix.IsFirstCall)
throw ix.Ask("confirm", new FormInput(form)); // becomes input_required + a sealed requestState
var outcome = ix.Read("confirm", form); // validated answer (attacks 3 to 5)
// ... declined / cancelled / re-ask, then execute exactly once
// (full implementation in the complete project)
}
Notice that both halves of the "yes" come from the client: the answers and the state. That's where every attack below aims.
Attack 1: Edit the state, keep the approval
What I did: approved a 25.00 EUR refund, then sent the retry with the same requestState but amount: 64.90 in the arguments.
What happened at first: my first spike kept plain JSON in requestState, like most samples do. The tool read the confirmation and applied it to the new amount. The MRTR spec says servers MUST treat requestState as attacker-controlled input, and this is why.
What stops it now: the state is sealed with ASP.NET Core Data Protection (authenticated encryption) and bound to the exact call it belongs to:
public sealed record RequestStateEnvelope
{
public required string InteractionId { get; init; }
public required string Tool { get; init; }
public required string ArgumentsDigest { get; init; } // SHA-256 over the canonical arguments
public required string Subject { get; init; } // the user who started the call
public required int Round { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
// ... version, per-round nonce, form fingerprints, carried answers
// (JSON names and the full record in the complete project)
}
The digest is computed over sorted keys with normalized numbers, so 25.00 and 25 match but 64.90 doesn't. The retry now fails with -32602: requestState does not match these arguments before any answer is read. Flipping one character of the sealed text gets requestState failed verification, and a state older than ten minutes gets requestState expired.
Attack 2: Replay someone else's approval
What I did: took Alice's valid, accepted state and sent it as Bob.
What stops it: the Subject binding. On every retry the server unseals and checks all the bindings at once:
var check = sealer.Unseal(requestState, tool, digest, subject);
if (!check.IsValid)
throw new McpProtocolException(RejectionMessage(check.Status), McpErrorCode.InvalidParams);
Bob gets requestState was issued to a different user. A related trick, sending inputResponses with no state at all, is refused too, because nothing proves which question those answers belong to.
One operational note: every instance that might receive a retry needs the same Data Protection key ring and application name. Otherwise a perfectly honest retry fails verification on the other box.
Attack 3: Answer the form like a raw JSON client
Real MCP clients render the form nicely. An attacker doesn't need a real client. I sent:
-
"action": "ACCEPT"in capitals -
acceptwithconfirm: false -
acceptwith an extraamount: 9999field inside the form content
What I learned: the SDK's ElicitResult.IsAccepted is case-insensitive, so "ACCEPT" passes it. Don't use it as a consent check.
What stops it: an ElicitResponseValidator that checks the answer against the same FormSpec that produced the schema:
- only lowercase
accept,declineandcancelcount as actions; anything else is treated as cancel - required fields, types, lengths, ranges and enum values are checked on the server
- fields the server didn't ask for are ignored and reported, never bound
- an accepted form with
confirm: falseis not consent, so the tool asks again with a hint
Because form and validator share one spec, they can't drift apart when someone adds a field.
Attack 4: Click Accept on an empty form
This one needed no hostile client at all, which makes it the scariest.
What I did: gave the confirm checkbox default: true (it looked friendly), then clicked Accept without touching anything.
What happened: the SDK client fills schema defaults into an accepted response for fields the user left empty. That's useful for a "close reason" that defaults to completed. On a consent checkbox it turns an empty click into consent on the wire.
What stops it: consent fields are required and have no default, and a linter checks every form the server can produce when the app starts:
FieldSpec.Boolean("confirm", "Issue this refund", required: true, consentBearing: true)
// at startup: lint every form, throw if any finding is an error
// (the 13 rules are in the complete project)
The demo run of the linter on a deliberately bad form:
lint: EL002 Error [confirm] Consent-bearing field has a default. The SDK client fills defaults into an accepted response, so an empty 'accept' would read as consent.
lint: EL011 Error [confirm] Consent-bearing field must be required.
lint: EL001 Error [cardPin] Looks like a secret or credential. Form mode MUST NOT collect these; use URL mode.
Attack 5: Approve something that no longer exists
What I did: opened the form ("120.00 EUR still refundable"), refunded 10 EUR as a colleague in another session, then accepted the original form.
What stops it: when the answer comes back, the server rebuilds the form from current data and compares its fingerprint with the one sealed in the state. If they differ, the user agreed to a form that's out of date, so the tool asks again and says "the form changed since it was shown".
For bigger refunds the typed-back amount adds one more barrier. Ticking a box and typing "150" are different acts, and a wrong number gets the form back. The server also stops after 4 rounds with a normal tool result, so the assistant has something to explain instead of an exception.
Attack 6: Get a secret into the conversation
Changing a payout account needs an IBAN. The tempting design is a form field called iban. The elicitation spec says form mode MUST NOT ask for passwords, API keys, tokens or payment credentials, and the linter above flags fields like that (EL001).
So change_payout_account uses URL mode. The tool asks the client to open a page on my server, the client shows the full host and asks before opening it, and the retry carries accept with no content. The link has a random reference and no personal data, and the page checks that whoever opens it is the user who started the tool call. When Bob opened Alice's link he got a 403. In the demo, a recorder on the MCP connection captured the whole flow, and none of it contained the IBAN.
Two SDK details: the server doesn't check the mode you send against what the client declared, and a client with a handler registered will receive URL requests it never declared. So the server checks capabilities before asking, and a form-only client gets a plain "needs a client that supports URL-mode elicitation" instead of a form fallback.
The attack I'm not answering here
All six are about forging or bending the "yes". There's one more that doesn't need forgery: the honest double submit. Alice clicks Accept, the proxy times out, her client retries with the same valid state, and the two requests land on different instances a few milliseconds apart. A sealed, bound, unexpired state is still not a single-use state. Making one approval move money exactly once across stateless instances, including after a crash halfway through the payment call, is a chapter of its own in the PDF that comes with the project.
A red-team checklist for your own confirmation tool
- [ ]
requestStateis sealed, not plain JSON or base64 - [ ] It's bound to the tool, the canonical arguments, the user and an expiry
- [ ] Answers with no state are rejected
- [ ] Actions are compared exactly;
IsAcceptedis not your consent check - [ ] Unknown fields are ignored, never bound
- [ ] Consent fields are required and have no default, enforced by a startup lint
- [ ] The form is rebuilt and compared before the answer is used
- [ ] Secrets go through URL mode with a same-user check, never a form
- [ ] Clients that can't show forms get a refusal for destructive tools, not a silent fallback
What I tested
The project has 285 passing tests (236 unit, 49 WebApplicationFactory integration tests) and needs no API key. A 10-scene CLI demo runs every attack above against a real Kestrel host with a raw JSON-RPC client, plus the two-instance scene. The SDK behaviors I mention (case-insensitive IsAccepted, default filling, URL requests reaching clients that didn't declare URL mode) came from spikes against 2.3.0 and are pinned as tests. What I haven't done is run it against every MCP client on the market, so test yours against the checklist.
Get the complete project
Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. This project is already inside: the full .NET 10 solution with the sealed and bound requestState, the form spec, validator and 13-rule linter, five MCP tools including the two-round callback and the URL-mode payout page, the fallbacks for older clients, the client-side elicitation gate, the minimal API, the demo and all 285 tests, plus a PDF guide that goes much deeper than this post and answers the single-use question in full, with code. You also get the other complete, tested .NET + AI projects in the membership, with new ones added regularly.
Join Tech Skill Builder for $5/month
Which of these six would your current confirmation step let through?
Top comments (1)
Testing the confirmation from the attacker's side is the right way to find these gaps. The pattern we'd want is for the server to store each pending refund with a random ID, the exact amount, an expiry and a used flag, so a "yes" can only be redeemed once and only for the action it was issued for. That blocks replays and also a model that tries to resubmit the call with a different amount. Which of your six attacks was the hardest to close once the server became stateless with MRTR?