Skip to content

Serve subscriptions/listen over stdio, with era-scoped 2026 cancel silence#3120

Draft
maxisbey wants to merge 7 commits into
mainfrom
serve-listen-over-stdio
Draft

Serve subscriptions/listen over stdio, with era-scoped 2026 cancel silence#3120
maxisbey wants to merge 7 commits into
mainfrom
serve-listen-over-stdio

Conversation

@maxisbey

Copy link
Copy Markdown
Contributor

Serves subscriptions/listen over stdio (and every stream-pair transport riding the dual-era loop), and generalizes two 2026-era behaviors the fix depends on: the era lock now commits at a request's first client-visible frame, and cancelled modern requests go silent per the transport spec.

Motivation and Context

Over stdio, subscriptions/listen was rejected at the transport level with METHOD_NOT_FOUND while server/discover on the same connection still advertised resources.subscribe and the listChanged flags — a 2026 client that trusted the advertisement got an error on the exact method it names. The rejection was quickest-path containment from the original stdio-2026 work, guarding against a wedge that no longer exists: the dual-era loop dispatches handlers concurrently, ListenHandler is transport-neutral (the ack and events are request-scoped notifications, which ride the duplex pipe with the in-band subscriptionId stamp the stdio spec requires), and the client-side listen driver already speaks this shape. With the guard gone, listen dispatches through serve_one to the registered handler exactly like every other modern request — the same code path streamable HTTP uses — and the capability advertisement becomes truthful with no further change.

Two era-scoped fixes ride along because listen exposes them, but they fix the class rather than the instance:

  • Era lock at first client-visible success frame. The modern lock previously committed only when a request's dispatch returned. A listen returns only at graceful close, so a pipelined initialize behind a live stream could lock the connection legacy mid-stream. The lock now commits immediately before a classified-modern request's first non-error frame (notification, progress, or response). Plain requests lock at their response exactly as before; a listen locks at its ack; classification failures and cancelled-away requests still never lock.
  • Quiet cancellation at 2026. The stdio binding says a server MUST NOT send any further messages for a cancelled request, and no released 2026 peer expects one. After an inbound notifications/cancelled, a classified-modern request now sends nothing — no result, no error — including at loop shutdown. Legacy-era requests keep the byte-identical code-0 "Request cancelled" answer that released 2025 clients unblock on.

Completes the stdio half of subscriptions serving (#3035 / #3047 shipped the HTTP server half and the client driver; #3038 / #3040 shipped stdio dual-era serving) — the follow-up named when #2901 closed.

How Has This Been Tested?

  • Full suite with 100% branch coverage and strict-no-cover; pyright clean.
  • New raw-frame tests over the dual-era loop: the listen lifecycle (ack shape and ordering, event stamping, graceful-close result), era-lock adjacency (ack beats a pipelined initialize; a cancelled-away or rejected listen never locks; the accepted lock-without-delivered-frame corner is pinned — see below), cancel silence on modern for both listen and plain requests with the legacy answer pinned byte-identical, the shutdown drain honoring the silence commitment, request id 0 through the full lifecycle, max_subscriptions slot recycling, and a custom listen handler registered on the lowlevel server.
  • End-to-end against a real stdio subprocess with raw pipe capture: cancel silence on the wire, -32022 era-boundary answers, EOF behavior unchanged for non-cancelled in-flight requests, legacy cancel answer byte-identical.
  • Interop against typescript-sdk 2.0.0-beta.4 over stdio in both roles: the ts client opens a subscription against this server, receives events, and both close directions behave (client-side close leaves the connection healthy; server-side close delivers the graceful result). Legacy-mode ts connect still handshakes. A stdio subscriptions checklist derived from the spec (ack-first per subscription, in-band id stamping, filter enforcement at the wire level, cancel silence windows, graceful close, capability honesty, EOF exit) passes fully against this branch and against the ts beta.4 stdio server as a control.

Breaking Changes

None for the 2025 era: the handshake path is untouched and the legacy cancel answer is pinned byte-identical. Two behavior changes on the 2026 surface (unreleased protocol): cancelled modern requests no longer receive a late error response, and subscriptions/listen now serves over stdio instead of rejecting. docs/whats-new.md and the subscriptions handler docs are updated.

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

Design note on the lock timing (reviewer sign-off wanted). The lock commits immediately before the first frame's write. A peer cancel landing exactly at that write's checkpoint can leave the connection locked modern with zero delivered frames. This is a deliberate, documented deviation from lock-on-delivered-success: it exists only for mid-handler frames (a listen ack, progress), a plain request's response cannot hit it, and the resulting state is self-consistent and recoverable — only a validly-classified modern envelope reaches this path, and a follow-up initialize gets -32022 naming the modern versions, which auto-negotiating clients treat as modern evidence and re-probe. Committing only after delivery would reopen the pipelined-initialize hijack the first-frame lock exists to prevent. Documented on serve_dual_era_loop and commit_modern, pinned by three adjacency tests.

The dispatcher itself stays era-ignorant: it gains two per-request facts (cancel_answer, defaulting to the legacy answer, and an on_success_frame observer), both defaulting to existing behavior, and the era knowledge lives in the loop layer that sets them. The single-exchange HTTP and direct in-process paths need no seam — cancellation is structurally silent there already.

AI Disclaimer

maxisbey added 7 commits July 16, 2026 21:22
…tcher

Two per-request facts on the dispatch context, both defaulting to the
existing behavior:

- cancel_answer: the error written when a peer cancel interrupts the
  handler. Defaults to the legacy code-0 "Request cancelled" compat
  answer; None means total silence (the 2026 rule that nothing follows
  notifications/cancelled). Handler-origin error responses now also
  honor the silence commitment, so an error computed after the cancel
  landed (shielded handler, synchronous raise) never reaches the wire.
- on_success_frame: an observer invoked immediately before each
  non-error frame written for the request (request-scoped
  notifications, progress, the success result), with no checkpoint
  before the write. Loop layers use it to commit connection state no
  later than the request's first client-visible output.

Configured via suppress_cancel_answer / observe_success_frames, which
no-op on dispatch contexts that are already structurally silent after
a cancel (single-exchange HTTP, direct dispatch). The dispatcher stays
era-ignorant; the era knowledge lives in whoever calls the setters.
Remove the transport-level rejection of subscriptions/listen on the
stream-pair modern path: the method now dispatches through serve_one to
the registered ListenHandler like every other modern request. Over
stdio the listen request simply stays pending - the ack and every event
are request-scoped notifications on the duplex pipe, and the response
arrives only when the stream ends gracefully. server/discover's
subscription capability advertisement is now truthful on this transport.

Two era-scoped generalizations ride the dispatcher's new per-request
seams, fixing the class rather than the listen instance:

- Era lock at first client-visible success frame. A classified-modern
  request commits the modern lock immediately before its first
  non-error frame is written (notification, progress, or response),
  not when its dispatch returns. Plain requests lock at their response
  exactly as before; a listen locks at its ack, so an initialize
  pipelined behind a live stream is rejected with -32022 instead of
  flipping the connection legacy mid-stream. Classification failures
  and pre-frame cancellations still never lock, and progress written
  for a request that later fails locks correctly (the client that
  stamped a valid envelope already committed to modern).

- Quiet cancellation for every classified-modern request. After an
  inbound notifications/cancelled the server sends nothing further for
  that request - no result, no error - matching the transport spec's
  MUST NOT. Legacy-era requests keep the byte-identical code-0
  "Request cancelled" answer released 2025 clients unblock on.

The single-exchange HTTP and direct in-process paths need no seam:
cancellation is structurally silent there (stream close / caller's own
scope).
A peer cancel that lands at the first success frame's own transport-write
checkpoint - after the era lock committed, before the frame delivered -
leaves a dual-era connection locked modern with ZERO frames on the wire.
era_settles already re-checks cancel_requested at observer fire time, so
this one-checkpoint window is the entire residue, and it exists only for
mid-handler frames (the listen ack, progress): a plain request's response
cannot be cancelled away because its in-flight entry is removed, with no
checkpoint in between, before the response write starts.

This is a deliberate, documented deviation from the no-frame-no-lock
doctrine: closing it fully requires committing the lock only after the
write is known delivered, which reopens the worse pipelined-initialize
hijack the at-first-frame lock exists to prevent. The orphaned state is
self-consistent and client-recoverable - only a validly-classified modern
envelope reaches this path, a follow-up initialize is answered with -32022
naming the modern versions (released auto-negotiating clients treat that
as modern evidence and re-probe), and the connection keeps serving modern
traffic.

Document the corner on serve_dual_era_loop and commit_modern, and pin it
with raw-frame adjacency tests: the orphan itself (locked, silent,
recoverable, slot freed), the ack beating an adjacent pipelined
initialize, and the plain-request path that cannot orphan.
The dispatcher's shutdown arm wrote the CONNECTION_CLOSED drain answer for
every in-flight request it caught unwinding, including one whose peer had
already cancelled it and whose loop layer had committed it to 2026 cancel
semantics - where no frame of any kind may follow notifications/cancelled.
A silence-committed peer-cancelled request caught at loop shutdown now
stays silent.

The rule lives in one predicate on the dispatch context, must_stay_silent,
consulted by both frame-suppressing sites (_answer_error and the shutdown
drain) so it cannot drift between them; the interrupt arm remains its
complementary write side. Requests the peer did NOT cancel keep the drain
answer regardless of the commitment - the EOF-drain doctrine is unchanged.
The silence fact attached only after classification succeeded, so a
request rejected before classification (envelope-less, malformed envelope)
on a modern-locked connection could still answer after a peer cancel: the
reject runs on a task spawned after the read loop may have already
consumed an adjacent cancel. Unreachable under asyncio's FIFO scheduling -
the reject's answer write always starts before the cancel is consumed,
which is the legitimate already-answering case - but reachable under
trio's randomized scheduling and under any future checkpoint on the reject
path, where the frame written would have been the legacy code-0 answer or
a post-cancel rejection error.

Attach the fact at the top of the modern-locked branch instead, rejections
included: a rejection error is a legitimate answer, only the post-cancel
frame must be silent. On a still-unlocked connection the rule stays
classification-scoped (an unclassifiable request has not proven modern
semantics and keeps the legacy answer), initialize has no window at all
(inline dispatch parks the read loop), and a legacy straggler cancelled
after the lock keeps its legacy answer - scope documented at both
attachment sites and on serve_dual_era_loop.

With the locked branch owning the connection-scoped fact, serve_modern now
attaches its two per-request facts only while the era is unlocked: once
locked, the silence fact is already set and the era-lock observer is a
permanent no-op, so post-lock requests skip the partial allocation and the
per-frame observer invocation.
Four small hardenings around the per-request seams:

- on_success_frame is invoked through fire_success_frame, which contains a
  raising observer (log and continue, the same boundary as the subscription
  bus's listeners) so an observer bug cannot convert the success it is
  observing into an error response. The only observer today cannot raise;
  this makes the contract structural.

- suppress_cancel_answer / observe_success_frames now reach through
  NoServerRequestsDispatchContext instead of silently no-oping on it: the
  wrapper delegates wire I/O to the loop context it wraps, so per-request
  facts attached through it must land there. The wrapper moves from
  server/runner.py to shared/dispatcher.py - it was never server-specific,
  and the shared setters can only name it without a layering violation from
  there. The no-op remains for genuinely structurally-silent contexts
  (single-exchange HTTP, direct dispatch).

- The legacy code-0 cancel answer template is never written to the wire:
  the one site that writes a cancel answer sends a fresh model_copy.
  ErrorData is mutable and frames cross in-memory transports by reference,
  so sharing the instance would let a consumer's mutation corrupt every
  later cancel answer process-wide.

- ListenHandler's class docstring states the actual transport requirement
  (any dispatch context that forwards request-scoped notifications: HTTP
  SSE and the stream-pair dual-era loop) instead of naming SSE alone.
…tests

Falsy request id 0 through the full listen lifecycle (ack, era lock, and
graceful-close result all stamped with the literal 0), double-cancel and
stale-cancel idempotence with the max_subscriptions slot recycled, event
fan-out surviving the cancellation of a sibling stream, and a plain
async-function listen handler whose schema-invalid result fails loudly
without locking the era while a valid one serves and locks.
@github-actions

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3120.mcp-python-docs.pages.dev
Deployment https://6895adfa.mcp-python-docs.pages.dev
Commit 77c35e7
Triggered by @maxisbey
Updated 2026-07-17 13:32:35 UTC

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