# `ConduitMcp.Cancellation`
[🔗](https://github.com/nyo16/conduit_mcp/blob/v0.11.0/lib/conduit_mcp/cancellation.ex#L1)

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 {: .warning}
>
> 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.

# `request_id`

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

A JSON-RPC request id: string or integer.

# `scope`

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

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

# `cancel`

```elixir
@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`

```elixir
@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?`

```elixir
@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?`

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

# `cleanup`

```elixir
@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`

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

Clears a request id from the cancellation set. Idempotent.

# `in_flight?`

```elixir
@spec in_flight?(request_id() | nil, scope()) :: boolean()
```

Returns `true` when some process has `track/2`ed `request_id` in `scope`
and not yet untracked it.

# `reason`

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

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

# `scope`

```elixir
@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`

```elixir
@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`

```elixir
@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?`

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

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
