Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
586de50
feat(core): in-Protocol park primitive for the listen driver
felixweinberger Jun 18, 2026
5e65543
feat(client): Client.listen() + McpSubscription + listChanged auto-op…
felixweinberger Jun 18, 2026
bf5dbbf
docs(examples): runnable subscriptions/listen example pair
felixweinberger Jun 18, 2026
358c318
test(e2e): subscriptions/listen cells
felixweinberger Jun 18, 2026
d0b46e4
chore: lint/format fixes; update integration discoverRoundtrip for A1…
felixweinberger Jun 18, 2026
3c198ca
docs: migration.md entry for subscriptions/listen
felixweinberger Jun 18, 2026
48c562d
fix(client): guard listen() connectivity before park; auto-open failu…
felixweinberger Jun 18, 2026
3c470a4
refactor(client): centralize per-connection state reset
felixweinberger Jun 18, 2026
94010ae
refactor(client): listen() driver as an explicit opening→open→closed …
felixweinberger Jun 18, 2026
f654626
fix(client): derive auto-open listen filter from configured ∩ server-…
felixweinberger Jun 18, 2026
e40c89f
fix(client): McpSubscription.close() is transport-agnostic — always a…
felixweinberger Jun 18, 2026
ed05d01
fix(client): per-request abort in StreamableHTTP is a clean shutdown …
felixweinberger Jun 18, 2026
65d1298
fix(client): feature-detect AbortSignal.any (Node >=20 floor includes…
felixweinberger Jun 18, 2026
9f5e28e
fix(client): settle() drops _listenState by the captured id, not via …
felixweinberger Jun 18, 2026
178d325
fix(client): listen() honors RequestOptions.signal
felixweinberger Jun 18, 2026
1b9b64a
docs: auto-open listen filter is configured ∩ server-advertised every…
felixweinberger Jun 18, 2026
dceef7c
fix(client): listChanged config is durable (read fresh per connect, n…
felixweinberger Jun 18, 2026
c1abaff
fix(client): pass through unmatched listen acks; reword server-cancel…
felixweinberger Jun 18, 2026
cd882dd
fix(client): settle live listen state on connection reset; reset even…
felixweinberger Jun 18, 2026
7619694
test(client): cover the listen() state machine's termination paths
felixweinberger Jun 18, 2026
649aff8
test(e2e): subscriptions/listen honored filter narrows against advert…
felixweinberger Jun 18, 2026
28ff64b
fix(client): listen auto-open inherits only ack timeout; thread reque…
felixweinberger Jun 18, 2026
c72a59a
feat(client): McpSubscription.closed observes termination cause; rese…
felixweinberger Jun 18, 2026
0826c2d
fix(client): listen polish — derived ack-wait signal; envelope on lis…
felixweinberger Jun 18, 2026
ebf896d
refactor(client): listen() driver is transport-level — drop the in-Pr…
felixweinberger Jun 18, 2026
b142b80
fix(client): close transport when connect-signal aborts during auto-o…
felixweinberger Jun 18, 2026
be1694d
fix(client): fire onRequestStreamEnd on the GET-resume 405/null-body …
felixweinberger Jun 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/subscriptions-listen-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/core': minor
'@modelcontextprotocol/client': minor
---

`Client.listen(filter)` opens a `subscriptions/listen` stream on a 2026-07-28-era connection, resolving once the server's acknowledged notification arrives with an `McpSubscription { honoredFilter, close(), closed }`. `closed` is a `Promise<'local' | 'remote'>` that resolves exactly once when the subscription terminates (`'local'` = you called `close()`; `'remote'` = the server cancelled, the stream ended, or the transport dropped — re-listen if you still want events) and never rejects. Change notifications delivered on the stream dispatch to the existing `setNotificationHandler` registrations — the same handlers the 2025-era unsolicited notifications fire on a legacy connection — so `listen()` is era-transparent for consumers that already register those. `close()` aborts the listen request's stream (where the transport supports it) and sends `notifications/cancelled` referencing the listen id — both, on every transport; no automatic re-listen. On a 2025-era connection `listen()` throws a typed `MethodNotSupportedByProtocolVersion` steering to `resources/subscribe` and `ClientOptions.listChanged`. `ClientOptions.listChanged` now auto-opens a listen stream on a modern connection — the filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities; auto-open is skipped (`client.autoOpenedSubscription` stays `undefined`) when that intersection is empty; otherwise the auto-opened subscription is exposed at `client.autoOpenedSubscription`. `TransportSendOptions` gains `requestSignal` (per-request abort) and `onRequestStreamEnd` (fires when a per-request response stream ends or errors for any non-deliberate reason) on the Streamable HTTP transport.
23 changes: 20 additions & 3 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1159,6 +1159,23 @@ Resolution is per field, most specific author first: for each of `ttlMs` and `ca
per-resource hint that sets only one field never suppresses the other field configured at the operation level. Configured hints are validated when they are configured — an invalid `ttlMs` (negative or non-integer) or `cacheScope` throws a `RangeError`. Responses on 2025-era
connections never carry these fields, with or without configuration.

### `subscriptions/listen` (2026-07-28): change-notification streams replace unsolicited delivery

The 2026-07-28 revision delivers `tools/prompts/resources` `list_changed` and `resources/updated` only on a `subscriptions/listen` stream the client opened — the server never sends an un-requested notification type. Both halves ship:

**Server side.** Nothing to register: the serving entries handle `subscriptions/listen` themselves. `createMcpHandler` returns `.notify.{toolsChanged, promptsChanged, resourcesChanged, resourceUpdated(uri)}` typed publish sugar over an in-process bus (supply your own
`ServerEventBus` for multi-process deployments). On stdio, `serveStdio` routes the pinned instance's existing `send*ListChanged()` calls onto the active subscriptions automatically. The 2025-era unsolicited delivery model is unchanged on legacy connections.

```typescript
const handler = createMcpHandler(() => buildServer());
// after a tool registration changes:
handler.notify.toolsChanged();
```

**Client side.** `ClientOptions.listChanged` keeps working: on a 2026-07-28 connection the SDK auto-opens a `subscriptions/listen` stream whose filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities, so the same handlers
fire on every published change (the auto-opened subscription is exposed at `client.autoOpenedSubscription` for `close()`; when the intersection is empty auto-open is skipped and `autoOpenedSubscription` stays `undefined`). `client.listen(filter)` opens a stream explicitly and resolves once the server's acknowledged notification arrives with `{ honoredFilter, close(), closed }` (where `closed` is a `Promise<'local' | 'remote'>` that resolves once on termination — `'remote'` means the server cancelled, the stream ended, or the transport dropped, so re-listen if you still want events); change notifications dispatch to the existing `setNotificationHandler`
registrations. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, request `notifications/resources/updated` via the `resourceSubscriptions` field of the listen filter instead.

### Multi round-trip requests (2026-07-28): write-once handlers and the client auto-fulfilment driver

The 2026-07-28 revision removes the server→client JSON-RPC request channel: servers obtain client input (elicitation, sampling, roots) **in-band**, by answering `tools/call`, `prompts/get`, or `resources/read` with an `input_required` result that embeds the requests, and the
Expand Down Expand Up @@ -1192,9 +1209,9 @@ has: only `tools/call` has a catch-all that wraps handler failures into `isError

**`requestState` is untrusted input — protect it yourself.** `inputRequired({ requestState })` lets a server round-trip opaque state through the client instead of holding it in memory. The SDK treats it as an opaque string end to end: the client echoes it back byte-exact and
never parses it, and the server sees the echoed value raw at `ctx.mcpReq.requestState`. The specification's requirement is the consumer's obligation: the value comes back as **attacker-controlled input**, so if it influences authorization, resource access, or business logic you
MUST integrity-protect it when minting it (for example HMAC or AEAD over the payload, bound to the principal, the originating method/parameters, and an expiry) and MUST reject state that fails verification on re-entry. The SDK does not provide or apply any sealing of its own,
but it does provide the place to put your verification: configure `ServerOptions.requestState.verify`, and the seam runs it before the handler whenever `requestState` is present — a thrown rejection answers the client with a frozen `-32602` (above the tool funnel, so it is a
real JSON-RPC error rather than an `isError` result). See `examples/server/src/multiRoundTrip.ts` for a worked HMAC example.
MUST integrity-protect it when minting it (for example HMAC or AEAD over the payload, bound to the principal, the originating method/parameters, and an expiry) and MUST reject state that fails verification on re-entry. The SDK does not provide or apply any sealing of its own, but
it does provide the place to put your verification: configure `ServerOptions.requestState.verify`, and the seam runs it before the handler whenever `requestState` is present — a thrown rejection answers the client with a frozen `-32602` (above the tool funnel, so it is a real
JSON-RPC error rather than an `isError` result). See `examples/server/src/multiRoundTrip.ts` for a worked HMAC example.

**Client side — auto-fulfilment by default.** When a call to `tools/call`, `prompts/get`, or `resources/read` on a 2026-07-28 connection answers `input_required`, the client fulfils the embedded requests through the same handlers registered with
`setRequestHandler('elicitation/create' | 'sampling/createMessage' | 'roots/list', …)` and retries the original request (fresh request id, `inputResponses`, byte-exact `requestState` echo) up to `inputRequired.maxRounds` rounds (default 10). `client.callTool()` and its siblings
Expand Down
Loading
Loading