Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 19 additions & 0 deletions .changeset/track-resource-subscriptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@modelcontextprotocol/server': minor
---

Add SDK-owned bookkeeping for 2025-era per-resource subscriptions to `McpServer`.
Declaring the `resources.subscribe` capability now activates it automatically at
`connect()`: the SDK installs the `resources/subscribe` and `resources/unsubscribe`
handlers (unless either verb already has a hand-registered handler, which wins) and
records subscribed URIs in the new per-connection `resourceSubscriptions` read-only
set. `trackResourceSubscriptions({ onSubscribe?, onUnsubscribe? })` is the explicit
configuration path — it declares the capability, installs the handlers, and attaches
veto hooks that run before the set changes and can refuse a request by throwing. A
`sendResourceUpdated` facade joins `sendResourceListChanged` so apps no longer reach
through `.server` to deliver `notifications/resources/updated`.

Behavior change: apps that declared `resources: { subscribe: true }` but never
registered subscribe handlers previously advertised the capability while answering
`-32601 Method not found`; they now get working SDK-owned subscribe/unsubscribe.
See the migration guide's "Server (McpServer / Streamable HTTP behavior)" section.
6 changes: 5 additions & 1 deletion docs/advanced/low-level-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Every serving recipe — [stdio](../serving/stdio.md), [HTTP](../serving/http.md

## Reach the low level from `McpServer`

Every `McpServer` owns its `Server` as `mcp.server`, so drop down per method, never per program. Declare the extra capability in the constructor, keep `registerTool` for the tools, and hand-register the one method `McpServer` has no API for.
Every `McpServer` owns its `Server` as `mcp.server`, so drop down per method, never per program. Declare the extra capability in the constructor, keep `registerTool` for the tools, and hand-register the method you want to answer yourself.

```ts source="../../examples/guides/advanced/low-level-server.examples.ts#lowLevel_escapeHatch"
const mcp = new McpServer({ name: 'catalog', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } });
Expand All @@ -144,6 +144,10 @@ mcp.server.setRequestHandler('resources/subscribe', async request => {

`registerTool` still answers `tools/list` and `tools/call`; `resources/subscribe` reaches the handler you wrote. On the 2026-07-28 revision resource subscriptions arrive on a `subscriptions/listen` stream the serving entries answer for you — see [Protocol versions](../protocol-versions.md).

::: info
Per-resource subscriptions specifically no longer need this escape hatch: declaring `resources: { subscribe: true }` alone makes the SDK install both subscription verbs at connect time and own the subscribed-URI set, and `trackResourceSubscriptions()` adds veto hooks — see [Resources](../servers/resources.md#serve-per-resource-subscriptions). Hand-registered handlers like the one above still win: the automatic install skips when either verb already has a handler. The mechanics shown here stay the same for any method you take over.
:::

## Decide which layer to build on

Default to `McpServer`. `registerTool`, `registerResource`, and `registerPrompt` cover everything this page rebuilt — schema derivation, argument validation, typed handler arguments — plus the bookkeeping it skipped: `listChanged` notifications, [completions](../servers/completion.md), and the list/read/get dispatch for every registry.
Expand Down
13 changes: 13 additions & 0 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1611,6 +1611,19 @@ not found`. Low-level `Server` users remain responsible for registering handlers
tests need re-baselining. To advertise without the default, set
`listChanged: false` explicitly; capabilities declared on the low-level `Server` are
advertised verbatim.
- **Declared `resources.subscribe` now activates SDK-owned subscription tracking.** At
`McpServer.connect()`, a declared `resources: { subscribe: true }` capability installs
the `resources/subscribe` and `resources/unsubscribe` handlers when neither verb has
one yet: both verbs answer `{}` instead of v1's `-32601 Method not found`, and the
subscribed URIs are recorded in the per-connection `mcpServer.resourceSubscriptions`
read-only set (cleared on every connect). Hand-registered handlers win — register
them before `connect()` (as in v1) and the automatic install skips, so existing
escape-hatch servers keep their exact behavior. To veto subscriptions, call
`mcpServer.trackResourceSubscriptions({ onSubscribe })` before the **first**
`connect()`: the hook runs before a URI is recorded and a throw is returned to the
client as the request's error (after a connect has auto-installed the handlers, the
explicit call throws its duplicate-registration error). Tests pinning `-32601` for
`resources/subscribe` on servers that declare the capability need re-baselining.
- **`WebStandardStreamableHTTPServerTransport` store-first `eventStore` semantics.**
Request-related events emitted after `closeSSE()` — and the final response when no
per-request stream is connected — are now persisted to the configured `eventStore` for
Expand Down
26 changes: 9 additions & 17 deletions docs/servers/resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,11 +214,14 @@ The notification tells connected clients to call `resources/list` again. A chang

## Serve per-resource subscriptions

A 2025-era client opts into `notifications/resources/updated` for one URI with `resources/subscribe`. The SDK routes the verb; the bookkeeping is yours: advertise the capability, track the URIs per connection, and send the notification to subscribers only.
A 2025-era client opts into `notifications/resources/updated` for one URI with `resources/subscribe`. Declaring `resources: { subscribe: true }` is the whole opt-in: at connect time the SDK installs the `resources/subscribe` and `resources/unsubscribe` handlers and records the subscribed URIs in `resourceSubscriptions`. Sending the notification to subscribers only stays your call.

```ts source="../../examples/guides/servers/resources.examples.ts#sendResourceUpdated_subscribers"
let deployStatus = 'idle';

// Declaring `resources.subscribe` is the whole opt-in: at connect time the SDK
// installs the subscribe/unsubscribe handlers and records this connection's
// subscribed URIs in `deploys.resourceSubscriptions`.
const deploys = new McpServer({ name: 'deploys', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } });

deploys.registerResource(
Expand All @@ -228,34 +231,23 @@ deploys.registerResource(
async uri => ({ contents: [{ uri: uri.href, text: deployStatus }] })
);

// The SDK routes the two verbs; which URIs this connection watches is yours to track.
const subscribedUris = new Set<string>();
deploys.server.setRequestHandler('resources/subscribe', request => {
subscribedUris.add(request.params.uri);
return {};
});
deploys.server.setRequestHandler('resources/unsubscribe', request => {
subscribedUris.delete(request.params.uri);
return {};
});

async function setDeployStatus(status: string): Promise<void> {
deployStatus = status;
if (subscribedUris.has('deploys://status')) {
await deploys.server.sendResourceUpdated({ uri: 'deploys://status' });
if (deploys.resourceSubscriptions.has('deploys://status')) {
await deploys.sendResourceUpdated({ uri: 'deploys://status' });
}
}
```

The `Set` belongs to one server instance, and each connection gets its own instance from your factory — a subscription never leaks across connections. Send `resources/updated` only to connections that subscribed; unsolicited per-resource updates are wrong on 2025-era connections.
The set belongs to one server instance, and each connection gets its own instance from your factory — a subscription never leaks across connections. Send `resources/updated` only to connections that subscribed; unsolicited per-resource updates are wrong on 2025-era connections. To refuse a subscription, call `trackResourceSubscriptions({ onSubscribe: uri => { ... } })` before connecting instead of relying on the automatic install: the hook runs before the URI is recorded, and a throw is returned to the client as the request's error (`onUnsubscribe` is symmetric). Handlers you hand-register on `server.server` always win — the automatic install skips when either verb already has one.

The pattern needs a connection that outlives the subscribe call: over stdio (and any sessionful wiring) the instance and its `Set` live as long as the connection. Behind `createMcpHandler`'s stateless legacy fallback each POST gets a fresh instance, so `resources/subscribe` succeeds and the `Set` is discarded with it — no update can ever be delivered on that posture. [Support legacy clients](../serving/legacy-clients.md) covers the serving postures.

::: info
On [2026-07-28](../protocol-versions.md) connections the verb does not exist: clients name resource URIs in their `subscriptions/listen` filter, and the entry filters delivery itself — `serveStdio` routes the instance's own `sendResourceUpdated` call onto matching streams, and `createMcpHandler` delivers what you publish on its notifier ([Notifications](./notifications.md#publish-a-resource-update-through-the-handler)).
:::

A dual-era server therefore still calls `sendResourceUpdated` on 2026-07-28 connections, where the subscribe set is always empty — gate on the connection's era as well as the set. The [`resources` example](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/resources) guards with `reqCtx.era === 'modern' || subscribedUris.has(uri)` in its factory and runs as a self-verifying pair: delivery is asserted over stdio on both eras and over HTTP on the 2026-07-28 listen path; the stateless legacy HTTP leg asserts only that the subscribe calls succeed.
A dual-era server therefore still calls `sendResourceUpdated` on 2026-07-28 connections, where the subscribe set is always empty — gate on the connection's era as well as the set. The [`resources` example](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/resources) guards with `reqCtx.era === 'modern' || server.resourceSubscriptions.has(uri)` in its factory and runs as a self-verifying pair: delivery is asserted over stdio on both eras and over HTTP on the 2026-07-28 listen path; the stateless legacy HTTP leg asserts only that the subscribe calls succeed.

## Recap

Expand All @@ -265,4 +257,4 @@ A dual-era server therefore still calls `sendResourceUpdated` on 2026-07-28 conn
- A template's `list` callback is what makes its instances appear in `resources/list`.
- Resolve file-backed paths to their real location and reject anything outside the root before reading.
- Registration changes emit `notifications/resources/list_changed` automatically.
- `resources/subscribe` bookkeeping is the server's: advertise `resources: { subscribe: true }`, track URIs per connection, send `resources/updated` to subscribers only.
- Declaring `resources: { subscribe: true }` gives you SDK-owned `resources/subscribe` bookkeeping automatically; the per-connection URI set is `resourceSubscriptions`, veto hooks come via `trackResourceSubscriptions({ onSubscribe })`, and you send `resources/updated` to subscribers only.
18 changes: 5 additions & 13 deletions examples/guides/servers/resources.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@ server.registerResource(
//#region sendResourceUpdated_subscribers
let deployStatus = 'idle';

// Declaring `resources.subscribe` is the whole opt-in: at connect time the SDK
// installs the subscribe/unsubscribe handlers and records this connection's
// subscribed URIs in `deploys.resourceSubscriptions`.
const deploys = new McpServer({ name: 'deploys', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } });

deploys.registerResource(
Expand All @@ -133,21 +136,10 @@ deploys.registerResource(
async uri => ({ contents: [{ uri: uri.href, text: deployStatus }] })
);

// The SDK routes the two verbs; which URIs this connection watches is yours to track.
const subscribedUris = new Set<string>();
deploys.server.setRequestHandler('resources/subscribe', request => {
subscribedUris.add(request.params.uri);
return {};
});
deploys.server.setRequestHandler('resources/unsubscribe', request => {
subscribedUris.delete(request.params.uri);
return {};
});

async function setDeployStatus(status: string): Promise<void> {
deployStatus = status;
if (subscribedUris.has('deploys://status')) {
await deploys.server.sendResourceUpdated({ uri: 'deploys://status' });
if (deploys.resourceSubscriptions.has('deploys://status')) {
await deploys.sendResourceUpdated({ uri: 'deploys://status' });
}
}
//#endregion sendResourceUpdated_subscribers
Expand Down
19 changes: 5 additions & 14 deletions examples/resources/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ const COUNTER_URI = 'counter://value';
let counter = 0;

function buildServer(reqCtx: McpRequestContext, publishUpdated?: (uri: string) => void): McpServer {
// Declaring `resources.subscribe` is the whole opt-in: at connect time the SDK
// installs the resources/subscribe and resources/unsubscribe handlers and
// tracks the URIs THIS connection watches in `server.resourceSubscriptions`.
const server = new McpServer(
{ name: 'resources-example', version: '1.0.0' },
{ capabilities: { resources: { subscribe: true, listChanged: true } } }
Expand Down Expand Up @@ -53,30 +56,18 @@ function buildServer(reqCtx: McpRequestContext, publishUpdated?: (uri: string) =
async uri => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: String(counter) }] })
);

// resources/subscribe bookkeeping is the application's: the SDK routes the
// two verbs, and which URIs THIS connection watches lives here.
const subscribedUris = new Set<string>();
server.server.setRequestHandler('resources/subscribe', request => {
subscribedUris.add(request.params.uri);
return {};
});
server.server.setRequestHandler('resources/unsubscribe', request => {
subscribedUris.delete(request.params.uri);
return {};
});

server.registerTool('increment', { description: `Bump ${COUNTER_URI} by one` }, async () => {
counter += 1;
if (publishUpdated) {
// Per-request serving: this instance answers one request and is gone,
// so the change is published on the entry's notifier for the listen
// streams other requests hold open.
publishUpdated(COUNTER_URI);
} else if (reqCtx.era === 'modern' || subscribedUris.has(COUNTER_URI)) {
} else if (reqCtx.era === 'modern' || server.resourceSubscriptions.has(COUNTER_URI)) {
// Connection serving (stdio): announce in-band. The entry routes it
// onto 2026-07-28 listen streams; on a 2025-era connection it goes
// only to subscribers — unsolicited per-resource updates are wrong.
await server.server.sendResourceUpdated({ uri: COUNTER_URI }).catch(() => {});
await server.sendResourceUpdated({ uri: COUNTER_URI }).catch(() => {});
}
return { content: [{ type: 'text', text: String(counter) }] };
});
Expand Down
20 changes: 6 additions & 14 deletions examples/todos-server/todos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,18 +231,10 @@ export function buildServer(reqCtx: McpRequestContext): McpServer {
}
);

// Per-resource subscriptions: 2025-era clients call resources/subscribe (tracked here so
// updates only go to subscribers); 2026-07-28 clients use a subscriptions/listen filter and
// the serving entry routes the same notification onto it.
const subscribedUris = new Set<string>();
server.server.setRequestHandler('resources/subscribe', request => {
subscribedUris.add(request.params.uri);
return {};
});
server.server.setRequestHandler('resources/unsubscribe', request => {
subscribedUris.delete(request.params.uri);
return {};
});
// Per-resource subscriptions: the declared `resources.subscribe` capability makes the SDK
// track 2025-era resources/subscribe calls in `server.resourceSubscriptions` (so updates only
// go to subscribers); 2026-07-28 clients use a subscriptions/listen filter and the serving
// entry routes the same notification onto it.

/** Tell connected clients the board changed: the resource list, and the board resource for watchers. */
const announceBoardChange = async (): Promise<void> => {
Expand All @@ -251,10 +243,10 @@ export function buildServer(reqCtx: McpRequestContext): McpServer {
// Per-request HTTP serving: cross-request delivery goes through the entry's notifier.
publishBoardChanged?.();
publishBoardUpdated('todos://board');
} else if (reqCtx.era === 'modern' || subscribedUris.has('todos://board')) {
} else if (reqCtx.era === 'modern' || server.resourceSubscriptions.has('todos://board')) {
// stdio: the serving entry routes this onto open listen subscriptions (2026-07-28);
// on 2025-era connections it goes out unsolicited, so only send it to subscribers.
await server.server.sendResourceUpdated({ uri: 'todos://board' }).catch(() => {});
await server.sendResourceUpdated({ uri: 'todos://board' }).catch(() => {});
}
};

Comment thread
claude[bot] marked this conversation as resolved.
Expand Down
9 changes: 8 additions & 1 deletion packages/core-internal/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1759,11 +1759,18 @@ export abstract class Protocol<ContextT extends BaseContext> {
this._requestHandlers.delete(method);
}

/**
* Whether a request handler is currently registered for the given method.
*/
hasRequestHandler(method: RequestMethod | string): boolean {
return this._requestHandlers.has(method);
}

/**
* Asserts that a request handler has not already been set for the given method, in preparation for a new one being automatically installed.
*/
assertCanSetRequestHandler(method: RequestMethod | string): void {
if (this._requestHandlers.has(method)) {
if (this.hasRequestHandler(method)) {
throw new Error(`A request handler for ${method} already exists, which would be overridden`);
}
}
Expand Down
20 changes: 20 additions & 0 deletions packages/server/src/server/mcp.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,26 @@ async function McpServer_sendLoggingMessage_basic(server: McpServer) {
//#endregion McpServer_sendLoggingMessage_basic
}

/**
* Example: Tracking per-resource subscriptions (2025-era `resources/subscribe`).
*/
async function McpServer_trackResourceSubscriptions_basic(server: McpServer) {
//#region McpServer_trackResourceSubscriptions_basic
server.trackResourceSubscriptions({
onSubscribe: uri => {
if (!uri.startsWith('status://')) {
throw new Error(`Subscriptions to ${uri} are not supported`);
}
}
});

// ...later, when the resource changes:
if (server.resourceSubscriptions.has('status://deploy')) {
await server.sendResourceUpdated({ uri: 'status://deploy' });
}
//#endregion McpServer_trackResourceSubscriptions_basic
}

/**
* Example: Logging from inside a tool handler via ctx.mcpReq.log().
*/
Expand Down
Loading
Loading