Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/codemod-versions-from-manifests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/codemod': patch
---

Read the v2 package versions the codemod writes into migrated `package.json` files directly from the workspace manifests at build time, replacing the committed generated `versions.ts` (which went stale after every release and made source builds write outdated versions).
5 changes: 4 additions & 1 deletion .changeset/pre.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
"@modelcontextprotocol/test-integration": "2.0.0-alpha.1"
},
"changesets": [
"beta-release"
"beta-release",
"cjs-support-v2-packages",
"codemod-iterations-5",
"post-dispatch-32021-http-400"
]
}
2 changes: 1 addition & 1 deletion docs/servers/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ On stdio, [`serveStdio`](../serving/stdio.md) routes the instance's own `send*Li
:::

::: info Coming from 2025-era subscriptions
A 2025-era connection delivers per-resource updates without a listen stream — [Subscriptions](../clients/subscriptions.md#fall-back-to-legacy-per-resource-subscribe) covers that path, and [Protocol versions](../protocol-versions.md) the era split.
A 2025-era connection delivers per-resource updates without a listen stream — [Resources](./resources.md#serve-per-resource-subscriptions) covers the server's subscription bookkeeping, [Subscriptions](../clients/subscriptions.md#fall-back-to-legacy-per-resource-subscribe) the client call, and [Protocol versions](../protocol-versions.md) the era split.
:::

## Pick an event bus for multi-process deployments
Expand Down
46 changes: 46 additions & 0 deletions docs/servers/resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,51 @@ server.sendResourceListChanged();

The notification tells connected clients to call `resources/list` again. A change to one resource's content is a different signal, `notifications/resources/updated` — [Notifications](./notifications.md) covers both.

## 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.

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

const deploys = new McpServer({ name: 'deploys', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } });

deploys.registerResource(
'deploy-status',
'deploys://status',
{ description: 'The current deploy state', mimeType: 'text/plain' },
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' });
}
}
```

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 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.

## Recap

- `registerResource(name, uri, config, readCallback)` registers a resource at a fixed URI.
Expand All @@ -220,3 +265,4 @@ The notification tells connected clients to call `resources/list` again. A chang
- 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.
2 changes: 1 addition & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ The one exception to the generic commands is the reference pair: [`cli-client/`]
| ------------------------------------- | ------------------------------------------------------------------------ |
| [`tools/`](./tools/README.md) | Register tools, infer input/output schemas, call them, structured output |
| [`prompts/`](./prompts/README.md) | Prompts + argument completion |
| [`resources/`](./resources/README.md) | Static + templated resources, list/read |
| [`resources/`](./resources/README.md) | Static + templated resources, list/read, era-split subscriptions |
| [`dual-era/`](./dual-era/README.md) | One factory, both protocol eras, both transports |

## Feature stories
Expand Down
59 changes: 59 additions & 0 deletions examples/guides/servers/resources.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,41 @@ server.registerResource(
// Imported dynamically so the page's lead region stays self-contained.
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// "Serve per-resource subscriptions"
// ---------------------------------------------------------------------------

//#region sendResourceUpdated_subscribers
let deployStatus = 'idle';

const deploys = new McpServer({ name: 'deploys', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } });

deploys.registerResource(
'deploy-status',
'deploys://status',
{ description: 'The current deploy state', mimeType: 'text/plain' },
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' });
}
}
//#endregion sendResourceUpdated_subscribers

const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client');

const client = new Client({ name: 'resources-docs-harness', version: '1.0.0' });
Expand Down Expand Up @@ -153,5 +188,29 @@ if (uris.some(uri => uri.startsWith('users://')) || !uris.includes('teams://core
server.sendResourceListChanged();
//#endregion sendResourceListChanged

// "Serve per-resource subscriptions" — a 2025-era client subscribes, the status
// changes, exactly one notifications/resources/updated arrives, and none after
// unsubscribe.
const watcher = new Client({ name: 'resources-docs-watcher', version: '1.0.0' }, { versionNegotiation: { mode: 'legacy' } });
const [watcherTransport, deploysTransport] = InMemoryTransport.createLinkedPair();
const updates: string[] = [];
watcher.setNotificationHandler('notifications/resources/updated', notification => {
updates.push(notification.params.uri);
});
await deploys.connect(deploysTransport);
await watcher.connect(watcherTransport);

await watcher.subscribeResource({ uri: 'deploys://status' });
await setDeployStatus('deploying');
await watcher.unsubscribeResource({ uri: 'deploys://status' });
await setDeployStatus('done');
await new Promise(resolve => setTimeout(resolve, 50));
console.log('updates:', updates);
if (updates.length !== 1 || updates[0] !== 'deploys://status') {
throw new Error(`resources.md subscription claim failed: ${JSON.stringify(updates)}`);
}
await watcher.close();
await deploys.close();

await client.close();
await server.close();
2 changes: 1 addition & 1 deletion examples/resources/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# resources

Direct resources (a fixed URI string) and templated resources (`ResourceTemplate('greeting://{name}')`). The client lists both, reads the direct config, and reads a templated greeting.
Direct resources (a fixed URI string), templated resources (`ResourceTemplate('greeting://{name}')`), and per-resource subscriptions. The client lists both kinds, reads the direct config and a templated greeting, then subscribes to `counter://value` — `subscriptions/listen` on 2026-07-28, `resources/subscribe` on 2025 — calls the `increment` tool, and asserts the `notifications/resources/updated` it produces. Per-request legacy HTTP has no delivery channel, so that leg skips the delivery assertion.

```bash
pnpm tsx examples/resources/client.ts
Expand Down
57 changes: 56 additions & 1 deletion examples/resources/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
/**
* Drives the resources example: list, list templates, read direct + templated.
* Drives the resources example: list, list templates, read direct + templated,
* then subscribe to `counter://value` and assert the update notification. The
* subscription sender is era-split — `subscriptions/listen` on 2026-07-28,
* `resources/subscribe` on 2025 — while the notification handler is one
* registration either way. Per-request legacy HTTP has no channel to deliver
* notifications, so that leg asserts the calls succeed and skips the delivery
* assertion.
*/
import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
Expand All @@ -18,6 +24,7 @@ await (transport === 'stdio'

const list = await client.listResources();
check.ok(list.resources.some(r => r.uri === 'config://app'));
check.ok(list.resources.some(r => r.uri === 'counter://value'));

const templates = await client.listResourceTemplates();
check.ok(templates.resourceTemplates.some(t => t.uriTemplate === 'greeting://{name}'));
Expand All @@ -30,4 +37,52 @@ const hello = await client.readResource({ uri: 'greeting://world' });
const helloContent = hello.contents[0];
check.equal(helloContent && 'text' in helloContent ? helloContent.text : '', 'Hello, world!');

// --- Subscriptions ---------------------------------------------------------

// One handler serves both delivery paths: the 2026-07-28 listen stream and a
// 2025-era connection's unsolicited notification dispatch the same way.
let resolveUpdated: ((uri: string) => void) | undefined;
client.setNotificationHandler('notifications/resources/updated', notification => {
resolveUpdated?.(notification.params.uri);
});

// Per-request legacy HTTP answers each POST in isolation: subscribing succeeds,
// but there is no stream to deliver the notification on.
const deliverable = !(era === 'legacy' && transport === 'http');

const updated = new Promise<string>(resolve => {
resolveUpdated = resolve;
});

const subscription = era === 'modern' ? await client.listen({ resourceSubscriptions: ['counter://value'] }) : undefined;
if (era === 'legacy') {
await client.subscribeResource({ uri: 'counter://value' });
}

const bumped = await client.callTool({ name: 'increment', arguments: {} });
check.ok(!bumped.isError);
const bumpedContent = bumped.content[0];
const bumpedValue = bumpedContent && bumpedContent.type === 'text' ? bumpedContent.text : '';

if (deliverable) {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('no resources/updated within 8s')), 8000);
});
const updatedUri = await Promise.race([updated, timeout]).finally(() => clearTimeout(timer));
check.equal(updatedUri, 'counter://value');

// The resource and the tool observe the same state: the re-read matches the
// value increment returned (not a literal, so a long-lived server re-runs).
const counter = await client.readResource({ uri: 'counter://value' });
const counterContent = counter.contents[0];
check.equal(counterContent && 'text' in counterContent ? counterContent.text : '', bumpedValue);
}

if (subscription) {
await subscription.close();
} else if (era === 'legacy') {
await client.unsubscribeResource({ uri: 'counter://value' });
}

await client.close();
64 changes: 57 additions & 7 deletions examples/resources/server.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
/**
* Resources primitive — direct + templated.
* Resources primitive — direct, templated, and subscribable.
*
* `McpServer.registerResource` accepts either a fixed URI string (direct
* resource) or a `ResourceTemplate` (URI template with substitution). One
* binary, either transport — selected from argv below.
* resource) or a `ResourceTemplate` (URI template with substitution). The
* `counter://value` resource is mutable: the `increment` tool bumps it and
* announces the change — in-band to this connection (2025-era subscribers
* tracked per connection, 2026-07-28 listen streams routed by the entry), and
* on the handler's notifier for clients on other requests. One binary, either
* transport — selected from argv below.
*/
import { createServer } from 'node:http';

import { parseExampleArgs } from '@mcp-examples/shared';
import { toNodeHandler } from '@modelcontextprotocol/node';
import type { McpRequestContext } from '@modelcontextprotocol/server';
import { createMcpHandler, McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';

function buildServer(): McpServer {
const server = new McpServer({ name: 'resources-example', version: '1.0.0' });
const COUNTER_URI = 'counter://value';

// The counter is application state, shared by every connection; which URIs a
// connection subscribed to is connection state, tracked inside buildServer.
let counter = 0;

function buildServer(reqCtx: McpRequestContext, publishUpdated?: (uri: string) => void): McpServer {
const server = new McpServer(
{ name: 'resources-example', version: '1.0.0' },
{ capabilities: { resources: { subscribe: true, listChanged: true } } }
);

// A direct resource at a fixed URI.
server.registerResource(
Expand All @@ -31,16 +45,52 @@ function buildServer(): McpServer {
async (uri, vars) => ({ contents: [{ uri: uri.href, text: `Hello, ${vars.name}!` }] })
);

// A mutable resource — the subscription story's subject.
server.registerResource(
'counter',
COUNTER_URI,
{ mimeType: 'text/plain', description: 'A number the increment tool bumps' },
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)) {
// 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(() => {});
}
return { content: [{ type: 'text', text: String(counter) }] };
});

return server;
}

const { transport, port } = parseExampleArgs();

if (transport === 'stdio') {
void serveStdio(buildServer);
void serveStdio(reqCtx => buildServer(reqCtx));
console.error('[server] serving over stdio');
} else {
const handler = createMcpHandler(buildServer);
const handler = createMcpHandler(reqCtx => buildServer(reqCtx, uri => handler.notify.resourceUpdated(uri)));
createServer(toNodeHandler(handler)).listen(port, () => {
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
});
Expand Down
11 changes: 11 additions & 0 deletions packages/client/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# @modelcontextprotocol/client

## 2.0.0-beta.2

### Patch Changes

- [#2405](https://github.com/modelcontextprotocol/typescript-sdk/pull/2405) [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts`
and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map
adds a `require` condition so `require('@modelcontextprotocol/…')` works from
CommonJS consumers. Output extensions are normalized across all packages
(`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the
public import paths are unchanged.

## 2.0.0-beta.1

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/client",
"version": "2.0.0-beta.1",
"version": "2.0.0-beta.2",
"description": "Model Context Protocol implementation for TypeScript - Client package",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
Expand Down
13 changes: 13 additions & 0 deletions packages/codemod/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# @modelcontextprotocol/codemod

## 2.0.0-beta.2

### Patch Changes

- [#2405](https://github.com/modelcontextprotocol/typescript-sdk/pull/2405) [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts`
and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map
adds a `require` condition so `require('@modelcontextprotocol/…')` works from
CommonJS consumers. Output extensions are normalized across all packages
(`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the
public import paths are unchanged.

- [#2412](https://github.com/modelcontextprotocol/typescript-sdk/pull/2412) [`ef120b2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/ef120b2be0c3c3d80468c3d4a9f79be30bb0c0a3) Thanks [@felixweinberger](https://github.com/felixweinberger)! - v1-to-v2 migration fixes from continued real-world migrations (codemod iterations 5).

## 2.0.0-beta.1

### Patch Changes
Expand Down
Loading
Loading