Conversation
The MCP specification says a receiver of a cancellation notification
SHOULD stop processing the request, free its resources and "not send a
response for the cancelled request". The SDK always sends one.
`processResult` in `internal/jsonrpc2/conn.go` writes the response with
`c.write(notDone{req.ctx}, response)`, and `notDone` strips the
cancellation from the context so the write goes ahead. Nothing at
application level runs between a handler returning and that write, so no
server built on this SDK could satisfy the clause. Captured on stdio: a
client's `notifications/cancelled` at 6.306s, and the server's response
to that same request id at 6.307s.
`notDone` is there on purpose and this does not change it. A response
must still be written when the handler's context ended for some other
reason, and until now the jsonrpc2 layer could not tell the two apart:
`Connection.Cancel` is called both by the preempter that saw the peer's
notification and by `ServerSession.Close`, which cancels in-flight
`subscriptions/listen` handlers to unblock them. So the distinction is
recorded rather than guessed. `CancelFromPeer` marks the incoming
request as cancelled by the peer, and `processResult` writes no response
for such a call. `Cancel` keeps its meaning, and the listen result a
session close produces is unaffected. The flag is written and read under
the connection's `stateMu`, in the same critical section that removes
the request from `incomingByID`, so a cancellation arriving once the
response is already on its way finds nothing to mark, and no response is
ever retracted.
Suppressing the write alone would have traded a spec deviation for a
hang. The streamable HTTP transport keeps a POST's stream open until
every call it carried has been answered, so a response that never comes
leaves that request open until the client goes away. A writer that holds
per-call state can now implement `jsonrpc2.ResponseDropper` and be told
the response is not coming; `streamableServerConn` implements it by
retiring the request through the same accounting a real response goes
through, so the stream completes exactly as it would have. Writers with
no such state, stdio and the in-memory transport among them, implement
nothing and are unaffected. In JSON response mode, where messages are
buffered and flushed together, a stream whose only call was cancelled
now flushes nothing and the POST ends with an empty body, which is what
no response looks like there.
## Testing
`TestStreamableCancelledCallGetsNoResponse` in `mcp` drives the whole
path over raw HTTP: initialize, a `tools/call` whose tool parks on
`ctx.Done`, `notifications/cancelled` for that id on a second request,
then a read of the call's stream to EOF. It asserts that the stream
carries nothing and that it ends. On unmodified main it fails with the
response the server sent:
event: message
data: {"jsonrpc":"2.0","id":2,"error":{"code":0,"message":"context canceled"}}
It fakes the client with raw HTTP rather than using a `ClientSession`
deliberately: an SDK client abandons the POST as soon as it cancels, so
it never sees what the server wrote on that stream, and a stream that
never completes looks to it exactly like one that did. With the
suppression in place but `DropResponse` removed, the same test fails the
other way, on the POST never returning.
`TestCancelFromPeerSuppressesResponse` in `internal/jsonrpc2` pins the
distinction itself. A second call acts as the barrier, since handlers
run one at a time: a peer-cancelled call is answered only by the
barrier's response and is reported to the dropper, while a locally
cancelled one is still answered.
Verification on go1.26: `gofmt -l .` clean, `go vet ./...` clean, `go
test ./...` ok, `go test -race ./internal/jsonrpc2/ ./mcp/` ok.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The MCP cancellation utility says a receiver of
notifications/cancelledSHOULD stop processing the request, free its resources, and "Not send a response for the cancelled request". This SDK does the first two and always does the opposite of the third, on every transport:processResultininternal/jsonrpc2/conn.gowrites the response withc.write(notDone{req.ctx}, response), and nothing at application level runs between a handler returning and that write, so no server built on the SDK can satisfy the clause.notDoneis there on purpose and this change leaves it alone. A response must still be written when a handler's context ended for some other reason. What the spec asks for is narrower: no response when the peer asked for this particular id to be cancelled. The jsonrpc2 layer could not express that, becauseConnection.Cancelhas two callers that mean different things,canceller.Preempt, which has just read the peer's notification, andServerSession.Close, which cancels in-flightsubscriptions/listenhandlers to unblock them so the connection can drain. So the change records the distinction rather than inferring it:Connection.CancelFromPeermarks the incoming request,processResultwrites no response for a request so marked, andCancelkeeps its old meaning.The mark is written and read under the connection's
stateMu, in the same critical section that removes the request fromincomingByID. A cancellation arriving once the response has been handed to the writer therefore finds nothing to mark, and a response already on its way is never retracted. That is the race the spec's Timing Considerations section describes, and it stays resolved exactly the way it is today.Suppressing the write on its own would have traded a spec deviation for a hang. The streamable HTTP transport keeps a POST's stream open until every call it carried has been answered, so a response that never arrives leaves that HTTP request open until the client goes away. A
Writerthat holds per-call state can now implementjsonrpc2.ResponseDropperand be told the response is not coming;streamableServerConnimplements it by retiring the request through the same accounting a real response goes through, so the stream completes exactly as it would have. Writers with no such state, stdio and the in-memory transport among them, implement nothing and are unaffected.Tests
TestStreamableCancelledCallGetsNoResponse(mcp) drives the whole path over raw HTTP: initialize, atools/callwhose tool parks onctx.Done,notifications/cancelledfor that id on a second request, then a read of the call's stream to EOF. It asserts that the stream carries nothing and that it ends. Onmainit fails with the response the server sent. It fakes the client with raw HTTP rather than using aClientSessiondeliberately: an SDK client abandons the POST as soon as it cancels, so it never sees what the server wrote on that stream, and a stream that never completes looks to it exactly like one that did. I checked the other half of the test as well, by keeping the suppression and removingDropResponse: the same test then fails the other way, on the POST never returning.TestCancelFromPeerSuppressesResponse(internal/jsonrpc2) pins the distinction itself. A second call acts as the barrier, since handlers run one at a time: a peer-cancelled call is answered only by the barrier's response and is reported to the dropper, while a locally cancelled one is still answered.TestLoggingConnDropResponse(mcp) covers the one wrapper in the way. A connection asks its writer forResponseDropper, andloggingConnis the onlyConnectionwrapper in the package, so aLoggingTransportaround a streamable server would have passed the type assertion straight through to nothing: the response suppressed, the POST's stream left open until the client went away. It now forwards to the delegate when the delegate implements the interface, and does nothing when it does not.gofmt -l .is clean,go vet ./...is clean,go test ./...passes, and so doesgo test -race ./internal/jsonrpc2/ ./mcp/.What an existing user sees change
notifications/cancelledand then keeps awaiting that id will wait forever. No client in this repository does: bothcallandcancelCallretire the call locally when they send the notification, so the SDK's own client never waited for that response.subscriptions/listenthe client cancels no longer produces the emptySubscriptionsListenResult, because a client cancels a listen by sendingnotifications/cancelledfor the listen's request id. A listen ended byServerSession.Closestill produces it, since that path callsCancel, notCancelFromPeer. This one is a judgement call: I read the spec as meaning that a cancelled request gets no response at all, completion result included, but the listen handler could be exempted if you read it the other way.Content-Typeis stillapplication/json, which is worth saying out loud: the response claims a JSON body and carries none. Answering202 Acceptedwith no content type would read better to a strict client, and I will change it to that if you prefer. This path has no test of its own, unlike the SSE one; say the word and I will add it.CancelFromPeerandResponseDropperare both ininternal/jsonrpc2.Other judgement calls
DropResponsetakes no context and returns no error. There is nothing to send, and a failure could only ever mean "the stream is already gone", which is the state the call is trying to reach anyway.processResultis reordered so that nothing is lost: a handler that returns a result which fails to marshal is still reported throughinternalErrorfexactly as before, whether or not the call was cancelled. Only the write is skipped.mcpgodebugalready gates two behaviour changes of roughly this shape (blockingcancelnotify,nomethodnotfoundcodeinerror), so if you would rather the old behaviour stayed reachable while callers adjust, say so and I will add one.docs/protocol.mdcancellation section gains two sentences. I editedinternal/docs/protocol.src.mdand the generated file together rather than running weave, since the generator fetchesgolang.org/x/example; the text is plain prose with no directives, so regenerating reproduces it.Fixes #1259.