Title: Serve subscriptions/listen over stdio, with era-scoped 2026 cancel silence by maxisbey · Pull Request #3120 · modelcontextprotocol/python-sdk · GitHub
Open Graph Title: Serve subscriptions/listen over stdio, with era-scoped 2026 cancel silence by maxisbey · Pull Request #3120 · modelcontextprotocol/python-sdk
X Title: Serve subscriptions/listen over stdio, with era-scoped 2026 cancel silence by maxisbey · Pull Request #3120 · modelcontextprotocol/python-sdk
Description: 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
Open Graph Description: 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...
X Description: 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&a...
Opengraph URL: https://github.com/modelcontextprotocol/python-sdk/pull/3120
X: @github
Domain: github.com
| route-pattern | /:user_id/:repository/pull/:id/files(.:format) |
| route-controller | pull_requests |
| route-action | files |
| fetch-nonce | v2:334c2b24-daa1-3d75-00dc-04422c8c1a01 |
| current-catalog-service-hash | ae870bc5e265a340912cde392f23dad3671a0a881730ffdadd82f2f57d81641b |
| request-id | D966:2CC294:1386213:1A41277:6A5D16A8 |
| html-safe-nonce | 8d5681bedfe47c9bf1902e002fa8224daa190a864dc14841d690a096ff50ae0c |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJEOTY2OjJDQzI5NDoxMzg2MjEzOjFBNDEyNzc6NkE1RDE2QTgiLCJ2aXNpdG9yX2lkIjoiNjI1OTA2NTU3ODkzNjE0NTU3NiIsInJlZ2lvbl9lZGdlIjoiaWFkIiwicmVnaW9uX3JlbmRlciI6ImlhZCJ9 |
| visitor-hmac | 6fd7eb6d78182bd1a385cb079518854de903e1f2940c04d59bda85bdf48034a2 |
| hovercard-subject-tag | pull_request:4076860844 |
| github-keyboard-shortcuts | repository,pull-request-list,pull-request-conversation,pull-request-files-changed,copilot |
| google-site-verification | Apib7-x98H0j5cPqHWwSMm6dNU4GmODRoqxLiDzdx9I |
| octolytics-url | https://collector.github.com/github/collect |
| analytics-location | / |
| fb:app_id | 1401488693436528 |
| apple-itunes-app | app-id=1477376905, app-argument=https://github.com/modelcontextprotocol/python-sdk/pull/3120/files |
| twitter:image | https://avatars.githubusercontent.com/u/224885523?s=400&v=4 |
| twitter:card | summary_large_image |
| og:image | https://avatars.githubusercontent.com/u/224885523?s=400&v=4 |
| og:image:alt | 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... |
| og:site_name | GitHub |
| og:type | object |
| hostname | github.com |
| expected-hostname | github.com |
| None | 5290d7e14309ad1e76106a9c4237bd1041517e83ea182c8ab756752cb0c6940b |
| turbo-cache-control | no-preview |
| diff-view | unified |
| go-import | github.com/modelcontextprotocol/python-sdk git https://github.com/modelcontextprotocol/python-sdk.git |
| octolytics-dimension-user_id | 182288589 |
| octolytics-dimension-user_login | modelcontextprotocol |
| octolytics-dimension-repository_id | 862584018 |
| octolytics-dimension-repository_nwo | modelcontextprotocol/python-sdk |
| octolytics-dimension-repository_public | true |
| octolytics-dimension-repository_is_fork | false |
| octolytics-dimension-repository_network_root_id | 862584018 |
| octolytics-dimension-repository_network_root_nwo | modelcontextprotocol/python-sdk |
| turbo-body-classes | logged-out env-production page-responsive full-width |
| disable-turbo | true |
| browser-stats-url | https://api.github.com/_private/browser/stats |
| browser-errors-url | https://api.github.com/_private/browser/errors |
| release | 9c975978430e9ad293956f2bbdaf153b1bd84a99 |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
Links:
Viewport: width=device-width