Cooperative request cancellation for long-running tools.
MCP clients can abort an in-flight request by sending
notifications/cancelled with the original request's id. Because
ConduitMCP is stateless and each HTTP request runs in its own Bandit
process, the notification (which arrives on a different request)
cannot directly preempt the in-flight handler. Instead, the handler
records the cancellation in a shared ETS table and tool code polls
this module to decide whether to abort.
The handler also tracks which requests are in flight: track/2 when a
request is dispatched, untrack/2 when it completes. A cancellation is
recorded only for a request in_flight?/2 in the caller's scope; one for
an unknown or already-completed request is ignored, as MCP permits.
cancel/3 itself does not check, so a library caller can still record a
cancellation directly.
Tool integration
Inside a long-running tool, periodically check the conn:
def my_long_tool(conn, params) do
if ConduitMcp.Cancellation.cancelled?(conn) do
{:error, %{"code" => ConduitMcp.Errors.request_cancelled(), "message" => "Request cancelled"}}
else
continue_work(...)
end
endTools that complete in tens of milliseconds typically do not need cancellation at all — the client's notification will arrive after the response has already been sent.
Modern streams: disconnect is cancellation
The modern (2026-07-28) HTTP transport has no client-to-server
notifications; a client cancels by closing the stream. When a streamed
tools/call response (ConduitMcp.StreamSpec) fails to write because
the client is gone, the transport records the cancellation here, under
the request's own scope, so cancelled?/1 turns true for the running
tool. A tool that has not returned within the transport's
:stream_cancel_timeout is killed.
Scoping
JSON-RPC ids are client-chosen and conventionally small integers, so
the table cannot be keyed on the id alone: a single
POST {"method":"notifications/cancelled","params":{"requestId":"1"}}
would abort every concurrent client's request id 1, and looping 1..1000
would abort every in-flight tool call on the node.
Rows are therefore keyed {scope, id}, where scope/1 derives the caller
from the Plug.Conn, most specific first:
"session:" <> idfromconn.private[:mcp_session_id]— set byConduitMcp.Transport.StreamableHTTPwhen sessions are configured."principal:" <> idfromConduitMcp.Principal.id/1— the authenticated principal, when its id is a string (asConduitMcp.Principal.put/2guarantees)."ip:" <> bucketfromConduitMcp.Principal.client_bucket/1— the last resort. That is the address for IPv4, and the /64 prefix for IPv6 ("ip:2001:db8:1:2::/64"), because a host is routinely handed a whole /64 and could otherwise rotate addresses to multiply its quota.
The prefix keeps the three namespaces apart: a principal whose id happens to equal a client's IP string does not share that client's rows or quota.
Enable sessions or auth
With neither sessions nor authentication, the scope falls back to the
client IP, so clients sharing a source address (behind a proxy or NAT)
share a cancellation namespace and can still abort each other's requests.
Configure :session or :auth on the transport to get real isolation.
Bounds
notifications/cancelled is reachable unauthenticated. Through the
handler, a row is written only for a request in flight in the caller's
scope, so a client can hold no more rows than it has requests running.
The table is still bounded two ways, as a backstop for direct cancel/3
callers and for requests that are long-running or leaked:
- Per-scope quota.
cancel/3refuses to insert pastconfig :conduit_mcp, :cancellations_max_rows_per_scope(default 256) and returns{:error, :cancellation_limit_reached}. This is the bound that matters, and the only one that rejects: a global cap alone is a cross-tenant denial of service, because one unauthenticated client filling the table stops every other client's cancellations from being recorded. - Global cap. A memory backstop at
config :conduit_mcp, :cancellations_max_rows(default 10000;:infinitydisables it). It never rejects — a caller under its own quota is always served. Reaching it evicts a batch ofmax_rows / 20rows, oldest first from the largest scope, then from the next largest, so a client holding fewer rows than the largest scopes is evicted only after them. When every scope holds one row they all tie and eviction falls back to age, so the oldest rows go whoever owns them. The batch is always full, even then, so a lone writer runs the full-table scan that picks the rows at most once per batch of inserts; writers that hit the cap together each scan but evict overlapping rows. The per-scope quota is checked first: 40 sessions x 256 rows exceeds 10 000 without any single scope being over quota, and a global check that rejected would hand exactly that flood a cross-tenant denial of service.
A row is normally removed inline: the handler calls clear/2 in a
try/after once the request completes. clear/2 reads before it writes,
so the common request, which was never cancelled, takes no write lock on
the table. The cap and janitor cover the cases where the inline removal
never happens — a cancel that races the request's completion, or a
request process killed before clear/2 runs.
Ids must be an integer or a string of at most 256 bytes (JSON-RPC
requires a string or number); anything else is rejected with
{:error, :invalid_request_id} rather than crashing the request process.
Reasons are truncated and stripped of control characters via
ConduitMcp.Reflect.
In-flight rows
track/2 writes one row per request process, {{scope, id}, pid}, to a
duplicate_bag, so concurrent requests reusing an id in one scope each hold
their own row and one completing (untrack/2 deletes only its own
{key, pid} object) leaves the other cancellable. A request process killed
before untrack/2 runs (as an HTTP/2 stream can be) leaves its row behind;
cleanup/1 deletes rows whose process is no longer alive.
Emits [:conduit_mcp, :request, :cancelled] telemetry on cancellation
with metadata %{request_id: id, scope: scope, reason: reason}, where
scope is the prefixed value described under Scoping, and
[:conduit_mcp, :cancellation, :cleanup] on each cleanup/1 pass with
measurements %{removed: count, in_flight_removed: count}: expired
cancellation rows and in-flight rows of dead processes respectively.
Summary
Types
A JSON-RPC request id: string or integer.
The caller a cancellation belongs to. See the Scoping section.
Functions
Marks a request id as cancelled within scope, with an optional reason.
Cancels the one request conn is handling, from the server side.
Returns true when the given request has been cancelled.
Removes cancellation entries older than ttl_ms milliseconds, and the
in-flight rows of processes that are no longer alive.
Clears a request id from the cancellation set. Idempotent.
Returns true when some process has track/2ed request_id in scope
and not yet untracked it.
Returns the cancellation reason recorded for the request, or nil.
Derives the cancellation scope for a connection.
Records that the calling process is handling request_id in scope.
Removes the calling process's track/2 row for request_id in scope.
Another process tracking the same id in the same scope stays in flight.
Idempotent.
Returns true for an id this module accepts: an integer, or a string of
at most 256 bytes.
Types
Functions
@spec cancel(request_id() | nil, term(), scope()) :: :ok | {:error, :invalid_request_id | :cancellation_limit_reached}
Marks a request id as cancelled within scope, with an optional reason.
Returns :ok, {:error, :invalid_request_id} for an id that is neither an
integer nor a string of at most 256 bytes, or
{:error, :cancellation_limit_reached} when scope already holds its
per-scope quota of rows. Only that quota rejects: when the table-wide cap
is reached, rows are evicted from the largest scopes and the insert still
succeeds.
This records the cancellation whether or not the request is in flight.
ConduitMcp.Handler calls it only for a request in_flight?/2 in the
caller's scope.
@spec cancel_request(Plug.Conn.t() | map(), term()) :: :ok
Cancels the one request conn is handling, from the server side.
For cancellations the server decides on — a modern client closing its
response stream, a stream reaching :max_stream_lifetime — rather than a
client's notifications/cancelled. The row is keyed to the request's
private token (assigned by the handler pipeline when it tracks the request), not
to {scope, id}: two clients behind one address without a session or
principal share a scope, and both number their requests from 1, so a
{scope, id} row would be visible to — and cleared by — the other
client's request.
Returns :ok, and does nothing for a conn that is not in flight.
@spec cancelled?(Plug.Conn.t() | request_id() | nil) :: boolean()
Returns true when the given request has been cancelled.
Accepts a Plug.Conn whose assigns carries :mcp_request_id (set by
ConduitMcp.Handler before dispatch) — the conn also supplies the scope —
or an explicit id plus scope. For a conn, both the client's cancellation
(notifications/cancelled, keyed by scope and id) and the server's own
(cancel_request/2, keyed to this one request) count.
@spec cancelled?(request_id() | nil, scope()) :: boolean()
@spec cleanup(non_neg_integer()) :: non_neg_integer()
Removes cancellation entries older than ttl_ms milliseconds, and the
in-flight rows of processes that are no longer alive.
Returns the number of cancellation entries removed. Emits
[:conduit_mcp, :cancellation, :cleanup] telemetry with measurements
%{removed: count, in_flight_removed: count} so both are observable.
@spec clear(request_id() | nil, scope()) :: :ok
Clears a request id from the cancellation set. Idempotent.
@spec in_flight?(request_id() | nil, scope()) :: boolean()
Returns true when some process has track/2ed request_id in scope
and not yet untracked it.
@spec reason(request_id(), scope()) :: String.t() | nil
Returns the cancellation reason recorded for the request, or nil.
@spec scope(Plug.Conn.t() | map() | nil) :: scope()
Derives the cancellation scope for a connection.
Returns "session:" <> session_id, "principal:" <> principal_id or
"ip:" <> ConduitMcp.Principal.client_bucket(conn), whichever is
available first (see the Scoping section), and "global" for anything
that is not a Plug.Conn. A principal id that is not a string (assigned
without going through ConduitMcp.Principal.put/2) is ignored, and the IP
scope is used.
The same conn shape must yield the same scope on the request being
cancelled and on the notifications/cancelled that cancels it, which is
why every component of this is per-client and not per-request.
@spec track(request_id() | nil, scope()) :: :ok
Records that the calling process is handling request_id in scope.
ConduitMcp.Handler calls this before dispatching a request and
untrack/2 once it completes. A nil id and an id valid_request_id?/1
rejects are ignored: such a request cannot be cancelled.
@spec untrack(request_id() | nil, scope()) :: :ok
Removes the calling process's track/2 row for request_id in scope.
Another process tracking the same id in the same scope stays in flight.
Idempotent.
Returns true for an id this module accepts: an integer, or a string of
at most 256 bytes.