7 min read

Duplicate MCP tool calls need an execution identity

Duplicate MCP tool calls need more than retries. Use request fingerprints and activity records to separate reconnects from real second actions.

Duplicate MCP tool calls need an execution identity

A reconnect is a transport problem. A second action is an execution problem. Teams get hurt when they treat those as the same thing and let a client retry every request that lacks a visible response.

MCP makes this easy to miss because a tool call can cross several boundaries: an agent process, an MCP transport, an action gateway, and an HTTP API or SSH target. A connection can disappear after the target accepted the work but before the agent receives the result. If your system then sends the call again, the target sees two valid requests. It has no reason to infer that the second one was an accident.

The fix is not a larger retry budget. Give each requested action an execution identity, record its lifecycle, and make retry decisions from that record. A request fingerprint tells you what the caller was trying to do. An activity record tells you whether the system already began or completed it. You need both.

A reconnect does not authorize another execution

A client reconnecting after a broken stream has evidence that communication failed. It does not have evidence that the original tool call failed.

That distinction sounds obvious until someone adds generic retry middleware beneath an MCP client. The middleware sees a timeout, a connection reset, or a missing response. It has no understanding of whether the POST was a read, a write, a remote command, or an irreversible operation. It sends the bytes again because that is what HTTP retry code often does.

For a read such as GET /repos/acme/api/branches, that may be tolerable. For POST /payments, DELETE /projects/atlas, or an SSH command that changes a production host, it can create a second side effect. A tool layer cannot repair that afterward by returning only one result to the model.

The MCP Streamable HTTP transport specification explicitly allows clients to resume server to client event delivery with Last-Event-ID when a stream breaks. That is a recovery mechanism for messages on a stream. It does not turn a second JSON-RPC tools/call request into the same execution. The TypeScript SDK documentation also separates resumption tokens from the request path and permits client middleware around fetch. That combination is precisely why teams should state their retry rule in code rather than assume the transport will save them.

Use this rule:

Resume a response stream when the protocol supports it. Reissue a side-effecting action only when the action layer can identify it as the same execution.

The difficult cases are not clean failures. The difficult cases are the ones where the server starts work, the response vanishes, and the client cannot tell whether it should wait, resume, query status, or retry. Your design must make that uncertainty visible.

JSON-RPC IDs identify messages, not durable actions

A JSON-RPC request ID is useful for correlating a request and its response. It is not enough to deduplicate an action across reconnects, process restarts, or separate agent runs.

Consider this pair of calls:

{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"name":"deploy_release","arguments":{"service":"catalog","version":"2026.07.22"}}}
{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"name":"deploy_release","arguments":{"service":"catalog","version":"2026.07.22"}}}

They may be the same request sent twice after a dropped connection. They may also come from two separate client processes that both begin numbering at 1 or 41. Even within a single process, an implementation bug can reuse IDs. The value tells you almost nothing unless you bind it to an authenticated caller and a particular protocol session.

Now consider two calls with different IDs:

{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"name":"deploy_release","arguments":{"service":"catalog","version":"2026.07.22"}}}
{"jsonrpc":"2.0","id":42,"method":"tools/call","params":{"name":"deploy_release","arguments":{"service":"catalog","version":"2026.07.22"}}}

Those could be a transport retry whose client assigned a fresh ID. Or the agent may have deliberately asked for a second deployment after receiving an uncertain result. Message IDs are evidence, not the decision.

Do not make the opposite mistake and deduplicate every matching tool call forever. Deploying the same release twice may be harmless or even intentional. Creating the same external ticket twice may be wrong. Rotating a credential twice may lock out a service. The action class decides how long an execution identity remains meaningful.

A practical model keeps three identifiers separate:

  • Protocol correlation ID: the JSON-RPC ID and, where applicable, the MCP session or stream context.
  • Execution ID: a server-created identifier for one accepted attempt to perform a tool action.
  • Intent fingerprint: a stable digest of the requested effect, used to find a prior execution when protocol correlation changes.

Once you separate these, the logs stop pretending they answer a question they cannot answer.

A useful fingerprint describes the effect

A request fingerprint should stay the same when delivery changes and change when the requested effect changes. Do not hash raw JSON bytes and call the result a fingerprint. Raw JSON varies with property order, whitespace, optional defaults, request IDs, and harmless formatting changes.

Build a canonical action record first. For an HTTP action, it might contain this shape:

{
  "actor": "signed-process:com.example.agent",
  "tool": "deploy_release",
  "channel": "http",
  "target": "deploy-api.internal.example/releases",
  "credential_ref": "deploy-service",
  "method": "POST",
  "arguments": {
    "service": "catalog",
    "version": "2026.07.22",
    "region": "us-east-1"
  },
  "intent_scope": "run:5f8097"
}

Canonicalize field order, omit fields with no semantic meaning, and normalize known equivalents before calculating a digest. If region defaults to us-east-1, either always materialize it or always omit it when the target will supply that default. Mixing the two creates false misses.

The actor field matters. Two different authorized agent processes that send identical arguments may represent separate intended actions. The credential_ref matters too. A request made through one service identity is not necessarily equivalent to the same path and body made through another. For SSH, include the host identity, account, command, working directory if it affects behavior, and a normalized command representation where you can safely produce one.

Keep secrets out of the canonical record. Never place bearer tokens, private keys, or raw authorization headers into a fingerprint input. If an argument itself contains a secret, replace it with a protected internal reference or compute the fingerprint using a keyed construction such as HMAC. A plain unsalted hash of a low-entropy secret turns your audit store into a guessing oracle.

The familiar recommendation to "just hash the request" is popular because it is short. It is wrong for action control. A hash proves only that some bytes were fed to a function. It does not tell you whether those bytes represent the same actor, the same target effect, or the same retry window.

The activity record needs states, not a single log line

A useful activity record answers where the action stopped. If it records only success and failure, a reconnect will leave you guessing at the moment you most need a clear answer.

Record at least these transitions for each execution ID:

  1. Accepted: the gateway validated the request and assigned an execution ID.
  2. Authorized: the required approval or session authorization allowed the action.
  3. Dispatched: the gateway handed the action to the HTTP client or SSH helper.
  4. Observed outcome: the target response, exit status, or an explicit delivery failure arrived.
  5. Result delivered: the agent received the tool result, if the transport can establish that fact.

The fourth and fifth states must stay separate. A target can return HTTP 201 while the connection to the MCP client breaks before it sees the response. Marking that execution as failed because result delivery failed is a lie. Marking it as completed gives the recovery code something useful to work with: it can return or reconstruct the known result without dispatching another request.

This is the record shape I want to see during an incident:

{
  "execution_id": "act_01J4K8J7DX7V",
  "fingerprint": "hmac-sha256:4a1e...d90c",
  "tool": "deploy_release",
  "actor": "signed-process:com.example.agent",
  "target": "deploy-api.internal.example/releases",
  "state": "completed_result_not_delivered",
  "accepted_at": "2026-07-22T14:03:18Z",
  "dispatched_at": "2026-07-22T14:03:19Z",
  "completed_at": "2026-07-22T14:03:25Z",
  "target_status": 201,
  "result_reference": "result_01J4K8JFM2"
}

The record does not need to expose the full target response to every operator. It needs enough protected detail for the gateway to make a recovery decision and enough readable detail for a human to understand what happened.

Sallyport's Activity journal records individual calls, while its Sessions journal records agent runs. That separation is useful in this investigation: the run tells you which agent process existed, and the call record tells you whether a particular outside-world action crossed the dispatch boundary. Its audit chain can also be verified offline with sp audit verify, which helps when you need to establish that the record was not quietly rewritten after an incident.

Treat unknown outcomes as a separate result

Approve the process once
A new agent process gets a per-session approval card showing its code-signing authority.

Most duplicate actions start with a system that has only two outcomes: success and failure. Networked actions need a third: unknown.

Unknown does not mean the system did nothing. It means the system cannot prove whether the target accepted the action. A timeout before any bytes leave your process is often safe to retry. A timeout after an HTTP request body has been handed to the operating system is not the same event. A broken SSH connection after the remote shell starts a command is worse still, because the remote command may continue after your local process exits.

Classify each tool action before you decide how recovery works:

Action typeExampleDefault after unknown outcome
Read onlyFetch a build statusRetry with ordinary limits
Idempotent writeSet a named resource to a declared stateRetry using the same idempotency identity
Conditional writeUpdate only if version matchesQuery state, then retry only if the condition still holds
Irreversible actionSend payment, revoke access, rotate credentialStop and request explicit review
Remote commandRun a migration over SSHQuery a durable marker or stop for review

An API's HTTP verb does not settle this table. PUT is often described as idempotent, but a badly designed endpoint can send a notification, trigger a build, or append an audit event every time it receives the request. POST can be safely repeatable when the API honors an idempotency key. Inspect the target's actual contract.

For long-running remote commands, add a durable marker before you run the work. A migration command can create a record with an execution ID, update it when work begins, and mark it complete only after validation. On reconnect, query that marker before dispatching again. Without a marker, "probably did not run" is not a recovery strategy.

Match retries in a bounded intent scope

A fingerprint alone will overmatch legitimate work. Scope it to the period and context in which a retry can reasonably happen.

The simplest scope is one agent run. If the same signed process sends the same action twice while its first result is unresolved, treat the second request as a candidate retry. If another process sends it hours later, treat it as a fresh intent unless the action itself supplies a durable idempotency key.

A good matching rule looks like this:

if prior.fingerprint == incoming.fingerprint
  and prior.actor == incoming.actor
  and prior.intent_scope == incoming.intent_scope
  and prior.state in {accepted, authorized, dispatched, completed_result_not_delivered}:
    recover_or_attach_to(prior.execution_id)
else:
    create_new_execution()

recover_or_attach_to must not blindly return success. Its behavior depends on the prior state.

If the prior execution is accepted but not dispatched, the gateway can continue that execution. If it is dispatched with an unknown outcome, the gateway should consult the target's status endpoint, idempotency facility, or durable marker. If it completed but result delivery failed, it should return the stored result reference. If it was rejected by authorization, it should return that rejection rather than creating a new approval path from the same ambiguous retry.

The scope must match the action. A five-minute window may be reasonable for an API request that times out. It is not enough for a software deployment that lasts an hour. A credential rotation may require a durable fingerprint until you can verify which credential is active. Do not use one global TTL because it is easy to configure. Use action-specific retention and recovery rules.

Approval is evidence, not an idempotency mechanism

Put actions behind Sallyport
Use the bundled sp mcp shim to put an action gateway between MCP agents and targets.

A human approval can establish that a process was allowed to attempt an action. It cannot prove whether a prior attempt already happened.

This matters for systems that prompt on each sensitive call. Suppose an agent asks to rotate a production credential. A person approves. The gateway dispatches the request, then the client disconnects. The agent reconnects and generates the same tool call. Prompting the person again creates a misleading choice. The operator sees a familiar request and may approve it, but the question they need answered is whether the first rotation completed.

Per-call approval still has a place. It controls authorization at the moment of use. Keep it separate from duplicate handling:

  • Authorization decides whether the current caller may initiate an execution.
  • Fingerprinting decides whether an incoming request maps to an existing execution.
  • Activity records decide whether that existing execution can be resumed, recovered, or must be reviewed.

When a retry maps to a pending execution, show the original activity record instead of presenting a fresh approval as though nothing occurred. A reviewer should see the target, the first dispatch time, the known outcome, and why the gateway did not send the action again.

Sallyport uses a fixed decision ladder: a locked vault denies actions, a new agent process receives per-session authorization by default, and selected credentials can require approval on each use. Those controls answer who may act. The execution record still needs to answer whether the action already crossed the boundary.

HTTP idempotency keys solve only part of the problem

If an upstream API accepts idempotency keys, use them. Send one stable value for the life of an execution, persist the target's response, and reuse that value only when recovering the same execution.

For example, the gateway can create an execution ID before dispatching and map it to the header expected by the API:

POST /v1/releases HTTP/1.1
Host: deploy-api.internal.example
Idempotency-Key: act_01J4K8J7DX7V
Content-Type: application/json

{"service":"catalog","version":"2026.07.22","region":"us-east-1"}

The API must define what it does when that header repeats. The best behavior is to return the original result for the same semantic request and reject a different request that tries to reuse the same value. If it silently accepts a changed body under the same key, your gateway cannot safely infer anything from a replay.

Do not use the fingerprint itself as the external idempotency key if the fingerprint can persist across intentional actions. An execution ID is unique for one accepted attempt. The fingerprint locates a potentially related attempt. They have different jobs.

HTTP idempotency also does nothing for SSH by itself. You need a remote protocol. A safe pattern is to pass a generated execution ID to a script that writes a durable status record on the host or in a shared store, then refuses to start the same operation twice. If you cannot modify the command or inspect an external marker, place that command in the irreversible category and require review after an uncertain disconnect.

Investigate the sequence, not the final count

See the dispatched call
Its Activity journal records each outside-world call separately from the agent run.

Two activity rows with matching arguments do not prove a duplicate. Start with the sequence of events and follow the first call to its dispatch boundary.

A real investigation should answer these questions in order:

  1. Did one agent process or two distinct processes send the calls?
  2. Did the first call receive authorization and enter dispatch?
  3. Did the gateway receive a response or exit status from the target?
  4. Did result delivery fail after target completion?
  5. Did the second call reuse the original execution ID, carry an idempotency key, or create a fresh attempt?

This ordering prevents a common bad conclusion: "The logs show two calls, so the agent acted twice." You may find that the gateway recorded one completed execution and one client retry that attached to it. Or you may find two separate authorized processes, each with a different planning context, both issued the action. Those need different fixes.

Keep the activity store append-only or otherwise tamper-evident. Duplicate investigations often happen after a costly event, when someone wants a cleaner story than the system can support. A hash-chained record does not make the original decision correct, but it makes later reconstruction harder to manipulate.

Do not hide ambiguity from the agent either. Return a result that says the prior execution is pending verification or completed with result delivery interrupted. A model that sees a fabricated failure will try again. A model that sees a clear uncertain state can query status, ask for review, or choose a safer path.

Make replay behavior part of every tool contract

Every side-effecting tool needs an explicit answer to one question: what happens when the caller loses the response after dispatch?

Write the answer next to the tool definition. State whether the action is read-only, repeatable with an idempotency identity, recoverable through a status query, or blocked after an unknown outcome. State what belongs in its fingerprint and how long an unfinished execution remains attachable. If nobody can write that down, the tool is not ready for autonomous use.

The engineering work is usually modest compared with the cleanup after a duplicate deployment, duplicate account, duplicate payment, or second credential rotation. Add the execution ID before the target call. Persist state transitions before and after dispatch. Preserve a result reference. Then make reconnect code consult that record before it touches the outside world again.

That is the standard to hold: a broken transport may interrupt a conversation, but it must not quietly turn uncertainty into a second action.

FAQ

What counts as a duplicate MCP tool call?

It is a repeated side effect caused by two executions reaching the target, not merely two messages in a log. A repeated HTTP request that only reads data may be annoying; a repeated request that sends money, deletes a branch, rotates a credential, or creates an account needs a different response. Start by identifying the downstream effect before arguing about whether the client "meant" to retry.

Can an MCP reconnect cause the same tool call to run twice?

Sometimes. A client may lose the response after the server has already completed the call, then reconnect and send the same request again. But identical-looking calls can also come from an agent that reconsidered its plan, a supervisor that restarted a worker, or a human who issued the instruction twice.

Is the JSON-RPC request ID enough for deduplication?

No. A JSON-RPC request ID identifies a message within a protocol conversation, but it is not a durable execution identity across a client restart or a fresh connection. Treat it as useful evidence, then pair it with a request fingerprint and an action record.

What should an MCP request fingerprint include?

Include the authorized process or principal, tool name, normalized arguments, target identity, credential identity, and a bounded time or intent scope. Do not include transport-specific noise such as a socket number, a transient SSE event ID, or a randomly generated request ID. The fingerprint should describe the requested effect, not the route used to deliver it.

Should I automatically retry a failed tool call?

Avoid automatic re-execution for any action with an external side effect unless the target supports an idempotency key or you can prove the first attempt did not begin. Reconnecting a response stream is different from resending a tool invocation. The safe recovery path is usually to recover the result or inspect the activity record first.

What should an activity record show during a retry investigation?

An activity record should capture the request fingerprint, the execution attempt, the authorization context, timestamps, the target, and an outcome state. It must distinguish "received," "started," "completed," and "result delivery failed." If those states collapse into one log line, an operator cannot tell whether a second action happened.

When should an agent send an idempotency key?

Use an idempotency key when the receiving API supports one and preserve it across a transport retry of the same intended action. Do not reuse it for a later intentional action, even if the arguments match. If the API has no idempotency mechanism, your gateway needs its own pending and completed execution records.

How can I tell a retry from an intentional second action?

A reconnect usually changes connection evidence: process start time, transport session, request ID, or stream state. An intentional repeat often has a new planning context, a new authorization decision, changed arguments, or a meaningful interval after the prior result. None of those signals alone settles the case, which is why the system needs a recorded decision rule.

Can I store only a hash of every tool request?

No. A digest is only an index. Keep a protected canonical representation or enough structured fields to explain why two calls matched, while limiting sensitive values in operator views. If fingerprints cover secrets, derive them with a secret key so a log reader cannot test guesses against the digest.

What should happen when the first call's outcome is unknown?

Treat it as an ambiguous execution and stop automatic replay for that action class. Show the previous activity record, the known outcome, and the exact target effect to the approving person. A fast duplicate is cheaper than an irreversible duplicate, but it is still a design defect worth fixing.

Sallyport

Sallyport runs API calls and SSH commands for your AI agent. The keys stay in a local vault on your Mac; you approve each run and every action lands in a sealed journal.

© 2026 Sallyport · Open source under Apache-2.0 · Oleg Sotnikov