ADR 190 · Accepted
ADR-190: Cancellation crosses the transport boundary as a signal
Context
ADR-103 gave an operator a cancel that works between steps: the run row moves to CANCELLED, the loop reads that at the next step boundary and stops. An HTTP call already on the wire never learned about it. A cancelled run therefore ended while the MCP request it had started ran to its own deadline — under ADR-170 up to the whole operation budget, about 45 seconds — with an operator watching a run they had stopped.
ADR-161 recorded that gap deliberately rather than papering over it, and pinned it structurally: its conformance suite asserted that McpClient and McpHttpTransport took no cancellation collaborator and no cancellation argument, so the check would fail the day such a seam appeared. It also named the three things that had to be decided before one could: an async request plus a poll loop, what a half-sent tools/call means for a non-idempotent remote write, and a bound on how long cancellation itself may take.
Two of them were not this extension's to decide. PSR-18 returns a response and never a handle, and Guzzle's synchronous branch settles its promise before it exists, so cancel() is a no-op there. Reaching a real abort needs CurlMultiHandler and a reference to the loop it runs on — both private to nr-vault, and rightly so: going around it would drop credential injection, the request-time SSRF DNS pin and the audit write. nr-vault 0.16.0 supplies the primitive.
Decision
Cancellation crosses the boundary as a caller-owned signal, never as a promise. nr-vault's CancellableHttpClientInterface takes a CancellationSignalInterface — one method, isCancelled() — and returns a PSR-7 response. No transport type crosses it in either direction. This extension therefore never holds a Guzzle promise, a handler or a client it could tick itself, and a later transport change inside nr-vault is not a change here. That shape is nr-vault's decision; adopting it rather than asking for a handle is ours.
Feature-detected, not version-gated: the interface is additive, so McpHttpTransport asks $client instanceof CancellableHttpClientInterface && $client->supportsCancellation() and otherwise sends exactly as before. A platform without curl_multi and a caller outside a persisted run take the same blocking path they always took.
The signal asks the same question the step boundary asks. AgentRunCancellationSignal reads the run row and compares its status to CANCELLED — the identical probe AgentRunExecutor runs between steps. One definition of "cancelled", not a second one beside it that can drift.
Three properties follow from the poll rate and from the interface's contract, and each is pinned by a test:
Throttled. nr-vault polls up to ten times a second per in-flight request. The row is read at most once per second, so observing a state an operator changes by hand costs one indexed read per second rather than ten.
Fail-soft. isCancelled() must not throw: an exception would escape mid-transfer, after the credential had gone out. It cannot — AgentRunPersister::findRun() catches Throwable and answers null, which reads as "not cancelled". A store hiccup must never fabricate a cancellation either, which is the rule the executor's own probe already follows.
Monotonic. Once true it stays true without reading again. A run that somehow left CANCELLED cannot un-cancel a transfer already torn down, and re-reading would only add a way to answer differently twice about one transfer.
The first call always reads. nr-vault asks before it touches the secret and refuses there under http_call_cancelled_before_send, so a signal that opened its window at construction would answer false on entry and let a credential go out for a run that was already cancelled — turning the cheap case into the expensive one.
The bound on cancellation is about a second. One second of signal throttle plus nr-vault's tick interval of a tenth, with its wall-clock budget as the backstop that only fires if the handler misbehaves. That is the answer to the third question ADR-161 left open, and it is the difference between ending a call and waiting out a 45-second deadline.
A half-sent ``tools/call`` is not retried, and nothing here pretends to know whether the write landed. Every imported MCP tool declares NON_IDEMPOTENT_WRITE, so a transfer torn down mid-flight may or may not have performed a remote mutation. Three existing rules already answer what follows, and this record changes none of them:
CANCELLED is terminal. AgentRunPersister::cancel() drops the run's resumable state and refuses a run that is already terminal, so no resume, approval or retry path in this extension accepts one.
The fence stays stamped. The cancellation branch persists the step and stops; it does not reach the clear in AgentRunExecutor::renewOrClearFence(), so the row keeps the pending-effect mark that AgentRuntime::mayRetryAfterFence() reads as "do not retry".
The operator gets rows rather than a guess. nr-vault distinguishes the two cases itself: http_call_cancelled when the transfer was in flight, so the credential went out, and http_call_cancelled_before_send when the signal was already true on entry, so no secret was read and nothing egressed. This extension claims neither; the run's own step carries the cancelled-call text and nr-vault's row says which of the two it was.
What is deliberately NOT decided here is compensation. Asking the remote server what happened, or undoing it, needs a protocol MCP does not have.
The tool level still reports this as a failed tool result. The transport raises its own McpTransportException variant and McpTool::execute() returns it as ToolResult::error(), the way it returns every transport fault. The text says cancelled rather than failed, because it is the sentence the model sees and the server may have been perfectly healthy. Giving the tool level an outcome of its own — a cancellation that is not an error — is a separate change with its own effect on the frozen surface, and belongs in its own record.
Consequences
netresearch/nr-vault is required at ^0.16.0.
McpClient::callTool(), McpHttpTransport::call() and McpHttpTransport::notify() take a nullable cancellation signal as their last argument. Neither class is @api; the frozen surface does not move.
McpToolProvider carries an AgentRunCancellationSignalFactory so McpTool — built by hand per catalogue row — gains one argument rather than two.
ADR-161's cancellation row and its decision 3 are amended: the suite now asserts the seam exists, in exactly the shape above, and still fails if a third one appears.
The handshake carries the signal too, both legs of it: initialize and the notifications/initialized that confirms it are full round trips to the same server under the same operation deadline, so leaving either out would keep the stall one leg earlier. That is why the seam list has three entries and not one.