ConduitMcp.Cancellation (ConduitMCP v0.11.0)

Copy Markdown View Source

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
end

Tools 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:

  1. "session:" <> id from conn.private[:mcp_session_id] — set by ConduitMcp.Transport.StreamableHTTP when sessions are configured.
  2. "principal:" <> id from ConduitMcp.Principal.id/1 — the authenticated principal, when its id is a string (as ConduitMcp.Principal.put/2 guarantees).
  3. "ip:" <> bucket from ConduitMcp.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/3 refuses to insert past config :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; :infinity disables it). It never rejects — a caller under its own quota is always served. Reaching it evicts a batch of max_rows / 20 rows, 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

request_id()

@type request_id() :: String.t() | integer()

A JSON-RPC request id: string or integer.

scope()

@type scope() :: String.t()

The caller a cancellation belongs to. See the Scoping section.

Functions

cancel(request_id, reason \\ nil, scope \\ "global")

@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.

cancel_request(conn, reason)

@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.

cancelled?(conn)

@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.

cancelled?(request_id, scope)

@spec cancelled?(request_id() | nil, scope()) :: boolean()

cleanup(ttl_ms)

@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.

clear(request_id, scope \\ "global")

@spec clear(request_id() | nil, scope()) :: :ok

Clears a request id from the cancellation set. Idempotent.

in_flight?(request_id, scope)

@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.

reason(request_id, scope \\ "global")

@spec reason(request_id(), scope()) :: String.t() | nil

Returns the cancellation reason recorded for the request, or nil.

scope(conn)

@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.

track(request_id, scope)

@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.

untrack(request_id, scope)

@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.

valid_request_id?(request_id)

@spec valid_request_id?(term()) :: boolean()

Returns true for an id this module accepts: an integer, or a string of at most 256 bytes.