Skip to content

refactor(core): extract connection-scoped state into a private Connection owner#2470

Draft
felixweinberger wants to merge 4 commits into
fweinberger/stateless-reuse-guardfrom
fweinberger/protocol-connection-extraction
Draft

refactor(core): extract connection-scoped state into a private Connection owner#2470
felixweinberger wants to merge 4 commits into
fweinberger/stateless-reuse-guardfrom
fweinberger/protocol-connection-extraction

Conversation

@felixweinberger

Copy link
Copy Markdown
Contributor

Note

Stacked on #2421 (fweinberger/stateless-reuse-guard) — only the top four commits are new here. This is a feasibility proof for maintainer review of the structural direction, not a merge candidate: if the shape looks right, the plan is to fold it into (or rebase it after) the lifecycle PR.

Extracts everything scoped to a single transport connection out of Protocol's instance fields into a private Connection owner: the transport with its wrapped callbacks, the response/progress/timeout registries, the in-flight handler abort controllers, pending debounced notifications, and the negotiated protocol version.

Motivation and Context

The five lifecycle bugs reviewed on #2421 (failed-start unwind, wedged close on a silent transport, debounced sends crossing connections, handler senders outliving their connection, stale negotiated version) share one root cause: per-connection state is smeared across ~7 instance fields with four separate teardown paths that each reset a different subset. Every fix on that PR is a guard patching one observable symptom of the same structural gap.

Giving the connection a single owner object makes the wrong states unrepresentable instead of individually guarded:

  • Atomic openConnection.open() installs callbacks, starts the transport, and fully unwinds on failure; the failed-start unwind guard is deleted.
  • Idempotent dispose — one teardown path; the close-wedge and double-teardown guards are deleted.
  • Identity-bound events — a disposed connection's transport callbacks are inert, so a stale transport cannot tear down or write into a successor connection.
  • Structural post-dispose send rejection — per-request senders capture the connection that delivered the request and fail structurally (ConnectionClosed) once it is gone; the five call-site abort gates are deleted.

The deleted guards' tests from #2421 pass unmodified against the structural version — including the pin for handlers cancelled on a still-live connection.

Commit structure (intended to be read in order):

  1. tests-first — acceptance suite pinning the behaviors any refactor must preserve, plus an expected-failing test documenting the remaining teardown gap (debounced sends crossing connections).
  2. mechanical move — state moves into Connection with zero behavior change (suite passes untouched).
  3. consolidation — teardown unified on dispose(), sends fail structurally after dispose, the expected-failing pin flips to plain.
  4. cancellation gate restore — a follow-up pass caught that the structural check alone (connection.disposed) covers connection loss but not notifications/cancelled on a live connection; the sender gates now check both, with a pin that nothing from a cancelled handler reaches the wire while the connection keeps serving fresh requests.

How Has This Been Tested?

  • Full workspace green: core-internal 1354, client 721, server 447, integration 351, e2e 44/44 files (2639 passed, 149 expected-fail legs).
  • protocol.ts branch coverage 89.22% vs 87.05% on the base branch.
  • Drove the cancellation scenario end-to-end over stdio (real spawned server): cancelling an in-flight request mid-handler leaves notify a no-op, send/elicitInput rejecting ConnectionClosed, nothing on the wire, and the same connection serving the next request; on the pre-fix commit the leak reproduces (notification delivered, server→client ping answered, elicitation prompt shown).
  • typecheck:all, lint:all (ESLint + Prettier + snippet sync) clean.

Breaking Changes

None. No public API change: the only surface touch is the additive optional Transport.protocolVersion, which the base PR already introduces for session resumption; this branch builds on it. Connection is module-private. Error shapes and messages of the replaced guards are preserved exactly.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

  • Refactor only — none of the template's change types fits; behavior is pinned to be identical to the base branch (plus the commit-4 cancellation-gate parity fix).
  • The negotiated protocol version moves onto the connection; writes before connect() land on a pending seed the next connection starts from (the serving-entry binding pattern), and a connection completing its own negotiation supersedes the seed so a post-close read cannot resurrect a stale value.
  • Observed while testing, pre-existing on main and untouched here: notifications/cancelled with requestId: 0 is dropped by the falsy guard in _oncancel, so the first request of a session (ids start at 0) cannot be cancelled. Worth a separate fix.

…ction disposal

notifications/cancelled aborts a handler while its connection stays open;
the structural disposed-connection check alone let a cancelled handler's
per-request senders (notify/send, elicitInput/requestSampling) reach the
wire. Restore the abort-signal check alongside the connection check at the
sender gates, preserving the previous error shapes: notifications resolve
as no-ops, requests reject with the typed ConnectionClosed error.

Also clear the pending negotiated-version seed once a connection completes
its own negotiation, so a post-close read cannot resurrect a stale
pre-connect seed over the connection-negotiated value.

Tests: pin cancellation-on-a-live-connection behavior (senders inert,
nothing reaches the wire, the connection still serves fresh requests),
unit-test the Connection owner's structural mechanisms (disposed send
rejection, idempotent dispose, request-funnel early reject), and cover the
seed lifecycle and the outbound _meta envelope attach seam.
@changeset-bot

changeset-bot Bot commented Jul 9, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: fc7fe83

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jul 9, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2470

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2470

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2470

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2470

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2470

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2470

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2470

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2470

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2470

commit: fc7fe83

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.

1 participant