Skip to content

mcp: send no response for a cancelled request on 2026-07-28 stdio sessions - #1267

Open
jmrplens wants to merge 13 commits into
modelcontextprotocol:mainfrom
jmrplens:jmrp-no-response-for-a-cancelled-request
Open

jmrplens wants to merge 13 commits into
modelcontextprotocol:mainfrom
jmrplens:jmrp-no-response-for-a-cancelled-request

Conversation

@jmrplens

@jmrplens jmrplens commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

The cancellation utility of 2026-07-28 says that a server receiving notifications/cancelled SHOULD "Not send a response for the cancelled request". On that revision stdio is the transport that carries the notification: over streamable HTTP, closing the response stream is the cancellation and no notification is expected (see StreamableHTTPOptions.PropagateRequestCancellation). This SDK answered the cancelled request anyway, because processResult in internal/jsonrpc2/conn.go writes the response with notDone{req.ctx} and no application code runs in between.

This change drops that response on the stdio-style transports (StdioTransport, CommandTransport, IOTransport and InMemoryTransport) for sessions on 2026-07-28 or later. Sessions on earlier versions, the SSE transport and the streamable transport keep answering a cancelled request, which is why this updates #1259 rather than fixing it.

  • internal/jsonrpc2: Connection.CancelFromPeer is CancelCause for a cancellation the peer asked for. Before it cancels the call's context, it tells a writer that implements the new optional ResponseDropper interface which call's response to drop. It does so under the connection's lock, in the critical section that finds the request, and processResult removes the request under the same lock before it writes the response, so the writer is told either before it is handed the response or not at all; ResponseDropper documents that DropResponse runs with the lock held. The connection still writes the response once the handler returns, and dropping it is the writer's decision. Cancel and CancelCause keep their meaning, so the listens ServerSession.Close cancels still get their results. A subscriptions/listen that the client cancels with notifications/cancelled is treated like any other cancelled request: on these sessions it no longer gets its SubscriptionsListenResult.
  • mcp: canceller.Preempt calls CancelFromPeer where mcp: bind peer cancellation before request ID reuse #1390 calls CancelCause, directly rather than in a goroutine. ioConn implements DropResponse and discards the response to a dropped call when the negotiated version is 2026-07-28 or later. loggingConn forwards DropResponse and sessionUpdated to its delegate. The jsonrpc2 connection asks its writer for ResponseDropper, and ServerSession asks its connection for sessionUpdated, which is how ioConn learns the version DropResponse checks. Without the forwarders, a LoggingTransport would hide both from ioConn. That also gives a server-side LoggingTransport the session updates mcp: LoggingTransport drops session state updates, so a wrapped transport behaves differently #1328 reports missing; the client side of mcp: LoggingTransport drops session state updates, so a wrapped transport behaves differently #1328 is not covered.
  • docs/protocol.md says which transports and versions send no response.

Tests: TestCancelFromPeerTellsWriter and TestCancelFromPeerHandlerIgnoresContext in internal/jsonrpc2; TestIOConnDropResponse, TestIOConnCancelledCallGetsNoResponse (end to end over an in-memory transport wrapped in LoggingTransport) and TestLoggingConnDropResponse in mcp.

Updates #1259.

@jmrplens

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (55350a9). It was conflicting against internal/jsonrpc2/conn.go and mcp/transport.go, both of which #1255 touched when it merged, so the conflict was between this change and my own.

Two of the three resolutions are worth stating, because they are decisions rather than mechanics.

CancelFromPeer now takes a cause. #1255 added Connection.CancelCause, which records why a request's context was cancelled so the handler can read it back through context.Cause, and this PR added CancelFromPeer, which suppresses the response. Both were variants of Cancel and the merged body wanted both values, so all three now delegate to one cancelIncoming(id, cause, fromPeer). Composing them this way rather than picking one is what the two changes are each about: a peer cancellation is precisely the kind that arrives with a stated reason, so canceller.Preempt both logs the reason and passes it as the cause, and the request is still answered with no response.

TestCancellationReason needed a change, and the reason is this PR's behaviour rather than the rebase. That test sends notifications/cancelled by hand and never retired the outgoing call, which was harmless while the receiver still answered a cancelled request. With no response it waits forever and synctest reports the bubble as deadlocked. The SDK's own cancel path already retires the call before it notifies (cancelCall in mcp/transport.go), precisely because a peer that cancels is not waiting for a result, so the test now does the same thing explicitly. That it surfaced here is the interaction working as intended: the test encoded an assumption this change is removing.

go build ./..., go vet ./... and go test ./... are clean on the rebased tree.

Comment thread mcp/streamable.go Outdated
Comment on lines +1119 to +1122
if data != nil {
s.pendingJSONMessages = append(s.pendingJSONMessages, data)
}
if done && len(s.pendingJSONMessages) > 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think in case of empty data, so peer cancelled request, it should return 204 as code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and done in 1faac54, rebased onto main.

A stream that ends with nothing written on it now answers the POST with 204 No Content and no Content-Type: in JSON mode there is nothing to flush, and in SSE mode the header is likewise still unsent when no event was ever written (lastIdx < 0), so both get the 204 rather than an empty 200 under a Content-Type; a stream that already carried something ends as before. TestStreamableCancelledCallGetsNoResponse runs in both modes and asserts the status, the missing Content-Type and the empty body; the protocol doc says so too.

The SDK client itself never reads it, since its POST is cancelled together with the call. A client that keeps the POST open while cancelling from another request reads a 204 with no body, which is what "no response" looks like on the wire.

@jmrplens
jmrplens force-pushed the jmrp-no-response-for-a-cancelled-request branch from 55350a9 to 1faac54 Compare September 20, 2026 10:07
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. A stream that ends with nothing written on
it, because every call it carried was cancelled, answers the POST with
204 No Content: in JSON response mode there is nothing to flush, and in
SSE mode no event was written, so in both the header is still unsent
and an empty 200 under a Content-Type would claim a body that is not
there. A stream that already carried an event or a buffered message
ends as before.

`TestStreamableCancelledCallGetsNoResponse` in `mcp` drives the whole
path over raw HTTP, in SSE and in JSON response mode: 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 POST is answered 204 with no Content-Type and
no body, 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.27.1: `gofmt -l .` clean, `go vet ./...` clean,
`go test ./...` ok, `go test -race ./internal/jsonrpc2/ ./mcp/` ok,
staticcheck clean.
@jmrplens
jmrplens force-pushed the jmrp-no-response-for-a-cancelled-request branch from 1faac54 to 9402582 Compare September 24, 2026 20:35
@jmrplens

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (e07f0c9). It conflicted with #1232 in deliverLocked, which now calls markWrittenLocked after writing an SSE event. I kept both, and the 204 branch now tests s.lastWrite.IsZero() instead of s.lastIdx < 0. #1232 documents that field as meaning nothing has been written to the current w, so its headers are still uncommitted, which is exactly what a 204 needs. The two conditions agree today, because the keep-alive waits for the first event before it writes, but the field is the one that says what this branch depends on.

jmrplens added a commit to jmrplens/gitlab-mcp-server that referenced this pull request Sep 25, 2026
The top of the stack: what the whole-stack review still held open after
two fix rounds, one CI defect the stack itself ran into, the register
brought up to date, and every generated artifact the layers below moved,
regenerated once here.

**The review's last two findings.**

- The test that holds the individual tool Descriptions assembled at run
time (the switches in deploykeys and deploytokens, the map in pages)
checked only the tool names they spell. The rule `cmd/audit_action_ids`
applies to a constant Description also refuses a dotted ID that resolves
nowhere or only as an alias, so a misspelt ID in one of those switches
passed every gate; the review reproduced it by changing
`access.deploy_key_list_project` to `list_projekt`. The test now offers
a dotted token as an ID on the same condition as the rule, one of its
halves being one a canonical ID uses, leaves a `.git` tail alone, and
reports an alias with the canonical ID it stands for. The planted
misspelling fails it on both catalog classes. The limits paragraph in
the cmd utilities page said "those are held by a test" of two holes when
the test holds one, and says which now.
- The comment on the temporary-file refusal of a download said the file
it names is `output_path` unless the path was relative or its leaf a
link. `canonicalDownloadOutputPath` resolves every existing component
through symlinks, so a linked directory above the leaf renames it too,
the macOS temp root among them. The comment and a test row say so.

**Lint from the tree, not from a restored analysis.** The golangci-lint
job restored `~/.cache/golangci-lint` with a prefix key, and a pull
request reads its own ref's cache first. After a rebase that cache holds
the analysis of the branch's previous tree, and on 2026-09-24 it failed
a cascaded layer of this stack twice on an unused
`//nolint:contextcheck` in a file the stack never touched, until that
ref's caches were deleted. A reused result can hide a finding as easily
as it invents one. The analysis cache is no longer restored, so a lint
result depends on the tree alone; setup-go's build and module caches
stay, since their keys are content hashes. On a 4-CPU host a cold
analysis of main took 101 s against 29 s warm on the identical tree; in
CI the restored cache never matched the tree anyway, and over twenty
runs on 2026-09-24 the make step took 110 to 300 s while the
cross-platform matrix that sets the wall clock took 800 to 1150 s. The
static analysis page says the cache is not kept, and why.

**The register.** `modelcontextprotocol/go-sdk#1232` merged on
2026-09-21, so the write-deadline fix issue 1262 asked for was opened as
[modelcontextprotocol/go-sdk#1293](modelcontextprotocol/go-sdk#1293);
row 8, its section and the re-verification paragraph name it. Row 12's
section says what `modelcontextprotocol/go-sdk#1267` answers a POST
whose every call was cancelled (204, since the 20th) and that it was
rebased after `modelcontextprotocol/go-sdk#1232` conflicted in
`deliverLocked`. The client-go paragraph named two gitlab.com reviewers
with pronouns nobody here was told, and names them by handle and role
instead.

**Regenerated.** The tool snapshots (the served-prose rewrite of issue
910: tool names outside a See also clause replaced by canonical IDs),
the token footprint, the llms files, the testing reference and the
README stats. Over this tree every `check-*` target the
generated-artifacts job runs passes, as do the whole unit suite with
`-shuffle=on` and snapshot parity checked, and golangci-lint over every
build tag.

**Recorded.** The request inventory, which CI records during the
coverage job and found 47 rows short: the layers below added tests that
drive group access tokens, SAML group links, group security settings and
the other refusals the permission hints describe. Recorded again over
this tree it holds 1604 rows over 956 distinct paths in 177 packages,
every one of the 1082 catalog actions owned by a package that issued a
request, and R-PATH passes over it. And the e2e coverage, from both
Docker runs against this tree on truenas: the CE suite ran 1024 tests
with 9 skipped and none failed, the licensed EE suite 1282 with 8
skipped and none failed, and action coverage holds where it stood (CE L1
821 of 869, L2 721, L3 705; EE L1 1016 of 1089, L2 917, L3 901).

Closes #945.
jmrplens added a commit to jmrplens/gitlab-mcp-server that referenced this pull request Sep 29, 2026
I read every go-sdk issue and pull request
`docs/development/upstream-bugs.md` links, and this records what moved.
Nothing there asks anything of us, and go-sdk has cut no release since
v1.8.0, so every merge recorded is still unreleased; the maintainer's
estimate of the next one, about two months after v1.8.0, is now cited.

- **Row 11,
[modelcontextprotocol/go-sdk#1266](modelcontextprotocol/go-sdk#1266
A maintainer updated the branch from `main` on 2026-09-29 and all nine
checks passed; `main` gained two commits after it, and I updated it
again the same day with GitHub's Update branch, which kept the
maintainer's merge commit, and the checks passed again. It has had no
review since 2026-09-15.
- **Rows 8 and 12.** Both were six commits behind `main`; I updated them
from `main` on 2026-09-29 and their checks passed.
[modelcontextprotocol/go-sdk#1293](modelcontextprotocol/go-sdk#1293)
has had no review, and the one review thread on
[modelcontextprotocol/go-sdk#1267](modelcontextprotocol/go-sdk#1267)
still waits on the reviewer.
- **Row 68.**
[modelcontextprotocol/go-sdk#1299](modelcontextprotocol/go-sdk#1299)
was triaged P2 on 2026-09-28.
[modelcontextprotocol/go-sdk#1300](modelcontextprotocol/go-sdk#1300),
which another contributor opened, now conflicts with `main` and its
checks have never run. The dates of my comment and review, and of the
author's follow-up, are corrected to UTC.
- **Row 48.** The case is named under "Known limitations" of
[modelcontextprotocol/go-sdk#1275](modelcontextprotocol/go-sdk#1275),
which another contributor opened and a maintainer merged on 2026-09-18,
so the summary row and the entry say so, with the maintainer's position
on that pull request. Its discussion also showed the entry's transport
claim too wide: v1.8.0's stateful HTTP handler refuses every 2026-07-28
request but `server/discover`, so only stdio reaches the case.

## knowledge-graph

- **Rows 75 and 76,
[gitlab-org/orbit/knowledge-graph!2650](https://gitlab.com/gitlab-org/orbit/knowledge-graph/-/merge_requests/2650)
and
[gitlab-org/orbit/knowledge-graph!2651](https://gitlab.com/gitlab-org/orbit/knowledge-graph/-/merge_requests/2651).**
Both descriptions now carry `[skip pinned-version-check]`, which the
project's check asks for when a change does not affect the shape, so the
merged-results pipeline a maintainer runs will not refuse either for
want of a `query_dsl` bump. The marker sits at the top of each
description's collapsed details block, where the project's description
lint does not count it and the 2700 characters a pipeline reads still
hold it. Each merge request also offers the bump instead: row 75's a
patch bump, so clients that cache the schema by version fetch the new
text, and row 76's should the maintainers read the declared default that
branch changes as part of the contract. Both Files lists now name the
test pins.

## Checks

- `npx markdownlint-cli2 docs/development/upstream-bugs.md` passes.
- `go run ./cmd/format_md_tables/ --check` passes.
- No em dashes in the added lines.
@guglielmo-san

Copy link
Copy Markdown
Contributor

@jmrplens i added a commit on top of you code, changing the approach of your implementation. Let me know what you think of this

@jmrplens

Copy link
Copy Markdown
Contributor Author

Thanks for taking this on. I read 436501f against my version and against main, and ran it.

I agree with the direction. Releasing the POST when the notification arrives, instead of when the handler returns, is better than what I had: with mine, a handler that ignored its context kept the POST open for as long as it ran. Yours also fixes a bug in mine that I had missed: ioConn did not implement DropResponse, so on a 2025-03-26 stdio session a batch with one cancelled call never got its other answers. I have a test that hangs at my commit and passes at yours. Keeping the ID reserved until the handler returns is right too.

I found four problems, each with a test that fails at 436501f:

  1. A batch loses the answers to the calls that were not cancelled. On a 2025-03-26 streamable session, take a batch of calls 2 and 3 where the client cancels 2. The POST ends with 204 and call 3 is never answered: in SSE mode when 3 finishes after the cancellation, and in JSON mode even when 3 had already finished and its response was buffered. TestStreamableCancelledCallInBatch describes this as intended, but the client never cancelled 3, main answers it, and ioConn answers the same batch correctly, so stdio and HTTP now disagree. What I would propose keeps your early release but makes it per call: the stream records the cancelled IDs, discards only their responses, and releases the POST once every call still unanswered on it was cancelled, flushing whatever a JSON batch already buffered.

  2. A GET that resumes a cancelled call's stream never ends. This needs an event store and a protocol before 2026-07-28. The connection carrying the call's POST drops, the client cancels the call, and then a reconnect with Last-Event-ID arrives while the handler is still running. terminate left the stream registered with no writer, so acquireStream lets the GET claim it, and when the handler returns, deliverLocked takes the peerTerminated branch before it closes s.done. The GET hangs until the session ends; main ends it, with the response. Treating the stream as done in acquireStream once only cancelled calls are left on it fixes it: the GET replays what was stored and ends.

  3. ioConn.dropped keeps entries for calls that were already answered. cancelIncoming finds the request under stateMu, releases the lock, and only then calls DropResponse. If the handler's response is written in that window, the entry is never consumed. About 5,000 tools/call and notifications/cancelled pairs over an in-memory transport left one to five such entries in 6 to 10 runs out of 10 each time I tried, every one for an ID whose response was already on the wire. MCP forbids reusing an ID within a session, so this is a slow leak rather than a lost response. Marking the request under stateMu and having processResult wait for DropResponse to return before it writes (a channel on incomingRequest) left none in 25 runs.

  4. This one is mine, and both versions have it. With an event store on 2025-11-25, the priming event commits the headers without going through markWrittenLocked, so lastWrite is still zero and the 204 path calls WriteHeader after the body. net/http logs "superfluous response.WriteHeader call" and the client gets a 200 anyway. I introduced it when I keyed the 204 on lastWrite.IsZero() while rebasing onto mcp: add StreamableHTTPOptions.StreamKeepAlive for periodic SSE comments on idle streams #1232. Marking the priming write, and what acquireStream replays, as a write fixes it.

One question about scope. sseServerConn, streamableClientConn and sseClientConn are not ResponseDroppers, so the 2024-11-05 SSE server still answers a cancelled call, and a client over streamable or SSE still answers a request that the server cancelled (up to 2025-11-25 the rule is for "Receivers", so it covers both sides; 2026-07-28 narrows it to servers). Mine covered those because the decision was in jsonrpc2. If leaving them out is deliberate, for instance so that a third-party transport that keeps per-call state cannot hang, then the sentence in docs/protocol.md ("A receiver of a cancellation notification sends no response") should name the transports it holds for. If it is not, none of those three keeps per-call state, so the connection could skip the write itself for a writer that is not a ResponseDropper, as mine did. The "plain writer" case in TestCancelFromPeerTellsWriter would then expect only the barrier's response.

If you agree, I can push one commit on top of yours with the fixes for 1 to 4 and their tests, with TestStreamableCancelledCallInBatch changed to expect call 3's answer. It touches internal/jsonrpc2/conn.go and mcp/streamable.go (terminate becomes a per-call drop, plus acquireStream and the priming write).

The tests (each fails at 436501f)

For 1, in TestStreamableCancelledCallInBatch: have the slow tool close a cancelled channel after <-ctx.Done(), release the gated call right after the cancellation instead of after the POST ends, and check that its answer arrived:

 				s.cancel(2)
+				<-cancelled
+				releaseHandler()

 				got := <-call
 				if got.err != nil {
 					t.Fatalf("the batch's POST: %v", got.err)
 				}
+				if !strings.Contains(got.body, "gated result") {
+					t.Errorf("the batch's POST lost the response to the call that was not cancelled:\n%s", got.body)
+				}

At 436501f this fails in three of the four cases, every one with a 204 and an empty body: cancelled first in SSE and JSON mode, and answered first in JSON mode.

For 2, in streamable_test.go (needs bufio in the imports):

// TestStreamableCancelledCallResumed checks that a GET resuming the stream of
// a call cancelled while it was disconnected ends without the response.
func TestStreamableCancelledCallResumed(t *testing.T) {
	sawCancel := make(chan struct{})
	release := make(chan struct{})
	server := NewServer(testImpl, nil)
	server.AddTool(&Tool{Name: "slow", InputSchema: &jsonschema.Schema{Type: "object"}},
		func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) {
			if err := req.Session.NotifyProgress(ctx, &ProgressNotificationParams{ProgressToken: "tok", Progress: 1}); err != nil {
				return nil, err
			}
			<-ctx.Done()
			close(sawCancel)
			<-release
			return nil, ctx.Err()
		})
	s := newCancelTestSession(t, server, &StreamableHTTPOptions{EventStore: NewMemoryEventStore(nil)}, protocolVersion20250618)
	releaseHandler := sync.OnceFunc(func() { close(release) })
	t.Cleanup(releaseHandler)
	stream2 := func(c *streamableServerConn) *stream { return c.streams[c.requestStreams[jsonrpc2.Int64ID(2)]] }

	// Read the call's stream up to its first event, then drop the connection.
	body, err := jsonrpc2.EncodeMessage(req(2, "tools/call", &CallToolParams{Name: "slow"}))
	if err != nil {
		t.Fatal(err)
	}
	postCtx, abort := context.WithCancel(s.ctx)
	httpReq, err := http.NewRequestWithContext(postCtx, http.MethodPost, s.url, bytes.NewReader(body))
	if err != nil {
		t.Fatal(err)
	}
	httpReq.Header.Set("Content-Type", "application/json")
	httpReq.Header.Set("Accept", "application/json, text/event-stream")
	httpReq.Header.Set(sessionIDHeader, s.sessionID)
	httpReq.Header.Set(protocolVersionHeader, s.version)
	resp, err := http.DefaultClient.Do(httpReq)
	if err != nil {
		t.Fatal(err)
	}
	var lastEventID string
	for scanner := bufio.NewScanner(resp.Body); lastEventID == "" && scanner.Scan(); {
		if id, ok := strings.CutPrefix(scanner.Text(), "id: "); ok {
			lastEventID = id
		}
	}
	abort()
	resp.Body.Close()
	s.waitFor("the stream to be released", func(c *streamableServerConn) bool {
		st := stream2(c)
		if st == nil {
			return false
		}
		st.mu.Lock()
		defer st.mu.Unlock()
		return st.w == nil
	})
	s.cancel(2)
	<-sawCancel

	getReq, err := http.NewRequestWithContext(s.ctx, http.MethodGet, s.url, nil)
	if err != nil {
		t.Fatal(err)
	}
	getReq.Header.Set("Accept", "text/event-stream")
	getReq.Header.Set(sessionIDHeader, s.sessionID)
	getReq.Header.Set(protocolVersionHeader, s.version)
	getReq.Header.Set(lastEventIDHeader, lastEventID)
	resumed := make(chan postResult, 1)
	go func() {
		resp, err := http.DefaultClient.Do(getReq)
		if err != nil {
			resumed <- postResult{err: err}
			return
		}
		defer resp.Body.Close()
		body, err := io.ReadAll(resp.Body)
		resumed <- postResult{status: resp.StatusCode, body: string(body), err: err}
	}()
	s.waitFor("the GET to resume the stream or end", func(c *streamableServerConn) bool {
		st := stream2(c)
		if len(resumed) > 0 || st == nil {
			return true
		}
		st.mu.Lock()
		defer st.mu.Unlock()
		return st.w != nil
	})
	releaseHandler()
	select {
	case got := <-resumed:
		if got.err != nil || strings.Contains(got.body, `"id":2`) {
			t.Errorf("resuming the stream: %v, body:\n%s", got.err, got.body)
		}
	case <-time.After(5 * time.Second):
		t.Fatal("the GET resuming the cancelled call's stream never ended")
	}
}

For 3, in transport_test.go (needs sync and jsonschema in the imports). It races on purpose, so it is a stress test rather than a deterministic one:

type fixedTransport struct{ conn Connection }

func (t fixedTransport) Connect(context.Context) (Connection, error) { return t.conn, nil }

// TestIOConnDropResponseRace checks that a cancellation racing with the
// response leaves nothing in ioConn.dropped once the response is written.
func TestIOConnDropResponseRace(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	server := NewServer(testImpl, nil)
	server.AddTool(&Tool{Name: "fast", InputSchema: &jsonschema.Schema{Type: "object"}},
		func(context.Context, *CallToolRequest) (*CallToolResult, error) { return &CallToolResult{}, nil })
	serverT, clientT := NewInMemoryTransports()
	conn := newIOConn(serverT.rwc)
	ss, err := server.Connect(ctx, fixedTransport{conn}, nil)
	if err != nil {
		t.Fatal(err)
	}
	defer ss.Close()

	const n = 5000
	pinged := make(chan struct{})
	var answeredMu sync.Mutex
	answered := map[any]bool{} // the IDs whose response is on the wire
	go func() {
		for scanner := bufio.NewScanner(clientT.rwc); scanner.Scan(); {
			if msg, err := jsonrpc2.DecodeMessage(scanner.Bytes()); err == nil {
				if r, ok := msg.(*jsonrpc.Response); ok {
					answeredMu.Lock()
					answered[r.ID.Raw()] = true
					answeredMu.Unlock()
					if r.ID.Raw() == int64(n) {
						close(pinged)
					}
				}
			}
		}
	}()
	send := func(msg jsonrpc.Message) {
		data, err := jsonrpc2.EncodeMessage(msg)
		if err != nil {
			t.Fatal(err)
		}
		if _, err := clientT.rwc.Write(append(data, '\n')); err != nil {
			t.Fatal(err)
		}
	}
	send(req(1, methodInitialize, &InitializeParams{ProtocolVersion: protocolVersion20250618}))
	send(req(0, notificationInitialized, &InitializedParams{}))
	for i := int64(2); i < n; i++ {
		send(req(i, "tools/call", &CallToolParams{Name: "fast"}))
		send(req(0, notificationCancelled, &CancelledParams{RequestID: i}))
	}
	send(req(n, "ping", &PingParams{}))
	select {
	case <-pinged:
	case <-ctx.Done():
		t.Fatal("ping never answered")
	}
	time.Sleep(100 * time.Millisecond) // let the last cancellations finish
	conn.batchMu.Lock()
	defer conn.batchMu.Unlock()
	answeredMu.Lock()
	defer answeredMu.Unlock()
	var stale []any
	for id := range conn.dropped {
		if answered[id.Raw()] {
			stale = append(stale, id.Raw())
		}
	}
	if len(stale) > 0 {
		t.Errorf("ioConn.dropped still holds %v, whose responses were already written", stale)
	}
}

For 4, in streamable_test.go (needs log in the imports; my version fails it as well):

// TestStreamableCancelledCallPrimedStream checks that a cancelled call whose
// stream already carried a priming event does not try to answer 204.
func TestStreamableCancelledCallPrimedStream(t *testing.T) {
	started := make(chan struct{})
	server := NewServer(testImpl, nil)
	server.AddTool(&Tool{Name: "slow", InputSchema: &jsonschema.Schema{Type: "object"}},
		func(ctx context.Context, _ *CallToolRequest) (*CallToolResult, error) {
			close(started)
			<-ctx.Done()
			return nil, ctx.Err()
		})
	handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{EventStore: NewMemoryEventStore(nil)})
	t.Cleanup(handler.closeAll)
	var serverLog safeBuffer
	httpServer := httptest.NewUnstartedServer(handler)
	httpServer.Config.ErrorLog = log.New(&serverLog, "", 0)
	httpServer.Start()
	t.Cleanup(httpServer.Close)
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	t.Cleanup(cancel)
	s := &cancelTestSession{t: t, ctx: ctx, handler: handler, url: httpServer.URL, version: protocolVersion20251125}
	// The priming event commits the headers before initialize is answered,
	// so read the response to its end before going on.
	initResp, err := s.post(req(1, methodInitialize, &InitializeParams{ProtocolVersion: s.version}))
	if err != nil {
		t.Fatal(err)
	}
	io.Copy(io.Discard, initResp.Body)
	initResp.Body.Close()
	s.sessionID = initResp.Header.Get(sessionIDHeader)
	s.mustPost(req(0, notificationInitialized, &InitializedParams{}))

	call := s.postAsync(req(2, "tools/call", &CallToolParams{Name: "slow"}))
	<-started
	s.cancel(2)
	if got := <-call; got.err != nil || strings.Contains(got.body, `"id":2`) {
		t.Errorf("the cancelled call's POST: %v, body:\n%s", got.err, got.body)
	}
	if msg := string(serverLog.Bytes()); strings.Contains(msg, "superfluous") {
		t.Errorf("the server logged:\n%s", msg)
	}
}

@guglielmo-san

Copy link
Copy Markdown
Contributor

@jmrplens you are right, and i am noticing how this PR is requiring a lot of changes for a behavior that exists only in legacyProtocol (<20260728), unless it is used the stdio transport.
I was thinking about scoping this change to stdio and new ProtocolVersion, without interfering with legacy protocolVersion and its implementation.

@guglielmo-san

Copy link
Copy Markdown
Contributor

I pushed a new change that target only stdioConn on new protocolVersion, let me know if this is enough for your use case

@jmrplens jmrplens changed the title mcp: send no response for a request the peer cancelled mcp: send no response for a cancelled request on 2026-07-28 stdio sessions Oct 13, 2026
@jmrplens

Copy link
Copy Markdown
Contributor Author

Yes, this scope works for us. stdio is our main transport, and on 2026-07-28 it is the one transport that carries notifications/cancelled: over streamable HTTP, closing the response stream is the cancellation and no notification is expected. For sessions on earlier versions we already live with the late answer on our side (we log it as a cancellation rather than an error, and our docs tell clients to expect it), so leaving those as they are is fine with me.

Of the four points in my last comment, only number 3 still applies to what is left, and the narrower scope makes it worse. cancelIncoming looks the request up under stateMu and calls DropResponse only after releasing it, so when the handler's response is written in between, ioConn.dropped keeps an entry for a call that was already answered. Last time I called that a slow leak because MCP forbids reusing an ID within a session, but that is the rule up to 2025-11-25. 2026-07-28 only says a request ID must not match one the sender "has issued and not yet received a response for", so a client that has its answer may send a new request with the same ID, and Write then discards the response to that new request, which nobody cancelled. The SDK's own client never reuses an ID, so it does not hit this; a client that does gets no answer.

I moved the stress test to a 2026-07-28 session (each request carrying the _meta from newProtocolMeta, no initialize). At 746a459 it left 1 to 3 such entries in 5 runs out of 6, and the test below, which then sends a new tools/call reusing one of those IDs, got no answer in 3 runs out of 3. The change I described last time fixes it on its own: mark the request under stateMu, and have processResult wait for DropResponse to return before it writes. With it, the stress test left nothing in 10 runs out of 10, the test below passed 3 runs out of 3, and go vet, go test ./internal/jsonrpc2/ ./mcp/ and go test -race on the cancellation tests pass. It is 22 added and 8 removed lines in internal/jsonrpc2/conn.go. Since this scope is exactly where reuse is allowed, I would rather it went in with this PR than after it. I can push it as one commit on top of yours, with or without the test.

#1390 changes the same line in canceller.Preempt, calling the cancellation directly instead of in a goroutine, so the two PRs conflict there and the resolution should call CancelFromPeer directly. That change alone does not close this window: processResult runs on the handler's goroutine and can remove and answer the call between the lookup and DropResponse, whichever goroutine cancelIncoming runs on. With only that change on 746a459, 2 stress runs out of 6 still left an entry and the reuse test still lost the answer in 2 runs out of 3. With both changes, neither test found anything and go test ./internal/jsonrpc2/ ./mcp/ passes.

Two smaller things:

The reuse test (a stress test like the last one; in transport_test.go, needs `sync` in the imports)
type fixedTransport struct{ conn Connection }

func (t fixedTransport) Connect(context.Context) (Connection, error) { return t.conn, nil }

// TestIOConnDroppedEntryAndReusedID checks, on a 2026-07-28 session, that a
// cancellation racing with the response does not make Write discard the
// answer to a later request that reuses the ID.
func TestIOConnDroppedEntryAndReusedID(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
	defer cancel()
	server := NewServer(testImpl, nil)
	AddTool(server, &Tool{Name: "fast"}, func(context.Context, *CallToolRequest, struct{}) (*CallToolResult, any, error) {
		return &CallToolResult{}, nil, nil
	})
	serverT, clientT := NewInMemoryTransports()
	conn := newIOConn(serverT.rwc)
	ss, err := server.Connect(ctx, fixedTransport{conn}, nil)
	if err != nil {
		t.Fatal(err)
	}
	defer ss.Close()

	var mu sync.Mutex
	answered := map[int64]int{}
	got := make(chan int64, 1<<16)
	go func() {
		for scanner := bufio.NewScanner(clientT.rwc); scanner.Scan(); {
			msg, err := jsonrpc2.DecodeMessage(scanner.Bytes())
			if r, ok := msg.(*jsonrpc.Response); ok && err == nil {
				id, _ := r.ID.Raw().(int64)
				mu.Lock()
				answered[id]++
				mu.Unlock()
				got <- id
			}
		}
	}()
	send := func(msg jsonrpc.Message) {
		data, err := jsonrpc2.EncodeMessage(msg)
		if err != nil {
			t.Fatal(err)
		}
		if _, err := clientT.rwc.Write(append(data, '\n')); err != nil {
			t.Fatal(err)
		}
	}
	waitFor := func(id int64) {
		for {
			select {
			case v := <-got:
				if v == id {
					return
				}
			case <-ctx.Done():
				t.Fatalf("no answer to %d", id)
			}
		}
	}
	meta := newProtocolMeta("")
	next := int64(1)
	for round := 0; round < 20; round++ {
		for range 5000 {
			send(req(next, "tools/call", &CallToolParams{Meta: meta, Name: "fast"}))
			send(req(0, notificationCancelled, &CancelledParams{RequestID: next}))
			next++
		}
		send(req(next, "ping", &PingParams{Meta: meta}))
		waitFor(next)
		next++
		time.Sleep(100 * time.Millisecond) // let the last cancellations finish
		var stale int64
		conn.sessionMu.Lock()
		mu.Lock()
		for id := range conn.dropped {
			if n, _ := id.Raw().(int64); answered[n] > 0 {
				stale = n
			}
		}
		mu.Unlock()
		conn.sessionMu.Unlock()
		if stale == 0 {
			continue
		}
		// The client has the response to stale, so 2026-07-28 lets it reuse the ID.
		send(req(stale, "tools/call", &CallToolParams{Meta: meta, Name: "fast"}))
		send(req(next, "ping", &PingParams{Meta: meta}))
		waitFor(next)
		time.Sleep(time.Second)
		mu.Lock()
		defer mu.Unlock()
		if answered[stale] < 2 {
			t.Errorf("the request that reused ID %d was never answered", stale)
		}
		return
	}
}

guglielmo-san added a commit that referenced this pull request Oct 13, 2026
A peer cancellation is currently scheduled in a goroutine before looking
up its request. If the old invocation finishes and the read loop admits
a replacement with the same ID first, that lookup can cancel the
replacement.

Call `CancelCause` directly from `canceller.Preempt`, binding
cancellation to the active invocation before the read loop advances.
`CancelCause` captures the request under the existing connection lock
and cancels its context; it does not wait for handler completion or
response delivery. The peer's cancellation reason is preserved. No
public API, numeric-ID behavior, or response-suppression behavior
changes.

This PR changes only `mcp/transport.go`. The dedicated
cancellation-reuse test harness is retained downstream.

Validation on Go 1.27.1, darwin/arm64:

- After removing the dedicated test: `go test -race ./mcp -run
TestCancellation -count=1 -timeout=180s` and `git diff --check` passed.
- The unchanged production fix previously passed `go test ./...
-timeout=10m`, cancellation race tests, `go vet ./...`, and `go build
./...`.
- Separate protocol conformance scripts were not run.

Related: #1267 changes this same call to `CancelFromPeer`; if that lands
first, the invocation lookup should still happen before returning from
preemption. This fix is independent of #1375 and contains no numeric
decoding or transport hardening changes.

Fixes #1389.

---------

Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
@guglielmo-san

Copy link
Copy Markdown
Contributor

Agreed, all your points should been addressed now and ready to merge

@jmrplens

Copy link
Copy Markdown
Contributor Author

Thanks. I checked ffe0f81 and the race is closed: cancelIncoming now finds the request and tells the writer in one critical section under stateMu, and processResult removes the request under the same lock before it writes, so the writer hears of the drop either before it is handed the response or not at all. On a 2026-07-28 session, with the same two tests as last time:

  • The stress test (4998 calls, each followed by notifications/cancelled) left nothing in ioConn.dropped in 10 runs out of 10. At 746a459, 8 runs out of 10 left 1 to 4 entries.
  • The reuse test found no answered ID in ioConn.dropped after 20 rounds of 5000 cancelled calls, in 5 runs out of 5. At 746a459 the request reusing the ID lost its answer in 5 runs out of 5.
  • go vet ./..., go test ./internal/jsonrpc2/ ./mcp/ and go test -race on the cancellation tests pass.

canceller.Preempt calling CancelFromPeer directly is the right resolution of the conflict with #1390. The merge did drop the two-line comment #1390 put above that call, which says why it must not be deferred, and I would keep it.

Nothing in the suite holds the new lock discipline: with DropResponse moved back out of stateMu on ffe0f81, go test ./internal/jsonrpc2/ ./mcp/ still passes. The test below does: its DropResponse blocks while the handler returns. It fails 50 runs out of 50 at 746a459 and 10 out of 10 with that change, and passes 50 out of 50 at ffe0f81. I can push it with the comment as one commit, or leave both out if you would rather merge as it is.

On #1259: the reproduction I gave there is a 2025-06-18 stdio session and a streamable HTTP POST, and both still get the response after this change, by design. What this fixes is the case #1235 reported, which quotes the 2026-07-28 stdio text and which you closed as a duplicate of #1259. Anyone rerunning #1235's snippet will still see write: for the cancelled call, because it reads the server's LoggingTransport log and loggingConn logs after its delegate returns; a LoggingTransport on the client side shows that the response never arrives. The squash takes the description, so I changed its last line to Updates #1259, said why in the scope paragraph, and added that DropResponse now runs under the lock. If you would rather not change the earlier versions at all, tell me and I will close #1259 myself with a note on what this PR changed.

The test (in internal/jsonrpc2/conn_test.go, needs fmt, slices and time in the imports)
// slowDropper is a ResponseDropper whose DropResponse waits for proceed. It
// records the drops and the responses it is given, in order.
type slowDropper struct {
	entered, proceed chan struct{}
	written          chan struct{}

	mu     sync.Mutex
	events []string
}

func (w *slowDropper) DropResponse(id ID) {
	close(w.entered)
	<-w.proceed
	w.record(fmt.Sprintf("drop %v", id.Raw()))
}

func (w *slowDropper) Write(_ context.Context, msg Message) error {
	if resp, ok := msg.(*Response); ok {
		w.record(fmt.Sprintf("write %v", resp.ID.Raw()))
		w.written <- struct{}{}
	}
	return nil
}

func (w *slowDropper) record(event string) {
	w.mu.Lock()
	defer w.mu.Unlock()
	w.events = append(w.events, event)
}

// TestCancelFromPeerDropsBeforeTheResponse checks that a writer is told to
// drop a response before it is handed that response, even when the handler
// returns while the writer is still being told.
func TestCancelFromPeerDropsBeforeTheResponse(t *testing.T) {
	w := &slowDropper{entered: make(chan struct{}), proceed: make(chan struct{}), written: make(chan struct{}, 2)}
	started, release := make(chan struct{}), make(chan struct{})
	conn, incoming := newCancelTestConn(t, w, func(context.Context) (any, error) {
		close(started)
		<-release
		return struct{}{}, nil
	})

	incoming <- mustCall(t, 1, "slow")
	<-started
	cancelled := make(chan struct{})
	go func() {
		conn.CancelFromPeer(Int64ID(1), errPeerAskedToStop)
		close(cancelled)
	}()
	<-w.entered
	close(release) // the handler returns while DropResponse runs
	writes := 0
	select {
	case <-w.written:
		writes++
	case <-time.After(100 * time.Millisecond):
	}
	close(w.proceed)
	<-cancelled
	incoming <- mustCall(t, 2, "barrier")
	for ; writes < 2; writes++ {
		<-w.written
	}

	w.mu.Lock()
	events := slices.Clone(w.events)
	w.mu.Unlock()
	if want := []string{"drop 1", "write 1", "write 2"}; !slices.Equal(events, want) {
		t.Errorf("the writer saw %q, want %q", events, want)
	}
	if err := conn.Close(); err != nil {
		t.Errorf("Close() = %v", err)
	}
}

At 746a459 it fails with the writer saw ["write 1" "drop 1" "write 2"], want ["drop 1" "write 1" "write 2"].

@guglielmo-san

Copy link
Copy Markdown
Contributor

Yes I would close #1259 with a comment specifying only stdio in new protocolVersion has been targeted, and no extra changes added to the PR

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants