[writing]
Agents Are Speedrunning the History of RPC
When a tool call dies mid-flight, neither MCP nor A2A can tell you whether it executed, and no retry can prove it is the same request. RPC solved this in 1984. Three changes close the gap.

Part 4 of the agentic-web series. Part 1, Part 2, and Part 3 came before.
When an agent’s tool call fails, nothing in the current protocol stack can tell you whether it executed, and nothing lets the retry say “I’m the same request, don’t run me twice.” MCP made this worse on 28 July, when it went stateless and dropped its recovery machinery. A2A opened a public epic admitting the same hole five weeks earlier. Both teams are now rediscovering, issue by issue, machinery that RPC systems shipped in 1984 and fintech reinvented as the idempotency key a decade ago.
If your tools move money, merge branches, or send messages, this is a layer you own today without having agreed to. At the end are the three changes I think both specs should make. Everything before that is why.
Say your tool charges a card. Under MCP 2026-07-28 the call goes out looking like this:
POST /mcp HTTP/1.1
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: charge_card
Accept: application/json, text/event-stream
Content-Type: application/json
{"jsonrpc":"2.0","id":7,"method":"tools/call",
"params":{"name":"charge_card",
"arguments":{"amount":4000,"currency":"usd"},
"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{}}}}
The stream dies before anything comes back. You’re now holding a request you can’t classify. Either it never landed, or it landed and the card is charged and the reply evaporated, or it’s still running on a box that hasn’t noticed you left.
Look at the frame for anything that would let the server spot a second attempt. id: 7 won’t do it:
The request ID MUST NOT match the ID of any other request the sender has issued and not yet received a response for.
Unique among in-flight requests, and that’s all. It’s a demux handle for concurrent calls on a connection, which is what JSON-RPC always meant by it, and it carries no identity across attempts. Nothing else in the envelope helps either: the reserved _meta keys are progressToken, the io.modelcontextprotocol/ set, and the three OpenTelemetry trace-propagation keys. No idempotency key, no dedup token, nowhere to put one that means anything.
Somebody wrote this exact problem up in 1994:
The only way to fix this is to change the protocol to add request IDs. But since this is a standardized interface, there is no way to do this.
Jim Waldo, Geoff Wyant, Ann Wollrath and Sam Kendall, A Note on Distributed Computing, about a queue interface they made up for the argument. The system they actually autopsy later in that paper is NFS.
What 28 July changed
The stateless core got all the coverage and deserved it. Dropping initialize and Mcp-Session-Id means any request lands on any instance behind an L4 round robin, and you stop running sticky sessions just to keep a tool server alive. Real win. I’d take it.
Item nine of the major changes is the one nobody quoted:
Remove SSE stream resumability and message redelivery (the
Last-Event-IDheader and SSE event IDs) from the Streamable HTTP transport. A broken response stream loses the in-flight request; clients MUST re-issue it as a new request with a new request ID.
Before this revision a dropped stream could be resumed, if the server implemented it: reconnect with Last-Event-ID, the server could replay from that cursor, final response included. Every step was a MAY, but the recovery path existed. Now the retry is mandated and the correlation is deliberately destroyed, in the same sentence. Mutating tools/call is at-least-once with no dedup anywhere in the stack, and it fails quietly. You don’t get a duplicate error. You get a second charge.

SEP-2575 is straight about the trade, under a heading called Resumable Streams Are Removed:
Because connection drops now implicitly cancel a request, resumable SSE streams (via
Last-Event-IDreconnection) are removed. They contradict the stateless-by-default paradigm: resuming would require the server to retain per-request state across connection failures.Workloads that need durability or resumability MUST use the tasks primitive instead, which provides explicit mechanisms for fetching results after a connection drop.
That first clause is doing more work than it looks like. Closing the response stream is now defined as cancellation. Clean at the transport, since each request owns its stream. Fiction at the application, where your handler is four frames deep in somebody’s payment SDK and will finish what it started regardless. gRPC hit this years ago and says so out loud: the library “does not have a mechanism to interrupt the application-provided server handler,” so a long-running handler has to poll for its own cancellation. Nobody’s tool handler polls for anything.
And durability-via-tasks inherits the same hole at its own front door, because tasks mint their IDs server side. Hold a task ID and you can poll tasks/get forever. Lose the response that carried it and you have no way to learn the task exists. The same release removed tasks/list, reasonably, on the grounds that it can’t be scoped safely without sessions. It was also the one workaround. A client that loses a task ID now has no path back to it.
The idempotency gap itself came up while tasks were being designed and was deliberately deferred to a proposal of its own. That proposal went up last week. More on it at the end.
A2A, same wall, opposite direction
Eight epics landed against A2A on 25 June under v1.1-candidate. #1987 is [Epic] Idempotency & safe retries. Zero comments on it as of today.
If a client crashes (or the connection drops) after sending the first message but before it receives and persists the server-generated
taskId/contextId, it has no way to learn whether a task was started on its behalf
The proximate cause is one line in section 3.4.2:
Client-provided
taskIdvalues for creating new tasks is NOT supported.
The identifier that names the work gets minted on the far side, one round trip after you needed it. There was a real reason: a client-chosen ID could become a handle into another client’s task, and #1987’s acceptance criteria say the retry key they add back must not reopen that. But the fix took the retry story out with it, which is why this epic is harder than it looks and why it has no comments.
A2A does have a client-minted identifier, message_id, required on every message. It even has a section headed Idempotency, and the modal verbs are the entire story:
Send Message operations MAY be idempotent. Agents may utilize the messageId to detect duplicate messages.
MAY and may. The raw material is on the wire and the obligation isn’t, so you can’t know whether a given agent implemented dedup and can’t write a retry policy that leans on it. Push notifications are more upfront about where that leaves you: “Clients SHOULD process notifications idempotently, as duplicate deliveries may occur.”
Epic #1986 covers the streaming half: “no per-task event ordering, no cursor to resume from, no defined replay behavior on tasks/resubscribe.” And resubscribe keys on taskId rather than on a stream, so with two streams live against one task you get both streams’ events fired at you.
Two protocols, two teams, five weeks apart, same wall.
The prior art
Exactly-once delivery isn’t implementable. Over a lossy channel no finite protocol gets two parties to agree a message arrived, because the agreement needs an ack, and the ack needs an ack. Akkoyunlu, Ekanadham and Huber proved it at SOSP in 1975, where the parable is two gangs of gangsters coordinating a job rather than generals on hilltops. Jim Gray gave it the name that stuck three years later.
Fifty years old.

Exactly-once effect is implementable, and the recipe hasn’t changed. Accept at-least-once delivery, have the caller mint a key, have the receiver keep a table of executed keys and replay the stored result on a repeat. Three conditions decide whether it actually holds:
- The dedup record and the side effect commit together. Writing the key to Redis and then charging the card is the same race with more moving parts. If the effect is a third-party call, you need an outbox or that provider’s own idempotency key.
- The retention window is finite and published. The contract has to say how long a retry is honoured and what a late duplicate gets.
- The caller’s key namespace survives restart. A client that reboots and restarts its counter gets live requests eaten as duplicates, which is a miserable bug to chase.
Birrell and Nelson shipped most of that in 1984, in Implementing Remote Procedure Calls. A call identifier made of machine, process, and a monotonic sequence number; a server table keeping the highest number seen per caller and discarding anything at or below it; state discarded once retransmission stops being plausible, which they put at roughly five minutes; and a conversation identifier so a rebooted caller can’t collide with the corpse of its old self. That’s a modern idempotency-key implementation, including the restart case most of them still get wrong. The piece they don’t solve is the atomic commit, which is the one that keeps biting people forty-two years later.
They also never wrote “exactly once” or “at most once.” Their guarantee: if the call returns, the procedure ran precisely once, and if you get an exception it ran “either once or not at all” with the caller not told which. They named the ambiguity window instead of pretending they’d closed it, which is more than most retry documentation manages today.
ONC RPC, still the specification behind NFS, put it in Standards Track language. RFC 5531, unchanged from RFC 1831 in 1995:
A server may wish to remember previously granted requests from a client and not regrant them, in order to insure some degree of execute-at-most-once semantics. […] The server is not allowed to examine this ID in any other way except as a test for equality.
The XID is an idempotency key and that last clause is the opaque-key rule, still violated by everyone who parses structure out of a request ID because it looked like it had a timestamp in it.

DCE RPC did the thing I keep wishing somebody would copy. Call semantics were an operation attribute in the IDL: idempotent, broadcast, maybe, with at-most-once as the default when you declared nothing. The interface stated the retry contract and the runtime enforced it. You couldn’t ship a mutating call that a stub would cheerfully replay, because the safe behaviour was what you got by saying nothing.
MCP is closer to this than I gave it credit for, and the distance is the interesting part. ToolAnnotations has carried an idempotentHint all along. But the schema says annotations are hints, not guaranteed to describe behaviour faithfully, and tells clients not to trust them from unknown servers. So it’s an assertion by the one party with an incentive to make it, verified by nobody, and it says nothing about what the server should do when the same call shows up twice. DCE’s idempotent was enforced. MCP’s is a comment.
gRPC declined on purpose
From PROTOCOL-HTTP2.md:
Unless explicitly defined to be, gRPC Calls are not assumed to be idempotent. Specifically:
- Calls that cannot be proven to have started will not be retried.
- There is no mechanism for duplicate suppression as it is not necessary.
- Calls that are marked as idempotent may be sent multiple times.
End-to-end argument, applied correctly. Duplicate suppression needs application semantics the transport doesn’t have, so the transport shouldn’t pretend. Even gRPC’s hedging, which fires the same call at several backends on purpose, fits: it’s only meant to be enabled for methods already safe to run twice.
The catch is that “the application” meant one team’s code in one repo. When the caller is a model picking tools at runtime and the callee is another company’s agent behind an agent card, there’s no shared application to push the problem down into. The end-to-end argument tells you where the mechanism belongs. In this topology that place has no owner.

The standardisation attempt tells you the rest. draft-ietf-httpapi-idempotency-key-header got to -07 in October 2025 and expired on 18 April 2026 without becoming an RFC. Its own implementation status section lists nine organisations shipping that exact header, Stripe and Adyen and WorldPay among them, then ten more shipping the same idea under names they picked themselves: PayPal-Request-Id, I-Twilio-Idempotency-Token, BBVA’s X-Unique-Transaction-ID. Square and Google Standard Payments put it in the body instead.
Nineteen independent implementations of one mechanism. Six years of drafts. No RFC.
Deadlines, same story
grep -inE 'deadline|expire|expiry|ttl' specification/a2a.proto gives you nothing. Issue #857 has been asking since 10 July 2025:
A deadline is an absolute timestamp by which the server must complete the task or stream of tasks.
It’s assigned. It’s not on the v1.1 candidate list.
A gRPC call to an A2A agent can carry an RPC deadline, because every gRPC call can; that’s the transport’s doing, and A2A’s spec never mentions deadlines at all. What doesn’t exist anywhere is a task deadline: one that outlives a single RPC, survives push delivery and resubscribe, and means something in the JSON-RPC and HTTP+JSON bindings, which are co-equal in v1.0.
The mechanics people usually get backwards: some gRPC language APIs take an absolute deadline, others a relative timeout, but what goes on the wire is only ever grpc-timeout, a relative value, 1500m or 30S. Relative on purpose, because an absolute timestamp is only as trustworthy as the clock agreement between two machines and there is no clock agreement. One budget set at the root, each hop forwarding what’s left after its own elapsed time.
The SRE book has the counterexample. A gives B ten seconds. B takes eight to start, then calls C with a hardcoded twenty instead of the two it actually has. C dequeues five seconds later and gets to work believing it has fifteen in hand. A gave up three seconds before C even started.
Stack that five agents deep with nobody propagating and the chain has no bounded completion time at all. The depth makes it worse: the agent burning the most speculative compute is the one furthest from anyone who could tell it to stop.

Nelson had a word for this in 1981. An orphan is a remote call whose ancestor is running on a crashed node. Every agent framework that fans out sub-agents and then loses the parent is manufacturing orphans in exactly his sense. His remedy, exterminating them after a crash, assumed a runtime that could see the call tree. No agent runtime sees the call tree once it crosses an org boundary.
The part that actually is harder
One piece of this is genuinely new, and it’s worth being precise about which piece.
Sagas are the answer to a half-completed workflow. Garcia-Molina and Salem, 1987. Split the long transaction, give the sub-transactions compensating transactions, unwind backwards when something fails. It works, and people have built a lot on it.
Then you read the assumptions. The paper scopes itself to “a centralized database system.” It merges the saga log and the transaction log into one. It allows saga code outside the database’s control on the basis that sagas are written by trusted application programmers. And when a compensation itself fails, its remedies are an alternate implementation or a human, the second described, honestly, as “definitely not an elegant solution, but it is a practical one.”
One database, one log, one coordinator, everybody trusted. Agent-to-agent delegation violates all four.
Step three of your workflow ran inside another company’s agent and it billed you. There’s no rollback here. You can’t execute a compensating transaction in their system, they’re under no obligation to run one for you, and if they say they did you can’t check. That’s a claim, not a compensation, and no agent protocol has a message type for it.
There’s a footnote in the paper that reads differently now than it can have in 1987: forward recovery works only if every sub-transaction eventually succeeds given enough retries. Inside one company, a resourcing assumption. Across a trust boundary, false, because the counterparty is allowed to say no and keep saying it.
And the limit case hasn’t moved: “if a transaction fires a missile, it may not be possible to undo this action.”
What I think the specs should do
Both protocols are moving. A2A has eight epics open and a TSC decision pending between two proposals that directly contradict each other on multi-turn state. SEP-3182 went up against MCP on 1 August, no sponsor, no reviewers, no labels. Its motivation runs long but it reaches the right sentence fast:
A
tools/callthat mutates state (charges a payment, merges a branch, sends a notification) carries no field that would let the server recognize a retry and return the original outcome instead of executing the side effect again.
If it were mine to fix, three changes. None of them invents anything; all three adopt designs with decades of production behind them, and none needs a new protocol or a working group. Three PRs and the willingness to write MUST.
One: a client-minted execution key on every mutating call. In MCP, a declared field on tools/call params, not _meta. Opaque, compared by equality only, the RFC 1831 rule. Within a published retention window, a repeated key returns the stored result and MUST NOT re-execute. SEP-3182 is the right vehicle and deserves a sponsor. In A2A, the same key on message/send closes #1987 without reopening the security hole that killed client-set task IDs, because the key is scoped to the authenticated caller: the task ID stays server-minted and names the work, the key is per-principal and names the attempt. That split is the entire design, and it’s been sitting in Birrell and Nelson’s activity table since 1984.
Two: make the retry contract declarative and enforceable. idempotentHint should grow up into a declared semantic: at-most-once, idempotent, or unsafe, safe default when nothing is declared, tested by the conformance suite MCP already gates spec features on. A hint nobody can trust protects nobody. A declared contract with a test behind it changes what clients can safely automate.
Three: a relative time budget, decremented hop by hop. budgetMs on the call, gRPC’s design lifted whole: relative on the wire so clock skew can’t corrupt it, every hop forwarding the remainder, any hop receiving a spent budget failing fast. That’s the task deadline #857 has wanted for a year, and it gives a five-hop delegation chain a bounded completion time, which today it does not have.
One thing I wouldn’t do: put exactly-once in the transport. gRPC’s refusal was correct, and Two Generals says the transport can’t deliver it anyway. The protocol’s job is to carry the key, the contract, and the budget. Effect semantics belong to the endpoint that owns the side effect. That line has held since 1975 and nothing about agents moves it.
While you wait
Until something like that lands, you own this layer in application code:
Mint your own key and put it in tool arguments, not _meta. Declare it in your inputSchema so the model carries it across retries. SEP-3182 argues several popular frameworks strip _meta, and a guarantee riding on a field that might not survive the trip isn’t one.
Commit the key and the effect in one transaction. Or an outbox, or the downstream provider’s own idempotency key. Two separate network calls just relocate the race.
Publish the retention window. Pick twenty-four hours, enforce it, decide what a late duplicate gets back.
Set one deadline at the root and decrement it. A hop that receives a spent budget should fail immediately rather than start work nobody wants.
Assume every mutating tool call arrives twice. Write the handler that way and the transport stops being your problem.
Everyone building agents is carrying this separately and incompatibly right now, which is roughly where HTTP APIs sat on idempotency keys a decade ago, and where RPC sat in 1984.
Waldo and his co-authors called the loop. Their section heading was Déjà Vu All Over Again:
Every ten years (approximately), members of the language camp notice that the number of distributed applications is relatively small. They look at the programming interfaces and decide that the problem is that the programming model is not close enough to whatever programming model is currently in vogue (messages in the 1970s, procedure calls in the 1980s, and objects in the 1990s). A furious bout of language and protocol design takes place and a new distributed computing paradigm is announced that is compliant with the latest programming model. After several years, the percentage of distributed applications is discovered not to have increased significantly, and the cycle begins anew.
Tools in the 2020s. New paradigm, same partial failure.
Earlier parts of this series covered protocol discovery, agent identity and delegation, and the contract layer between work and payment, the last of which became draft-laxsharma-pact at the IETF.