An MCP cancellation can stop the wait without stopping the tool

A cancelled MCP request shows the client stopped waiting, not that the tool or its downstream work stopped. A gated test harness that finds the boundary where the stop signal disappears, with an independent witness and an honest list of unknowns.

Written by Worksi's AI marketing agents and checked by a separate AI fact-checking agent.

How this blog is made
An unplugged cable lies beside its socket while a row of gears keeps turning and an orange box slides off a small conveyor belt into a tray.
Illustration made with AI
In this article
  1. The 2025-11-25 spec asks the receiver to stop. It does not require it.
  2. The wire signal depends on the revision and the transport.
  3. A cancelled task is a status, not a stopped worker.
  4. Five boundaries where a stop can disappear.
  5. A harness that holds the work until the stop is observed.
  6. Read each result for what it shows and what it leaves open.
  7. Where a gateway trace fits.
  8. Write down the unknowns before you act.

A cancelled MCP call can still leave its record behind: the client reports the request as cancelled, and the record the tool was meant to write appears anyway. Picture that in a test of your own. Nothing crashed. The MCP tool call cancellation went out while the tool was still running, and nothing in the protocol forbids that. Under the 2025-11-25 specification, cancelling an ordinary request means sending a message that asks the other side to stop; on a 2026-07-28 Streamable HTTP connection in the TypeScript SDK, it means closing the request's stream. For the 2025-11-25 message, the specification lets the receiver finish anyway, ignore it, or receive it too late.

A cancelled request on the client shows one thing: the client stopped waiting. It does not show the handler stopped, and it says even less about the database write or queue job the handler started. Neither the client log nor the server log settles this. You need a test that holds the work at a known phase until the stop has been observed, plus an independent witness: a marker the downstream worker writes outside the MCP path, read by the harness without trusting any layer under test.

The 2025-11-25 spec asks the receiver to stop. It does not require it.#

The 2025-11-25 cancellation page defines notifications/cancelled with a requestId and an optional reason. The sender's rules are strict: the notification MUST only reference requests that "were previously issued in the same direction" and "are believed to still be in-progress". The client MUST NOT cancel initialize.

The receiver's rules are softer. Receivers SHOULD stop processing, free associated resources and not send a response. They MAY ignore the notification if the request is unknown, if processing has already completed, or if "the request cannot be cancelled". The sender SHOULD ignore any response that arrives afterward. The notification may also arrive after processing has completed, possibly after the response was sent, and both parties MUST handle that race.

So "cancelled, no response" on the client fits at least three server-side states: the work stopped, the work is still running, or the work finished before the notification arrived and the client threw the result away. The protocol message cannot tell these apart. A witness outside the protocol can.

The wire signal depends on the revision and the transport.#

Before you search a capture for notifications/cancelled, find out which signal your connection should carry. The MCP TypeScript SDK's guide to protocol revision 2026-07-28 says that on a 2026-07-28 Streamable HTTP connection, aborting an in-flight client request "closes that request's SSE response stream", which it calls the spec cancellation signal, "instead of POSTing notifications/cancelled". In the same SDK, 2025-era connections and stdio at any era still send notifications/cancelled.

The Ruby SDK's cancellation page describes the server side the same way: requests in the modern lifecycle are sessionless single POST exchanges, "so a separately POSTed cancel notification cannot reach them", and a modern client closes the connection instead.

If the expected form does not appear at your capture point, the finding is narrow: the expected signal was not observed there. Before you call it lost, check that the client supports cancelling this request, that the abort was forwarded into it, that the request had not already completed, and that your capture covers the path the signal would take.

Do not extend either SDK's behavior to every client, transport or revision. Record what was negotiated. The TypeScript SDK guide documents client.getProtocolEra() on the client and getNegotiatedProtocolVersion() for a server served through serveStdio, and says v2 speaks the 2025-era protocol by default; 2026-07-28 is an explicit opt-in. Record the installed SDK versions next to the revision.

A cancelled task is a status, not a stopped worker.#

A tools/call augmented with a task follows different rules. The 2025-11-25 tasks page says tasks are "currently considered experimental", and the cancellation page says that for task-augmented requests, tasks/cancel MUST be used instead of notifications/cancelled.

The tasks page is specific here. On a valid tasks/cancel, receivers "SHOULD attempt to stop the task execution and MUST transition the task to cancelled status before sending the response." Then: "Once a task is cancelled, it MUST remain in cancelled status even if execution continues to completion or fails." A cancelled status records that the cancel was accepted. By the spec's own words, execution may continue after it. A task already in a terminal status is refused with -32602, and receivers MAY delete cancelled tasks at any time, so read whatever you need before you cancel.

Five boundaries where a stop can disappear.#

At each one, a stop can be dropped while everything on the near side looks correct.

  1. Caller to client. The agent framework or UI aborts, but the abort never reaches the MCP request. openai-agents-js issue #1530, opened on 27 July 2026, reports this: the MCP tool wrapper never read the run's abort signal, so aborting a run let the MCP call run to completion with no notifications/cancelled. It was closed as completed via #1532 the same day. It is one report about one codebase at one point in time; check your own version.
  2. Client to wire. The request was aborted locally, but nothing left the process, or the wrong request ID did.
  3. Wire to server runtime. The signal arrived, but the runtime could not match it to an in-flight request. An unknown ID is one of the cases the spec lets a receiver ignore.
  4. Server runtime to handler. The runtime knows, but the handler never checks. In the Ruby SDK, cancellation is cooperative: "the SDK does not forcibly terminate tool code", and long-running tools poll server_context.cancelled?. The same page notes that on StdioTransport a nested sampling call inside a tool "runs to completion even if the parent tools/call is cancelled."
  5. Handler to downstream. The handler stops, but the HTTP request it made, the transaction it opened or the job it queued does not. A committed write or a job already handed to a queue cannot be called back by the handler; it can only stop waiting.

A harness that holds the work until the stop is observed.#

Run this only in an isolated test environment, with a synthetic tool, a fake downstream worker and no real credentials. The side effect is a harmless marker: a file or a row in a scratch store, keyed by a test ID.

Timing a cancel with sleeps produces flaky results. Gates are better, if you keep two moments apart: the local abort, when the client's controller fires, and remote observation, when the server's handler receives the stop. The harness keeps every work gate closed after the abort and waits, with a bounded timeout, for the handler to report the signal. If it does not arrive in time, the harness records cancel_not_observed and releases the gates as a labeled race step whose result is diagnostic only.

Each case also has a precondition: the phase it claims to test. A wait_for that times out returns false, and ignoring that would label an attempt with a phase it never reached. So every phase event goes through require_event, and the after-settle case requires the call to have resolved before it aborts. An attempt whose precondition fails is recorded as invalid_phase, cleaned up and never enters the result table below. Count those attempts: if they are frequent, fix the setup or raise the timeout before reading any result.

The handler reports the signal through a cancellation listener. If your SDK exposes only a polled flag, as the Ruby SDK's server_context.cancelled? does, run a small poller in the test server that logs the first time it flips.

Record these for every attempt, in one append-only log:

  • test_id, passed as a tool argument. Request IDs are only unique within a session and direction, so the test ID joins the client, server and worker records.
  • The wire request ID, negotiated revision, transport, client and server SDK versions.
  • Client abort time, and how the call settled: resolved, rejected as aborted, or pending at the timeout.
  • Wire events for that request ID at the server's transport (a proxy for HTTP, a tee on the pipes for stdio).
  • Handler signal, the checkpoint where the handler acted on it, and how it exited.
  • Worker started, committed or stopped, written by the worker itself, and the marker state read by the harness at the end of the window.

Put every process on one host and one clock if you can. Across hosts, clock skew makes close timestamps unreliable, and the gate order is your better evidence of sequence.

# Pseudocode. Isolated test environment, synthetic tool, fake worker,
# scratch marker store, no real credentials.

tool slow_marker(test_id, ctx):
    log(test_id, "handler_start", request_id=ctx.request_id)
    ctx.on_cancel(lambda: log(test_id, "handler_signal_received"))
    try:
        wait(gate[test_id].before_work)
        if ctx.cancelled():
            log(test_id, "handler_acted_on_cancel", phase="before_work"); return
        job = fake_worker.submit(test_id, signal=ctx.cancel_signal)
        result = job.wait(signal=ctx.cancel_signal)
        log(test_id, "handler_got_result")
        wait(gate[test_id].release_response)
        return result
    except Cancelled:
        log(test_id, "handler_acted_on_cancel", phase="waiting_on_job"); raise
    finally:
        log(test_id, "handler_exit", cancelled=ctx.cancelled())

fake_worker.run(test_id, signal):
    log(test_id, "worker_started")
    wait(gate[test_id].allow_commit)
    if signal.aborted:
        log(test_id, "worker_stopped"); worker_state[test_id] = "stopped"; return
    write_marker(test_id)
    log(test_id, "worker_committed"); worker_state[test_id] = "committed"

require_event(test_id, event):
    if not wait_for(test_id, event, timeout=T):
        raise InvalidPhase(event)

harness(case):   # baseline | before_work | during_work | after_commit | after_settle
    test_id = new_id()
    call = client.call_tool("slow_marker", {test_id}, abort=controller.signal)
    log(test_id, "client_sent", revision=negotiated_revision(), transport=transport_name())
    try:
        require_event(test_id, "handler_start")
        if case == "during_work":
            open(gate.before_work)
            require_event(test_id, "worker_started")
        if case == "after_commit":
            open(gate.before_work, gate.allow_commit)
            require_event(test_id, "handler_got_result")
        if case == "after_settle":
            open(gate.before_work, gate.allow_commit, gate.release_response)
            outcome = settle(call, timeout=T)
            log(test_id, "client_settled", outcome=outcome)
            if outcome != "resolved":
                raise InvalidPhase("resolved")
        log(test_id, "phase_established", case=case)
    except InvalidPhase as missing:
        log(test_id, "invalid_phase", missing=missing)
        cleanup(test_id, call)        # open gates, abort, settle; no verdict
        return "invalid_phase"

    if case != "baseline":
        controller.abort(); log(test_id, "client_abort")
        if not wait_for(test_id, "handler_signal_received", timeout=T):
            log(test_id, "cancel_not_observed", within=T)
            log(test_id, "race_step_begins")      # diagnostic only from here

    open(gate.before_work, gate.allow_commit, gate.release_response)
    if case != "after_settle":
        log(test_id, "client_settled", outcome=settle(call, timeout=T))
    sleep(observation_window)
    return verdict(read_log(test_id), wire_capture(test_id),
                   marker_exists(test_id), worker_state.get(test_id, "unknown"))

The cancel_not_observed race step is a different thing from invalid_phase. It can only happen after phase_established, so a race result always belongs to a known starting phase.

Run five cases:

  • Baseline, no cancel. The call must resolve and the marker must appear. If not, the harness is broken and no cancel result means anything.
  • Before work. Abort while the handler waits at its first gate.
  • During work. Abort after the worker has started, before it may commit. This tests forwarding into downstream work.
  • After commit, before response. The marker exists and the response gate keeps the request pending. This tests the late result.
  • After settle. The call resolved, then the client aborts. No signal is expected, and cancel_not_observed is correct. A signal here means the client cancels requests it already completed.

Repeat each case until the results agree across runs. If two runs of the same case disagree, log a race with both outcomes. A single run can land on either side of a race and give you a confident wrong answer.

Read each result for what it shows and what it leaves open.#

The verdicts describe observations, not causes. Only attempts that logged phase_established belong here. Rows about missing signals apply to a still-pending ordinary request, not to the after-settle case or to task-augmented calls.

Observation What it shows What it leaves open Look next
Baseline: call did not resolve, or no marker The harness or fake worker does not work Everything about cancellation Fix the harness first
After settle: no signal on the wire Expected: the request had completed Nothing about pending work Run the other cases
Pending request; cancel_not_observed; expected form (notification on 2025-era or stdio, stream closure on 2026-07-28 Streamable HTTP in the TypeScript SDK) not seen at the server's transport The expected signal was not observed at the capture point within the timeout Client support for cancelling this request, abort forwarding, a path your capture misses Client support, abort forwarding, capture coverage
Signal captured at the server's transport; cancel_not_observed in the handler The signal reached the server Failed ID correlation, or a listener that never fired Runtime correlation, handler listener
Handler received the signal before the commit gate opened; worker then committed Downstream work completed after the handler had the stop Signal not forwarded, or a step that cannot stop Forwarding; whether that step is cancellable
After commit: a response for the request ID crossed the wire after the cancel The server answered a cancelled request, which the spec says it SHOULD NOT Nothing about the effect, which already happened Client settlement: discarded or surfaced
cancel_not_observed, then a commit during the race step The stop did not reach the handler in time, and the work finished Whether the stop would have arrived later Treat the effect as done and reconcile
Handler and worker log a stop; worker state reads stopped; no marker at the end of the window Stop independently confirmed within the window Anything after the window, such as a queue retry Lengthen the window for queue-backed work
Worker state cannot be read Nothing about the downstream effect Whether the effect happened Record unknown

Do not skip the last two rows. A marker missing after 30 seconds is evidence about those 30 seconds. If the downstream system retries failed jobs or runs deferred work, the window has to cover that, or the verdict stays bounded. If the worker exposes no state you can read independently, the honest verdict is unknown, and a cancelled status in the MCP layer does not change it.

Each witness has its own limit. The client log shows what the client intended. A wire capture shows what crossed one hop. The handler log is written by the code under test. The worker's state and the marker are closest to the effect, and only as trustworthy as the fake worker you wrote.

Where a gateway trace fits.#

If MCP traffic passes through a gateway, the gateway's record is one more witness at one more boundary. Worksi's MCP gateway checks each tools/call to a registered server and records each call as a span. That shows what crossed the gateway and when. It does not show that a cancel reached the upstream server's handler, that downstream work stopped, or that an effect was reversed, and Worksi does not claim any of those. In the table above, a gateway span belongs next to the wire capture, not next to the marker.

Write down the unknowns before you act.#

When a test ends at unknown or at a race, the next step is a decision about the effect: retry, reconcile or leave it alone. That decision belongs to the effect's own idempotency and reconciliation design, covered in our article on agent tool timeouts, retries and idempotency. Before making it, write down which boundary the stop was last seen at, which revision and transport the connection used, how long you observed, and which downstream state you could not read. A retry decided without that list is a guess about a tool that may still be running.

Sources

  1. MCP specification 2025-11-25: Cancellation
  2. MCP specification 2025-11-25: Tasks
  3. MCP TypeScript SDK: Supporting protocol revision 2026-07-28
  4. MCP Ruby SDK: Cancellation
  5. openai-agents-js issue #1530

How this blog is made

Worksi's AI marketing agents write the articles here, and a separate AI fact-checking agent checks each one before it is published. The sources an article cites are listed with it. Its illustration was made with AI. If you find a mistake, write to info@worksi.ai.