Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 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() }`. 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` for per-request abort 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() }`; 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
1 change: 1 addition & 0 deletions examples/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Most clients expect a server to be running. Start one from [`../server/README.md
| Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) |
| URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) |
| Multi-round-trip client (2026-07-28) | Calls a write-once tool twice: default auto-fulfilment, then manual mode. | [`src/multiRoundTripClient.ts`](src/multiRoundTripClient.ts) |
| Subscriptions/listen client (2026-07-28) | `listChanged` auto-open then manual `client.listen()`; closes after a few changes. | [`src/subscriptionsListenClient.ts`](src/subscriptionsListenClient.ts) |

## URL elicitation example (server + client)

Expand Down
80 changes: 80 additions & 0 deletions examples/client/src/subscriptionsListenClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Drives the `subscriptions/listen` server example
* (`examples/server/src/subscriptionsListen.ts`) two ways on a 2026-07-28
* connection:
*
* 1. **auto-open via `ClientOptions.listChanged`** — the same option a
* 2025-era client sets; on a modern connection the SDK auto-opens a
* listen stream whose filter is the intersection of the configured
* sub-options and the server-advertised `listChanged` capabilities
* (auto-open is skipped when the intersection is empty), so the
* configured `onChanged` handlers fire on every published change;
* 2. **manual `client.listen()`** — opens a stream explicitly, registers a
* `notifications/tools/list_changed` handler the stream feeds, and closes
* after a few notifications.
*
* Start the server first, then:
*
* tsx examples/client/src/subscriptionsListenClient.ts
*/
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';

const URL = process.env.MCP_SERVER_URL ?? 'http://localhost:3000/';
const CLIENT_INFO = { name: 'subscriptions-listen-example-client', version: '1.0.0' };

async function autoOpenLeg(): Promise<void> {
console.log('--- auto-open via ClientOptions.listChanged ---');
let count = 0;
let done!: () => void;
const finished = new Promise<void>(resolve => {
done = resolve;
});
const client = new Client(CLIENT_INFO, {
versionNegotiation: { mode: 'auto' },
listChanged: {
tools: {
// autoRefresh: false — automatic per-request envelope emission
// is a client-side follow-up; until then a refreshing
// listTools() on a 2026 connection needs the envelope attached
// explicitly (see the multi-round-trip example).
autoRefresh: false,
onChanged: () => {
Comment thread
felixweinberger marked this conversation as resolved.
Outdated
console.log('[client] (auto) tools/list_changed received');
if (++count >= 2) done();
}
}
}
});
await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL)));
console.log(
`[client] (auto) connected (${client.getNegotiatedProtocolVersion()}); auto-opened filter:`,
client.autoOpenedSubscription?.honoredFilter
);
await finished;
await client.autoOpenedSubscription?.close();
await client.close();
}

async function manualLeg(): Promise<void> {
console.log('--- manual client.listen() ---');
const client = new Client(CLIENT_INFO, { versionNegotiation: { mode: 'auto' } });
let count = 0;
let done!: () => void;
const finished = new Promise<void>(resolve => {
done = resolve;
});
client.setNotificationHandler('notifications/tools/list_changed', () => {
console.log('[client] (manual) tools/list_changed received');
if (++count >= 2) done();
});
await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL)));
const sub = await client.listen({ toolsListChanged: true });
console.log('[client] (manual) listening; honored filter:', sub.honoredFilter);
await finished;
await sub.close();
await client.close();
}

await autoOpenLeg();
await manualLeg();
console.log('done.');
1 change: 1 addition & 0 deletions examples/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pnpm tsx src/simpleStreamableHttp.ts
| Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) |
| SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) |
| Multi-round-trip server (2026-07-28) | Write-once tool that returns `inputRequired(...)` (form + URL elicitation, requestState echo) via `createMcpHandler`. | [`src/multiRoundTrip.ts`](src/multiRoundTrip.ts) |
| Subscriptions/listen server (2026-07-28) | Publishes `tools/list_changed` to open `subscriptions/listen` streams via `handler.notify.toolsChanged()`. | [`src/subscriptionsListen.ts`](src/subscriptionsListen.ts) |

## OAuth demo flags (Streamable HTTP server)

Expand Down
61 changes: 61 additions & 0 deletions examples/server/src/subscriptionsListen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* `subscriptions/listen` change notifications served via `createMcpHandler`
* (protocol revision 2026-07-28).
*
* The handler exposes `.notify` typed publish sugar over its
* `subscriptions/listen` bus: this example calls
* `handler.notify.toolsChanged()` whenever a tool is added or removed, and
* every open `subscriptions/listen` stream that opted in to
* `toolsListChanged` receives a stamped `notifications/tools/list_changed`.
*
* Run with:
*
* tsx examples/server/src/subscriptionsListen.ts
*
* and point the paired client example at it:
*
* tsx examples/client/src/subscriptionsListenClient.ts
*/
import { createServer } from 'node:http';

import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

let extraToolEnabled = false;

function buildServer(): McpServer {
const server = new McpServer({ name: 'subscriptions-listen-example', version: '1.0.0' });

server.registerTool('greet', { description: 'Returns a greeting', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => ({
content: [{ type: 'text', text: `hello, ${name}` }]
}));
if (extraToolEnabled) {
server.registerTool(
'farewell',
{ description: 'Returns a farewell', inputSchema: z.object({ name: z.string() }) },
async ({ name }) => ({ content: [{ type: 'text', text: `goodbye, ${name}` }] })
);
}

return server;
}

// Host with the per-request HTTP entry on its default posture (2026-07-28
// served per request; 2025-era traffic served stateless from the same
// factory). The handler creates an in-process bus by default; supply your
// own `bus` for multi-process deployments.
const handler = createMcpHandler(() => buildServer());
const port = Number(process.env.PORT ?? '3000');

createServer((req, res) => void handler.node(req, res)).listen(port, () => {
console.error(`subscriptions/listen example server listening on http://localhost:${port}/`);
});

// Mutate the tool set every two seconds and publish the change to every open
// subscription stream that opted in to toolsListChanged. Safe to call when no
// subscription is open (no-op).
setInterval(() => {
extraToolEnabled = !extraToolEnabled;
console.error(`tools changed: farewell ${extraToolEnabled ? 'added' : 'removed'}`);
handler.notify.toolsChanged();
}, 2000);
Loading
Loading