Skip to content

internal/jsonrpc2: handle preceding notifications before retiring call responses - #1411

Open
Xio-Shark wants to merge 3 commits into
modelcontextprotocol:mainfrom
Xio-Shark:fix/1337-notify-order
Open

Xio-Shark wants to merge 3 commits into
modelcontextprotocol:mainfrom
Xio-Shark:fix/1337-notify-order

Conversation

@Xio-Shark

@Xio-Shark Xio-Shark commented Oct 17, 2026 •

Copy link
Copy Markdown

In readIncoming, incoming *Request messages (including notifications) are enqueued onto handlerQueue and processed sequentially by handleAsync, whereas incoming *Response messages were retired immediately on the reader goroutine. As a result, a call (such as CallTool) could return its result before the client had finished running handlers for notifications (such as notifications/progress or notifications/message) that preceded the response on the wire.

Deliver incoming *Response messages through handlerQueue when handleAsync is active so that all synchronous notification handlers queued ahead of a response complete before the awaiting call returns. When a synchronous handler itself issues a nested Call on the same connection, retire its response immediately so the handler does not block waiting on its own handlerQueue slot.

Summary

Before:
  wire:   [notif 1] -> [notif 2] -> [notif 3] -> [response]
             │            │            │             │
             ▼            ▼            ▼             ▼
          handlerQueue (handleAsync goroutine)   ac.retire(resp) immediately
                                                 (CallTool returns early!)

After:
  wire:   [notif 1] -> [notif 2] -> [notif 3] -> [response]
             │            │            │             │
             ▼            ▼            ▼             ▼
          handlerQueue: [notif 1] -> [notif 2] -> [notif 3] -> [ac.retire(resp)]
          (handleAsync runs notif 1..3 to completion, then retires response)
  • internal/jsonrpc2/conn.go:
    • Extend inFlightState.handlerQueue elements to queueItem (holding either an *incomingRequest or a pending (*AsyncCall, *Response) retirement).
    • Queue incoming *Response retirement behind pending items in handlerQueue when handleAsync is running, while preserving immediate retirement when idle or when the response belongs to a nested Call made by the currently active synchronous handler (activeSyncReleaser).
    • Add the immediateresponseretire compatibility parameter (see below), following the existing MCPGODEBUG pattern used by blockingcancelnotify and fatalnewprotocolerrors.
  • internal/docs/mcpgodebug.src.md, docs/mcpgodebug.md: document immediateresponseretire.
  • internal/docs/protocol.src.md, docs/protocol.md: state the ordering rule in the Concurrency section, including the handler must not wait on the return of the call it notifies about.
  • internal/jsonrpc2/conn_test.go:
    • Add TestResponseWaitsForPrecedingNotifications and TestSynchronousHandlerNestedCall.
  • mcp/client_test.go:
    • Add TestProgressBeforeResult covering in_memory, streamable_http, proxy progress forwarding, and notification_handler_nested_call.
  • mcp/streamable_test.go:
    • Buffer the notifications channel in testClientReplay (matching TestStreamableServerDisconnect). testClientReplay keeps its handler runnable while the caller is blocked in CallTool, which is what a client with a blocking handler now has to avoid; the buffered channel expresses that. Clients that cannot move work out of the handler can keep the old timing with MCPGODEBUG=immediateresponseretire=1.

Evidence

Scenario (1000 calls × 3 progress notifications) Before (main) After (this PR)
In-memory transport: CallTool returned before all 3 progress notifications handled 43–58 / 1000 0 / 1000
Streamable HTTP transport: CallTool returned before all 3 progress notifications handled 374–399 / 1000 0 / 1000
Proxy (upstream -> proxy -> downstream): progress notifications dropped when proxy stops on return 460–472 / 3000 0 / 3000
$ go test -race -count=3 -run 'TestResponseWaitsForPrecedingNotifications|TestSynchronousHandlerNestedCall' ./internal/jsonrpc2
ok  	github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2	0.284s

$ go test -race -count=3 -run TestProgressBeforeResult ./mcp
ok  	github.com/modelcontextprotocol/go-sdk/mcp	2.712s

$ go vet ./... && staticcheck ./...

Behavior change and the compatibility parameter

The ordering guarantee has a corollary: a notification handler must not wait for the call it is notifying about to return. Such a handler now blocks that call instead of being passed by it, and the failure mode is a deadlock with no error. Reported by @chrikrah in
the review thread, with a minimal reproduction: a ProgressNotificationHandler sending on an unbuffered channel that the caller drains after CallTool returns.

Two things follow, both in this PR:

  • The rule is documented in the Concurrency section of docs/protocol.md, so it is visible to anyone writing a handler.
  • MCPGODEBUG=immediateresponseretire=1 restores the old response timing for code that cannot move the work out of the handler.

Verified with @chrikrah's reproduction, unmodified, as a throwaway test file against this branch:

$ MCPGODEBUG=immediateresponseretire=1 go test -count=1 -run TestProbeUnbufferedHandler ./mcp
    probe_test.go:36: CallTool returned; draining 1
ok  	github.com/modelcontextprotocol/go-sdk/mcp	0.420s

$ go test -count=1 -run TestProbeUnbufferedHandler ./mcp   # default: new ordering
--- FAIL: TestProbeUnbufferedHandler (20.00s)   # CallTool never returns, as reported

TestImmediateResponseRetireGODEBUG in internal/jsonrpc2/conn_test.go covers the same shape at the transport layer: a notification handler that waits on the return of the call it notifies about. It fails in 5 s with the parameter set to 0 and passes with it set to 1, so the parameter is pinned in both directions.

Merge Danger

  • Two-way door: Internal ordering change in internal/jsonrpc2/conn.go with no wire format changes. It is observable by applications (see Behavior change above), so it ships with MCPGODEBUG=immediateresponseretire=1 as the escape hatch and is cleanly revertible.
  • Blast radius: Affects response retirement ordering on jsonrpc2.Connection when notifications or synchronous handlers are in flight on the same connection. Re-entrant calls from synchronous notification handlers (e.g., ToolListChangedHandler calling ListTools) are explicitly guarded and covered by tests.

Fixes #1337

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-18T11:39:09.983336Z f9aac4e Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Xio-Shark

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

An unknown error occurred
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@Xio-Shark

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

An unknown error occurred
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@Xio-Shark

Copy link
Copy Markdown
Author

Noting for the record: the automated Codex review has not produced a result on this PR. Three attempts (@codex review on Oct 17, Oct 18 10:24Z, Oct 18 11:10Z) all returned An unknown error occurred, and the reviewer's own summary row reports status: failed. I'll stop retrying rather than keep pinging it.

That leaves the review question I actually care about, which nobody has answered yet: is the ordering guarantee this PR implements the one you want?

The behaviour under test: when a call's response and a preceding notification are both ready, the notification must be dispatched before the call's response is retired, so a client that reacts to the notification has already observed it by the time the call result lands. "Retire" is one-shot — the queue drops a retired response so a late duplicate cannot re-deliver it — which is why ordering has to be settled before retirement rather than after.

Two points I would specifically like a second opinion on, since both are judgement calls rather than mechanical fixes:

  1. Notifications queued behind handler-owned responses. f9aac4e narrows retirement to responses that a handler is not currently writing. If a notification arrives for a response still being flushed, it is dispatched after that write completes rather than interleaved with it. I chose not to interleave because partially-written frames are not observable by the client anyway; if you read the guarantee as covering wire order instead, this is the line to change.

  2. Queued responses that never had a handler. These are retired immediately, since no handler means no ordering contract to preserve. That is the part I would most expect a maintainer to disagree with.

The diff is +489/−19 across 4 files, all inside internal/jsonrpc2. Happy to reduce it to whichever slice you would rather review first.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

An unknown error occurred
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@Xio-Shark
Xio-Shark force-pushed the fix/1337-notify-order branch from f9aac4e to a61c4a1 Compare October 21, 2026 14:06
@Xio-Shark

Copy link
Copy Markdown
Author

Rebased onto current main (6544ab3). The conflicts in internal/jsonrpc2/conn_test.go and mcp/client_test.go were purely additive — upstream added TestCancelFromPeer/dropWriter in the same place this branch adds TestResponseWaitsForPrecedingNotifications — so both tests are kept and the imports are the union of the two sides.

go test ./internal/jsonrpc2/... ./mcp/... passes after the rebase.

Separately: Test, Conformance Tests, and CodeQL Advanced are all action_required on this PR, so CI has never actually run for it. Could a maintainer approve the workflow runs?

@chrikrah chrikrah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The ordering fix is mergeable with an MCPGODEBUG opt-out for the old response timing. Without one, a client handler that waits on its own CallTool caller deadlocks after upgrade with no error, and the description calls this a "pure internal ordering change".

@Xio-Shark, the PR completes what the Concurrency section of docs/protocol.md already promises for notifications (#26). The cost is compatibility. Since 1afb0eb the read loop retired responses on its own goroutine, and #323 made notification handlers run in order, so a handler that blocked on the caller used to be harmless. Here, with ProgressNotificationHandler sending on an unbuffered channel that the caller reads after CallTool returns, CallTool never returns. The make(chan string) to make(chan string, 4) hunk in mcp/streamable_test.go patches that pattern in a test.

$ go test -count=1 -timeout 15s ./mcp/ -run TestProbeUnbufferedHandler -v   # head a61c4a1
=== RUN   TestProbeUnbufferedHandler
    probe_test.go:40: CallTool did not return in 3s
panic: test timed out after 15s
FAIL	github.com/modelcontextprotocol/go-sdk/mcp	15.061s
$ go test -count=1 ./mcp/ -run TestProbeUnbufferedHandler -v   # merge-base 6544ab3
--- PASS: TestProbeUnbufferedHandler (0.00s)
# not run: Windows, macOS
probe_test.go (package mcp)
func TestProbeUnbufferedHandler(t *testing.T) {
	ctx := context.Background()
	s := NewServer(testImpl, nil)
	AddTool(s, &Tool{Name: "work"}, func(ctx context.Context, req *CallToolRequest, _ struct{}) (*CallToolResult, any, error) {
		req.Session.NotifyProgress(ctx, &ProgressNotificationParams{ProgressToken: req.Params.GetProgressToken(), Progress: 1, Total: 1})
		return &CallToolResult{}, nil, nil
	})
	st, ct := NewInMemoryTransports()
	ss, err := s.Connect(ctx, st, nil)
	if err != nil {
		t.Fatal(err)
	}
	defer ss.Close()
	ch := make(chan float64)
	cs, err := NewClient(testImpl, &ClientOptions{
		ProgressNotificationHandler: func(_ context.Context, r *ProgressNotificationClientRequest) { ch <- r.Params.Progress },
	}).Connect(ctx, ct, nil)
	if err != nil {
		t.Fatal(err)
	}
	defer cs.Close()
	p := &CallToolParams{Name: "work"}
	p.SetProgressToken("x")
	done := make(chan struct{})
	go func() { cs.CallTool(ctx, p); close(done) }()
	select {
	case <-done:
		t.Log("CallTool returned; draining", <-ch)
	case <-time.After(3 * time.Second):
		t.Fatal("CallTool did not return in 3s")
	}
}

With conn.go reverted to 6544ab3 and the PR's tests kept, TestResponseWaitsForPrecedingNotifications, TestRetireQueuedResponse and TestProgressBeforeResult fail, so they do guard the change.

A note in the Concurrency list (internal/docs/protocol.src.md) saying handlers must not wait on the caller of an in-flight call would document the rule, but on its own it leaves existing programs hanging.

@nahapetyan-serob, since #1337 is with you: does this ship with an opt-out like blockingcancelnotify, or do responses stay immediate behind a barrier?

…l responses

In readIncoming, incoming *Request messages (including notifications) are
enqueued onto handlerQueue and processed sequentially by handleAsync,
whereas incoming *Response messages were retired immediately on the
reader goroutine. As a result, a call (such as CallTool) could return its
result before the client had finished running handlers for notifications
(such as notifications/progress or notifications/message) that preceded
the response on the wire.

Deliver incoming *Response messages through handlerQueue when
handleAsync is active so that all synchronous notification handlers
queued ahead of a response complete before the awaiting call returns.
When a synchronous handler itself issues a nested Call on the same
connection, retire its response immediately so the handler does not block
waiting on its own handlerQueue slot.

Fixes modelcontextprotocol#1337

Signed-off-by: xioshark <151488904+Xio-Shark@users.noreply.github.com>
…ctx nested calls

When a response is queued in handlerQueue behind an active handler,
readIncoming has already removed its ID from outgoingCalls. Update
Connection.Retire to also remove and retire a matching entry in
handlerQueue so caller-side retirement (such as context cancellation in
mcp/transport.go) still takes effect immediately and cannot race with
handleAsync draining the queue.

Also compare the monotonic queued-request counter (s.queuedRequests ==
ac.queuedAtStart) instead of requiring len(s.handlerQueue) == 0 when a
synchronous handler issues a nested Call without threading the handler
context, avoiding a deadlock when earlier notifications are already
waiting in handlerQueue.

Signed-off-by: xioshark <151488904+Xio-Shark@users.noreply.github.com>
The preceding-notification ordering guarantee means a response is
delivered after the notifications ahead of it on the wire have been
handled. A notification handler that waits on the return of the call it
notifies about — for example a progress handler sending on an unbuffered
channel that the caller drains after Call returns — therefore no longer
completes after upgrade, and neither does the call it waits on.

Restore the previous timing behind MCPGODEBUG=immediateresponseretire=1,
following the existing compatibility-parameter pattern. Document the rule
in the Concurrency section of the protocol docs and the parameter in the
MCPGODEBUG history.

Signed-off-by: xioshark <151488904+Xio-Shark@users.noreply.github.com>
@Xio-Shark
Xio-Shark force-pushed the fix/1337-notify-order branch from a61c4a1 to 74c4e6f Compare October 24, 2026 14:28
@Xio-Shark

Copy link
Copy Markdown
Author

@chrikrah You're right, and the framing of the PR description was wrong to call this a pure internal change. Both halves of your ask are in 74c4e6f:

MCPGODEBUG=immediateresponseretire=1 restores the old response timing, declared next to blockingcancelnotify / fatalnewprotocolerrors in internal/jsonrpc2/conn.go:

var immediateresponseretire = mcpgodebug.Value("immediateresponseretire")
...
if immediateresponseretire == "1" || !s.handlerRunning || (active != nil && (...)) {

Docs. docs/protocol.md Concurrency now states that a notification preceding a response is handled before the call returns, and that a handler must therefore not wait on the return of the call it notifies about; docs/mcpgodebug.md documents the parameter.

Your reproduction, unmodified, on this branch. I used it as a throwaway file (not committed — a test that hangs by default is not something to ship):

$ MCPGODEBUG=immediateresponseretire=1 go test -count=1 -run TestProbeUnbufferedHandler ./mcp
    probe_test.go:36: CallTool returned; draining 1
ok  	github.com/modelcontextprotocol/go-sdk/mcp	0.420s

$ go test -count=1 -run TestProbeUnbufferedHandler ./mcp    # default: new ordering
--- FAIL: TestProbeUnbufferedHandler (20.00s)               # CallTool never returns, as you reported

Committed coverage for the same shape lives at the transport layer: TestImmediateResponseRetireGODEBUG (internal/jsonrpc2/conn_test.go) blocks the notification handler on the call returning, passes with the parameter at 1, and fails in 5 s at 0.

On mcp/streamable_test.go: I kept the buffered channel, but you're right that it is not a fix for the ordering — testClientReplay is a client whose handler can run while the caller is blocked, and a real client in that shape has the same problem your probe has. Buffering states the constraint instead of hiding it; the parameter is the way out for clients that can't move the work out of the handler. If you'd rather the test exercise the deadlock-adjacent shape instead, tell me which you prefer and I'll change it.

Regarding @nahapetyan-serob's question on whether the opt-out belongs with this change: it's there now, so the decision is on the table rather than assumed. Happy to drop it if you two would rather keep the parameter list short and treat the ordering change as breaking-note-only.

Rebased onto 2e00768; go vet, gofmt, and go test ./internal/... ./mcp/... are clean. Could you take another look?

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.

Client returns a call's result before handling notifications that preceded it

2 participants