Skip to content

feat(stdio): serve both protocol eras through the SDK's serveStdio (#1025 step 2, PR 3/5) - #1116

Merged
debugmcpdev merged 7 commits into
mainfrom
feat/1025-stdio-2026
Nov 11, 2026
Merged

debugmcpdev merged 7 commits into
mainfrom
feat/1025-stdio-2026

Conversation

@debugmcpdev

@debugmcpdev debugmcpdev commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

Updated 2026-11-10 (second look: commit 53c92ccc). Two of the deferred minors listed below are now done: a 2026-07-28 client is named in the server log, and the stdio exit line is worded from what happened. See "Second look" at the end.

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-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 probes every stdio server on every connect, and until now recorded mcp-debugger as a legacy peer — lands on 2026-07-28: no initialize, subscriptions/listen for resource notifications (routed by the SDK from the pinned facade's own sendResourceUpdated / sendResourceListChanged), resultType and the cache fields stamped;
  • a client that opens with initialize gets the 2025 era exactly as before, resources/subscribe included.

The factory is createProtocolServer(core, { era, deliver: 'instance' }) per connection (plus one probe instance a client may abandon for initialize). Shutdown closes the serving handle first, then the one 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 on stdio.

Tests

  • tests/unit/cli/stdio-command.test.ts: the injected serveStdio double receives a factory that builds a facade in whichever era it is asked for, the real StdioServerTransport and an onerror; 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 over InMemoryTransport): a 2026-07-28 client lands on a modern pin, lists the 29 tools and hears resources/list_changed on its listen stream; a 2025 client lands on a legacy pin and keeps resources/subscribe with coalesced pings; closing the handle detaches the facade's sink.
  • tests/integration/cli/stdio-era-negotiation.test.ts (new, against dist/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_stop paused → close) and hears list changes; legacy → 2025-11-25 with subscribeResource; a raw server/discover line answers supportedVersions containing 2026-07-28.
  • Gates locally: typecheck:all 0, lint, check-docs, changelog, build, integration tests/integration/cli/ + self-debug 45/45, Python stdio smoke (real resources/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

  • Claude Code may keep a stdio server it once saw answer as legacy pinned that way for up to seven days (its mcp-logs-* carry "protocolEra"); restarting it after upgrading, or waiting it out, lets it probe again. Documented in getting-started.
  • http still 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:

  1. A hung handle.close() starved the core's teardown. The serving handle and core.stop() shared the one 5 s guard, so a handle that never settled (a subscriptions/listen stream whose consumer stopped reading) left closeAllSessions() 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.
  2. The wire could die with the process still alive. 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. 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 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 onerror and the facade; the "connected" log wording predates serveStdio; 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 identified once 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 main only, so a stacked PR gets no automatic run and gh pr checks shows nothing): https://github.com/debugmcp/mcp-debugger/actions/runs/42879521960 (head 3dc68367, 11 of 11 jobs green)

🤖 Generated with Claude Code

cynarlab and others added 2 commits November 9, 2026 21:06
…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

codecov Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.94737% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/server/protocol-facade.ts 95.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

cynarlab and others added 2 commits November 10, 2026 11:08
…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 debugmcpdev left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Base automatically changed from feat/1025-shared-core to main November 11, 2026 10:51
Preserve the reviewed stdio changes while incorporating merged dependency updates and the idle-policy logging fix.

@debugmcpdev debugmcpdev left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Integration review: updated against main ac504b1 after #1115 merged. The resulting 14-file diff matches the reviewed stdio changes exactly, with all dependency updates and the idle-policy logging fix retained. Fresh checks on this head must pass before merging.

@debugmcpdev
debugmcpdev merged commit cf5163b into main Nov 11, 2026
15 checks passed
@debugmcpdev
debugmcpdev deleted the feat/1025-stdio-2026 branch November 11, 2026 11:11
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.

2 participants