Repository navigation
feat(stdio): serve both protocol eras through the SDK's serveStdio (#1025 step 2, PR 3/5) - #1116
Merged
Merged
Conversation
…1025) The stdio transport is now served by `serveStdio(factory, { transport })` instead of a hand-wired `Server.connect(new StdioServerTransport())`. The entry owns the wire, answers a client's `server/discover` probe and pins the connection to the era its opening message chose: a probing client (Claude Code's v2 runtime, which until now recorded mcp-debugger as a legacy peer on every connect) lands on 2026-07-28 — no initialize, `subscriptions/listen` for resource notifications, routed from the pinned facade's own `sendResourceUpdated`/`sendResourceListChanged` by the SDK — and a client that opens with `initialize` gets the 2025 era exactly as before. The factory builds `createProtocolServer(core, { era, deliver: 'instance' })` per connection (and once for a probe instance a client may abandon). Shutdown closes the serving handle first (a graceful end for an open listen stream), then the core, under the existing 5 s guard; the stdin watchdog and the MCP_CONTAINER rule are unchanged. The command no longer overwrites the transport's onclose/onerror after connect, which had kept the SDK's own teardown from running. Tests: the stdio unit suite drives the injected `serveStdio` double (factory per era, options, close order, a refusing handle); an in-process suite serves the facade through the real `serveStdio` over the in-memory transport (a pinned 2026-07-28 client hears list changes on its listen stream, a 2025 client keeps resources/subscribe with coalesced pings, the handle's close detaches the sink); `tests/integration/cli/stdio-era-negotiation.test.ts` against `dist/index.js stdio` — `auto` lands on 2026-07-28 with 29 tools, a pinned client drives a full mock debug session and hears list changes, `legacy` still gets 2025-11-25 with `subscribeResource`, and a raw `server/discover` line names 2026-07-28. Gates: typecheck 0, lint, build, integration cli + self-debug 45/45, Python and HTTP e2e smokes 7/7. The npm bundle grows 4,205,542 → 4,347,620 bytes (the serving entry and its listen router). Docs: README transports bullet, getting-started (both eras, Claude Code's 7-day legacy memory), CLAUDE.md; fragment 1025-protocol-2026-stdio.added.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…g by itself (#1025) Two findings from the review of PR #1116. The serving handle and core.stop() shared the one 5 s exit guard, so a handle.close() that never settled (a subscriptions/listen stream whose consumer stopped reading) starved closeAllSessions() and left the proxy chains orphaned. The handle close is now bounded on its own (HANDLE_CLOSE_TIMEOUT_MS, 1 s) inside the guard; a late rejection is swallowed, the timeout logged at debug, and the core always gets its turn. The SDK's StdioServerTransport closes itself not only on stdin's EOF but on a stdout error (the client's end of the pipe gone) or an oversized frame, with stdin still open, and tells only its own onclose, which serveStdio owns. Nothing exited: the process lived on with a dead instance. ObservedStdioServerTransport, a subclass whose close() reports once after super.close(), runs every wire close through the same gate as a stdin EOF: exit in host mode or after client traffic, stay alive in a detached container no client ever spoke to (#633 unchanged). The shutdown's own close of the wire re-enters the idempotent shutdown. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
# Conflicts: # CLAUDE.md
…ne from what happened (#1025) A second look at two rulings made while building step 2, on JF's review. The plan called for a log line naming each modern-era client; it was dropped because a facade per request would have logged every request, leaving nothing in the server's own log to say a 2026-07-28 client was ever there (the facade's comment claimed they "are identified per request"). The 2026 era has no handshake, but every request carries the client's name, version and protocol revision in its envelope. A modern facade now registers its handlers through a registrar that hands that envelope to the core first, and the core logs "[MCP Server] Client identified" once per distinct client per process: one line for a stdio connection, one for an http client however many per-request facades serve it. Client info is optional and self-reported, so it is a bounded label for the log and nothing else, and the set of identities is capped. The three handler modules now take Pick<Server, 'setRequestHandler'>, which is all they ever used. The stdio exit line said "Stdio transport closed; MCP client disconnected" whenever the transport's close won the race, including after a stdout error or an oversized frame with the client still there. It is now worded from facts: a stdin that ended means the client left; a transport that closed itself with stdin open names the error it reported, and does not blame the client. Also: the stdio era test reads the tool count from TOOL_NAMES, and getting-started no longer says Claude Code probes on every connect two sentences before describing its seven-day legacy memory. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
debugmcpdev
added a commit
that referenced
this pull request
Nov 11, 2026
debugmcpdev
commented
Nov 11, 2026
debugmcpdev
left a comment
Collaborator
Author
There was a problem hiding this comment.
Reviewed stdio negotiation, notification routing, facade registration, transport-close handling and bounded shutdown. No blocking findings in this PR's changes. Original head 3dc6836 passed all 11 CI jobs. The reviewed idle-expiry correction from #1115 is now included through a merge from the preceding stack branch. Full CI/current-main checks will be verified before merging after #1115.
Preserve the reviewed stdio changes while incorporating merged dependency updates and the idle-policy logging fix.
debugmcpdev
commented
Nov 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PR 3 of 5 for #1025 step 2 (plan and decisions on the issue). Stacked on #1115 (PR 2); the base retargets when that merges. One commit.
The stdio transport is served through the SDK's own
serveStdio(factory, { transport })instead of a hand-wiredServer.connect(new StdioServerTransport()). The entry owns the wire, answers a client'sserver/discoverprobe and pins the connection to the era its opening message chose:initialize,subscriptions/listenfor resource notifications (routed by the SDK from the pinned facade's ownsendResourceUpdated/sendResourceListChanged),resultTypeand the cache fields stamped;initializegets the 2025 era exactly as before,resources/subscribeincluded.The factory is
createProtocolServer(core, { era, deliver: 'instance' })per connection (plus one probe instance a client may abandon forinitialize). Shutdown closes the serving handle first, then the one core, under the existing 5 s guard; the stdin watchdog and theMCP_CONTAINERrule are unchanged. The command no longer overwrites the transport'sonclose/onerrorafter connect, which had kept the SDK's own teardown from running on stdio.Tests
tests/unit/cli/stdio-command.test.ts: the injectedserveStdiodouble receives a factory that builds a facade in whichever era it is asked for, the realStdioServerTransportand anonerror; shutdown closes the handle before stopping the core; a refusing handle does not block the stop.tests/core/unit/server/protocol-facade-stdio.test.ts(new, in process overInMemoryTransport): a 2026-07-28 client lands on a modern pin, lists the 29 tools and hearsresources/list_changedon its listen stream; a 2025 client lands on a legacy pin and keepsresources/subscribewith coalesced pings; closing the handle detaches the facade's sink.tests/integration/cli/stdio-era-negotiation.test.ts(new, againstdist/index.js stdio):auto→2026-07-28, 29 tools; a pinned 2026-07-28 client drives a full mock debug session (create → breakpoint → start →wait_for_stoppaused → close) and hears list changes;legacy→2025-11-25withsubscribeResource; a rawserver/discoverline answerssupportedVersionscontaining2026-07-28.typecheck:all0, lint, check-docs, changelog, build, integrationtests/integration/cli/+ self-debug 45/45, Python stdio smoke (realresources/subscribe+resources/updated) and HTTP smoke 7/7.Sizes
npm CLI bundle
cli.mjs: 4,205,542 → 4,347,620 bytes (+142 KB: the serving entry and its listen router).Notes
mcp-logs-*carry"protocolEra"); restarting it after upgrading, or waiting it out, lets it probe again. Documented in getting-started.httpstill speaks the 2025 era only; PR 4 adds the modern leg there beside the sessionful app.Review fix pass (commit 2)
A fresh-context review of this PR found no Critical and two Important findings; both are fixed here, each RED→GREEN in
tests/unit/cli/stdio-command.test.ts:handle.close()starved the core's teardown. The serving handle andcore.stop()shared the one 5 s guard, so a handle that never settled (asubscriptions/listenstream whose consumer stopped reading) leftcloseAllSessions()unrun and the proxy chains orphaned. The handle close is now bounded on its own (HANDLE_CLOSE_TIMEOUT_MS, 1 s) inside the guard; a late rejection is swallowed, the timeout is logged at debug, and the core always gets its turn.StdioServerTransportcloses itself not only on stdin's EOF but on a stdout error (the client's end of the pipe gone) or an oversized frame — with stdin still open — and tells only its ownonclose, whichserveStdioowns. Nothing exited.ObservedStdioServerTransport(a subclass whoseclose()reports once aftersuper.close()) runs every wire close through the same gate as a stdin EOF: exit in host mode or after client traffic, stay alive in a detached container that no client ever spoke to (Docker stdio containers are orphaned when the MCP client exits (--rm never fires) #633 unchanged). The shutdown's own close of the wire re-enters an idempotent shutdown, covered by a test.Deferred minors (ledgered, not fixed here): the probe-discard path has no direct test; the pinned era is not logged server-side; a transport error is logged by both
onerrorand the facade; the "connected" log wording predatesserveStdio; the era test hard-codes 29 tools; getting-started's "probes on every connect" vs the 7-day memory wording; the Docker bundle size is not recorded.Second look (commit
53c92ccc)A 2026-07-28 client is named in the log. The plan called for it; I had dropped it because a facade per request would log every request, which left nothing in the server's own log to say a modern client was ever there. Every 2026 request carries the client's name, version and protocol revision in its envelope, so a modern facade now registers its handlers through a registrar that hands that envelope to the core first, and the core logs
[MCP Server] Client identifiedonce per distinct client per process. Client info is optional and self-reported: a bounded label for the log, nothing else.The stdio exit line says what happened. It read "Stdio transport closed; MCP client disconnected" whenever the transport's close won the race, including after a stdout error with the client still there. Now a stdin that ended means the client left, and a transport that closed itself with stdin open names the error it reported and does not blame the client. (My original ruling described this wrongly; reading the code corrected it.)
Also: the era test reads the tool count from
TOOL_NAMES, and getting-started no longer says "on every connect" two sentences before the seven-day note.Verified:
typecheck:all, lint, check-docs, changelog; pre-push hook unit 418 files / 8753 tests, integration 34 files / 165 tests (this head also carries #1115's review fix, merged up). One earlier push was blocked by an unrelated flake in the JavaScript adapter's subprocess-tree test (the debugged Node process crashed natively once; 3 of 3 alone; passed on an unchanged retry) — filed as #1119.CI on this branch head (dispatched by hand: the workflow triggers on PRs to
mainonly, so a stacked PR gets no automatic run andgh pr checksshows nothing): https://github.com/debugmcp/mcp-debugger/actions/runs/42879521960 (head3dc68367, 11 of 11 jobs green)🤖 Generated with Claude Code