Skip to content

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

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

Xio-Shark wants to merge 4 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?

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

@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.

@Xio-Shark with 74c4e6f my probe returns again under immediateresponseretire=1. One sentence in the docs needs flipping first, and the branch needs a rebase: #1460 conflicts in mcp/client_test.go. With both done, from my perspective this can be merged.

$ MCPGODEBUG=immediateresponseretire=1 go test -count=1 -run TestProbeUnbufferedHandler -v ./mcp/   # export of 74c4e6f, go1.27.1
    probe_test.go:40: CallTool returned; draining 1
ok      github.com/modelcontextprotocol/go-sdk/mcp      0.008s
$ go test -count=1 -timeout 20s -run TestProbeUnbufferedHandler -v ./mcp/
    probe_test.go:42: CallTool did not return in 3s
FAIL    github.com/modelcontextprotocol/go-sdk/mcp      20.094s
$ go test -count=1 ./internal/... ./mcp/...      # probe removed
ok      github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2        0.004s
ok      github.com/modelcontextprotocol/go-sdk/mcp      32.955s
# 5 more packages ok, exit 0
$ go test -race -count=3 ./internal/jsonrpc2/
ok      github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2        1.032s
$ go vet ./internal/jsonrpc2/ ./mcp/ && gofmt -l internal mcp   # no output
# not run: the conformance scripts, Windows, macOS

blocking: internal/docs/mcpgodebug.src.md:76 (and the generated docs/mcpgodebug.md:77) says "The previous timing is incompatible". It's the new default that is, as conn.go:43 puts it and the probe above shows.

non-blocking: on streamable_test.go I'd keep the buffered channel. That test is about replay, and TestImmediateResponseRetireGODEBUG already pins the blocking handler.

@Xio-Shark
Xio-Shark force-pushed the fix/1337-notify-order branch from 74c4e6f to e6b5d25 Compare October 28, 2026 08:42
@Xio-Shark

Copy link
Copy Markdown
Author

Both done in e6b5d25.

Docs sentence flipped. internal/docs/mcpgodebug.src.md:76 now reads "The new timing is incompatible with a notification handler that waits on the return of the call it notifies about", matching conn.go:43. Regenerated with go generate ./internal/docs, so docs/mcpgodebug.md:77 carries the same wording.

Rebased onto 3a46369. The mcp/client_test.go conflict with #1460 was additive at the end of the file — TestListRootsCapabilityDisabled from upstream and TestProgressBeforeResult from this branch are both kept, nothing dropped.

Verification on the rebased head:

$ go generate ./internal/docs && git diff --stat   # no residual diff after regeneration
$ go vet ./internal/jsonrpc2/ ./mcp/ ./internal/docs/ && gofmt -l internal mcp   # no output
$ go test -count=1 ./internal/... ./mcp/...
ok  github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2  0.323s
ok  github.com/modelcontextprotocol/go-sdk/internal/docs       0.313s
ok  github.com/modelcontextprotocol/go-sdk/mcp                32.691s
# 5 more packages ok, exit 0

Not run: the conformance scripts, -race, Windows/macOS.

On the non-blocking point: streamable_test.go:440 keeps the buffered channel, so testClientReplay stays as you'd want it and TestImmediateResponseRetireGODEBUG remains the only test pinning the blocking handler.

Requesting another look when you have a moment.

@Xio-Shark
Xio-Shark force-pushed the fix/1337-notify-order branch from e6b5d25 to b766a8b Compare October 28, 2026 12:29
…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>
The MCPGODEBUG entry credited the incompatibility to the previous timing,
but it is the new default that makes a handler waiting on the return of
the call it notifies about deadlock. Matches conn.go's description of
immediateresponseretire.
@Xio-Shark
Xio-Shark force-pushed the fix/1337-notify-order branch from b766a8b to 83fec63 Compare October 29, 2026 06:22
@Xio-Shark

Copy link
Copy Markdown
Author

Both blockers are cleared in 83fec63, rebased onto c15e86b (current main, includes #1468 / #1454 / #1460).

1. Rebase. The mcp/client_test.go conflict is gone: the rebase applied with no manual resolution — #1460 touched different hunks than this branch's additions. Semantic check on the one place that matters, internal/jsonrpc2/conn.go: #1468 changed the shutdown error to ErrConnClosing and added the OnResponseWriteError hook; neither touches the response-retire path this PR changes, so the queue/releaser bookkeeping applies unchanged on top.

2. Docs. The sentence is flipped in b766a8b: internal/docs/mcpgodebug.src.md:76-79 now reads that the new timing is the incompatible one, and docs/mcpgodebug.md is regenerated from it (go generate ./internal/docs produces no diff).

streamable_test.go keeps the buffered channel, per your non-blocking note.

Local run on the rebased head (go1.27.3, darwin/arm64):

$ go build ./...                                   # exit 0
$ go vet ./internal/jsonrpc2/ ./mcp/               # exit 0
$ go test -count=1 ./internal/... ./mcp/...        # exit 0
ok  github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2    0.460s
ok  github.com/modelcontextprotocol/go-sdk/mcp                 32.815s
$ go generate ./internal/docs && git status --porcelain   # no output

Your probe should still return under MCPGODEBUG=immediateresponseretire=1 and still block by default — I did not re-run it here since it lives in your review thread, but that behavior is pinned by TestImmediateResponseRetireGODEBUG in internal/jsonrpc2/conn_test.go. Could you take another look when you get a chance?

@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.

@Xio-Shark both points are done at 83fec63, and from my side nothing is left to block a merge. The rebase changed only the client_test.go hunk of the first commit, and the probe still hangs by default and returns under the opt-out.

$ MCPGODEBUG=immediateresponseretire=1 go test -count=1 -run TestProbeUnbufferedHandler -v ./mcp/   # export of 83fec63, go1.27.1 linux/amd64
    probe_test.go:36: CallTool returned; draining 1
$ go test -count=1 -timeout 20s -run TestProbeUnbufferedHandler -v ./mcp/
    probe_test.go:38: CallTool did not return in 3s
$ go test -count=1 ./internal/... ./mcp/...      # probe removed
ok      github.com/modelcontextprotocol/go-sdk/mcp      34.709s
# 7 packages ok, exit 0
$ go test -race -count=3 ./internal/jsonrpc2/    # ok; go vet and gofmt -l print nothing; go generate ./internal/docs leaves docs/ unchanged
# conn.go at 83fec63 with half of 004eb96 undone, tests kept:
#   Retire no longer scans handlerQueue        -> TestRetireQueuedResponse hangs (timeout 60s)
#   len(handlerQueue) == 0 instead of counter  -> TestSynchronousHandlerNestedCall hangs
# not run: the conformance scripts, Windows, macOS

non-blocking: one full run failed TestStreamableTransports/JSONResponse=true;replay=false ("CreateMessage success=true, want false"). That is a flake from main: 1 in 20 runs at c15e86b, 0 in 20 at this head.

I'll leave it with you and the maintainers from here.

@Xio-Shark

Copy link
Copy Markdown
Author

Thanks @chrikrah — approval logged at 83fec63, and from the code side I consider this done. What is left is repo-side, so here it is spelled out to save a maintainer the dig.

Why reviewDecision still says REVIEW_REQUIRED with an APPROVED review present. The go/source-security L3 - full protection ruleset on main requires 1 approving review from a user with write access; an approval from a contributor does not satisfy required_approving_review_count, so a maintainer approval is still needed.

Why there are no status checks at all. Fork PRs need a maintainer to approve the workflow run, and all four runs on this head are sitting at action_required, so none of the 7 required contexts has ever been produced:

Docs Check          https://github.com/modelcontextprotocol/go-sdk/actions/runs/40828827292
Conformance Tests   https://github.com/modelcontextprotocol/go-sdk/actions/runs/40828827275
Test                https://github.com/modelcontextprotocol/go-sdk/actions/runs/40828827293
CodeQL Advanced     https://github.com/modelcontextprotocol/go-sdk/actions/runs/40828827441

Approving those four runs produces the contexts the ruleset wants (lint, test (1.25), test (1.26), race-test from Test; client-conformance and server-conformance from Conformance Tests; CodeQL from CodeQL Advanced).

What I could verify locally on 83fec63 (go1.27.3, darwin/arm64), since I cannot start the matrix here:

$ gofmt -l .                    # clean
$ go vet ./...                  # exit 0
$ go test -race -count=1 ./...  # exit 0, every package ok (mcp 36.576s)

Not reproducible locally: the 1.25 / 1.26 matrix, staticcheck v0.6.1, the conformance scripts and CodeQL — those need the workflows.

Two mechanical warnings for whoever merges: dismiss_stale_reviews_on_push and require_last_push_approval are both on, so I will not push again — a further push would void the approval that just landed. Same for an Update branch click if strict status checks demand it: that also pushes, so it is cheaper to merge as-is once the runs above go green.

The only judgement call still open is the one from @chrikrah's Oct 22 review that never got a maintainer answer: @nahapetyan-serob, does this ship with the MCPGODEBUG=immediateresponseretire=1 opt-out (current shape, one knob next to blockingcancelnotify), or do you want responses to stay immediate behind a barrier? Everything else — ordering, tests, docs — is settled and reviewed.

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