-
Notifications
You must be signed in to change notification settings - Fork 2k
feat(client): Client.listen() and listChanged auto-open on modern connections #2322
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 5e65543
feat(client): Client.listen() + McpSubscription + listChanged auto-op…
felixweinberger bf5dbbf
docs(examples): runnable subscriptions/listen example pair
felixweinberger 358c318
test(e2e): subscriptions/listen cells
felixweinberger d0b46e4
chore: lint/format fixes; update integration discoverRoundtrip for A1…
felixweinberger 3c198ca
docs: migration.md entry for subscriptions/listen
felixweinberger 48c562d
fix(client): guard listen() connectivity before park; auto-open failu…
felixweinberger 3c470a4
refactor(client): centralize per-connection state reset
felixweinberger 94010ae
refactor(client): listen() driver as an explicit opening→open→closed …
felixweinberger f654626
fix(client): derive auto-open listen filter from configured ∩ server-…
felixweinberger e40c89f
fix(client): McpSubscription.close() is transport-agnostic — always a…
felixweinberger ed05d01
fix(client): per-request abort in StreamableHTTP is a clean shutdown …
felixweinberger 65d1298
fix(client): feature-detect AbortSignal.any (Node >=20 floor includes…
felixweinberger 9f5e28e
fix(client): settle() drops _listenState by the captured id, not via …
felixweinberger 178d325
fix(client): listen() honors RequestOptions.signal
felixweinberger 1b9b64a
docs: auto-open listen filter is configured ∩ server-advertised every…
felixweinberger dceef7c
fix(client): listChanged config is durable (read fresh per connect, n…
felixweinberger c1abaff
fix(client): pass through unmatched listen acks; reword server-cancel…
felixweinberger cd882dd
fix(client): settle live listen state on connection reset; reset even…
felixweinberger 7619694
test(client): cover the listen() state machine's termination paths
felixweinberger 649aff8
test(e2e): subscriptions/listen honored filter narrows against advert…
felixweinberger 28ff64b
fix(client): listen auto-open inherits only ack timeout; thread reque…
felixweinberger c72a59a
feat(client): McpSubscription.closed observes termination cause; rese…
felixweinberger 0826c2d
fix(client): listen polish — derived ack-wait signal; envelope on lis…
felixweinberger ebf896d
refactor(client): listen() driver is transport-level — drop the in-Pr…
felixweinberger b142b80
fix(client): close transport when connect-signal aborts during auto-o…
felixweinberger be1694d
fix(client): fire onRequestStreamEnd on the GET-resume 405/null-body …
felixweinberger File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: () => { | ||
| 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.'); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.