Quick answer

A tool call that an agent cancels is not the same as a request that never happened. By the time the MCP client sends a cancellation, the server may already have opened an upstream connection, started a long-running job, or written partial state. If the server treats cancellation as "drop the client and forget it," the upstream request keeps running, resources leak, and a job the user believes they stopped can still finish and mutate data. Model cancellation as an explicit signal: abort the in-flight upstream request where the transport supports it, release or roll back any partial work, and make the cancellation path something you test rather than an unhandled socket close.

Where the window for leakage exists

An MCP tool often fronts a slow upstream operation. The sequence that matters spans several layers.

agent --(call tool)--> MCP server --(HTTP request)--> upstream API
                                        |
                                        +-- job started, partial rows written
agent --(cancel)--> MCP server
                                        |
                                        +-- upstream still running if not aborted

The client side observes a clean cancellation and reports the action stopped. The server side, however, only sees the transport connection end or a cancellation notification arrive. If the handler is blocked awaiting an upstream response and nothing links that signal to the underlying request, the HTTP call continues to its own timeout. Repeated cancellations accumulate sockets, connections, and background jobs that are now unreachable but still consuming quota.

Why cancellation is easy to ignore

The happy path is where the design effort goes, and cancellation is usually treated as a client concern. On the server it shows up as an awkward asynchronous event that arrives at an unpredictable point in the handler. The temptation is to let the process finish and discard the result, which is simple but leaves the side effects intact. The distinction that matters is between a request that is merely no longer awaited and one that is actually stopped. The first frees the agent; only the second frees the resources. Conflating the two is what produces the leak.

What the server does on cancelUpstream behaviorResult
Nothing, handler runs to completionRequest finishes, job may commitLeaked work and resource use
Abort the in-flight requestUpstream receives abort, connection closesClean stop when supported
Cancel the job by idLong-running job is told to stopNeeded for async operations
Mark partial work for rollbackCompensating action runsPrevents half-committed state

Wiring cancellation to the upstream call

Use an abort signal that is tied to the request lifecycle and pass it all the way down so the upstream fetch is actually cancelled. In a Node-style handler that looks like passing the same signal into the HTTP call.

async function runReportTool(args, signal) {
  const job = await createUpstreamJob(args, { signal });
  signal.addEventListener("abort", async () => {
    try {
      await cancelUpstreamJob(job.id);
    } catch (err) {
      log("job cancel failed", job.id, err);
    }
  });
  return pollUntilDone(job.id, { signal });
}

For a long-running async job, aborting the local socket is not enough because the work continues on the remote system. You need an explicit cancellation call using the job identifier, which is why the tool should return or retain that id as soon as the job is created. If the cancel call itself fails, log it loudly rather than silently, because an orphaned remote job is the exact outcome you are trying to avoid.

Handling partial and non-cancellable work

Some upstream operations have no cancel endpoint and cannot be interrupted once started. In that case the honest design is to acknowledge the work cannot be stopped and to model a compensating action: a rollback, a delete, or a marker that prevents the orphaned result from being treated as authoritative. The server should also bound the lifetime of such work so it cannot run forever. Being explicit about non-cancellability is far better than pretending cancellation worked and leaving the user with a false belief that nothing changed.

Testing the cancellation path

Cancellation needs deterministic tests because it is otherwise only exercised by impatient users. Start a tool call, cancel it at known points, and assert the upstream abort or job-cancel call was made, the connection was released, and no partial rows remain. Place the cancellation at different moments: before the upstream request, mid-flight, and after the job is created. Each point exercises different cleanup, and a test that only cancels before anything starts will miss the real leaks.

FAQ

If the client stops listening, isn't the request effectively cancelled?

No. Closing the client connection only stops the response from being delivered. The server's handler and its upstream call can continue independently. Cancellation has to be propagated explicitly to the in-flight request and any remote job.

What if the upstream API has no cancel operation?

Then bound the work and use a compensating action such as rollback or marking the result orphaned, and record the job id so it can be reconciled. Do not report the operation as fully stopped when the remote side cannot be interrupted.

Should cancellation be best-effort or guaranteed?

It should be attempted with explicit handling of failure, not assumed guaranteed. The signal should reach the upstream and the cleanup should run, but if the cancel call fails the server must log it and surface the orphan rather than silently losing track.

Does this matter for short, fast tools?

Less urgently, but the same signal plumbing is cheap to add and protects you when a fast tool later becomes slow. Wiring the abort signal through from the start avoids a retrofit when the operation grows or starts touching shared state.

This is part of the The MCP Server Guide — start there for the full picture of building reliable tools for agents.