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-iterations-5.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/codemod': patch
---

v1-to-v2 migration fixes from continued real-world migrations (codemod iterations 5).
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).
6 changes: 6 additions & 0 deletions .changeset/post-dispatch-32021-http-400.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/server': patch
'@modelcontextprotocol/core-internal': patch
---

Return HTTP 400 for a `MissingRequiredClientCapabilityError` (`-32021`) produced after dispatch. The spec mandates `400 Bad Request` for this error with no condition on where it arose, but only the pre-dispatch capability gate honored that; the post-handler emission — the `input_required` gate rejecting an embedded request whose required capability the caller did not declare — surfaced in-band on HTTP 200. The JSON-RPC error body is unchanged, every other error code (including a handler relaying a downstream peer's `-32020`/`-32022`) keeps the origin-keyed in-band behavior, and the mapping only applies while the response is uncommitted: an exchange that already streamed — or one hosted with `responseMode: 'sse'`, which opens its stream at dispatch end — keeps its committed 200 and carries the error in-stream.
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"
]
}
12 changes: 12 additions & 0 deletions docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,18 @@ fetch-shaped handler.
> `toNodeHandler(handler)` and add the `@modelcontextprotocol/node` import.
> `NodeIncomingMessageLike` / `NodeServerResponseLike` are now exported from
> `@modelcontextprotocol/node`, not `@modelcontextprotocol/server`.
>
> Also: a `MissingRequiredClientCapabilityError` (`-32021`) produced **after** dispatch
> — the `input_required` gate refusing an embedded request whose capability the caller
> did not declare — now answers HTTP **400** (earlier alphas surfaced it in-band on
> 200). The spec mandates 400 for this error wherever it arises; the JSON-RPC body is
> unchanged. This applies to a handler-thrown `-32021` too: a proxy relaying a
> downstream server's `-32021` should translate it (its `requiredCapabilities`
> describes the downstream hop's envelope) rather than rethrow the bare error. Every
> other handler-produced code (including a relayed `-32020`/`-32022`)
> keeps the in-band 200, and an exchange whose response stream is already open — the
> handler streamed first, or `responseMode: 'sse'` — keeps its committed 200 and
> carries the error in-stream.

### Server over stdio / long-lived connections: `serveStdio`

Expand Down
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
19 changes: 10 additions & 9 deletions examples/cli-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ pnpm --filter @mcp-examples/todos-server start:http
ANTHROPIC_API_KEY=sk-… pnpm --filter @mcp-examples/cli-client start -- --server http://127.0.0.1:3000/mcp --provider anthropic
```

The status line shows what was negotiated — `connected to "todos" (2026-07-28, 8 tools, 2 resources, 2 prompts)`. Add `--legacy` in terminal B to force the 2025-era handshake against the same server and watch the legacy arms of every feature run instead (`connected to "todos" (2025-11-25, …)`).
The status line shows what was negotiated — `connected to "todos" (2026-07-28, 8 tools, 2 resources, 2 prompts)`. Add `--legacy` in terminal B to force the 2025-era handshake against the same server and watch the legacy arms of every feature run instead (`connected to "todos" (2025-11-25, …)`). To hold the connection to one exact revision, use `--protocol-version 2025-06-18` (or any supported revision) — the connection fails rather than settle on anything else.

A tour that touches everything, in one sitting:

Expand Down Expand Up @@ -111,14 +111,15 @@ For a persistent setup, copy `config.example.json` to `config.json` (or pass `--
## All flags

```text
--server <target> connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable)
--config <path> mcpServers config file (default: ./config.json, falling back to spawning todos-server)
--provider <name> scripted | anthropic | openai | gemini (default: first one with a key in the env, else scripted)
--model <id> pin a model id (default: the provider's latest mid-tier model)
--root <path> workspace root exposed to servers via roots/list (repeatable; default: cwd)
--callback-port <n> fixed loopback port for the OAuth callback (default: a free port)
--legacy use the 2025 initialize handshake instead of probing for 2026-07-28
-h, --help show usage
--server <target> connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable)
--config <path> mcpServers config file (default: ./config.json, falling back to spawning todos-server)
--provider <name> scripted | anthropic | openai | gemini (default: first one with a key in the env, else scripted)
--model <id> pin a model id (default: the provider's latest mid-tier model)
--root <path> workspace root exposed to servers via roots/list (repeatable; default: cwd)
--callback-port <n> fixed loopback port for the OAuth callback (default: a free port)
--legacy use the 2025 initialize handshake instead of probing for 2026-07-28
--protocol-version <v> negotiate exactly this revision: 2025-era values (e.g. 2025-06-18) via the legacy handshake, 2026-07-28+ via a modern pin
-h, --help show usage
```

## How this example is tested
Expand Down
36 changes: 20 additions & 16 deletions examples/cli-client/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,15 @@ import type { LLMProvider } from './providers/provider';
import { ScriptedProvider } from './providers/scripted';

const USAGE = `usage: tsx cli.ts [options]
--server <target> connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable)
--config <path> mcpServers config file (default: ./config.json, falling back to spawning the sibling todos-server)
--provider <name> scripted | anthropic | openai | gemini (default: first one with an API key in the env, else scripted)
--model <id> pin a model id (default: the provider's latest mid-tier model)
--root <path> workspace root exposed to servers via roots/list (repeatable; default: cwd)
--callback-port <n> fixed loopback port for the OAuth callback (default: a free port; set this when port-forwarding over SSH)
--legacy use the 2025 initialize handshake instead of probing for 2026-07-28
--help this help`;
--server <target> connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable)
--config <path> mcpServers config file (default: ./config.json, falling back to spawning the sibling todos-server)
--provider <name> scripted | anthropic | openai | gemini (default: first one with an API key in the env, else scripted)
--model <id> pin a model id (default: the provider's latest mid-tier model)
--root <path> workspace root exposed to servers via roots/list (repeatable; default: cwd)
--callback-port <n> fixed loopback port for the OAuth callback (default: a free port; set this when port-forwarding over SSH)
--legacy use the 2025 initialize handshake instead of probing for 2026-07-28
--protocol-version <v> negotiate exactly this revision: 2025-era values (e.g. 2025-06-18) via the legacy handshake, 2026-07-28+ via a modern pin
--help this help`;

function pickProvider(name: string | undefined, model: string | undefined): LLMProvider {
const chosen =
Expand Down Expand Up @@ -73,6 +74,7 @@ const { values } = parseArgs({
root: { type: 'string', multiple: true },
'callback-port': { type: 'string' },
legacy: { type: 'boolean' },
'protocol-version': { type: 'string' },
help: { type: 'boolean', short: 'h' }
}
});
Expand Down Expand Up @@ -111,15 +113,17 @@ for (const [serverName, entry] of Object.entries(config.mcpServers)) {
ui.status(` ${serverName} → ${'url' in entry ? entry.url : [entry.command, ...(entry.args ?? [])].join(' ')}`);
}

const host = new McpHost({
ui,
provider,
roots: values.root ?? [process.cwd()],
legacy: values.legacy ?? false,
oauthCallbackPort: values['callback-port'] ? Number.parseInt(values['callback-port'], 10) : undefined
});
hostRef.current = host;
let host: McpHost;
try {
host = new McpHost({
ui,
provider,
roots: values.root ?? [process.cwd()],
legacy: values.legacy ?? false,
protocolVersion: values['protocol-version'],
oauthCallbackPort: values['callback-port'] ? Number.parseInt(values['callback-port'], 10) : undefined
});
hostRef.current = host;
await host.connect(config);
} catch (error) {
ui.print(error instanceof Error ? error.message : String(error));
Expand Down
43 changes: 39 additions & 4 deletions examples/cli-client/host/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ import type {
Prompt,
Resource,
ResourceTemplateType,
Tool
Tool,
VersionNegotiationOptions
} from '@modelcontextprotocol/client';
import {
Client,
LOG_LEVEL_META_KEY,
ProtocolError,
SdkError,
StreamableHTTPClientTransport,
SUPPORTED_PROTOCOL_VERSIONS,
UnauthorizedError
} from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
Expand Down Expand Up @@ -61,10 +63,43 @@ export interface McpHostOptions {
roots?: string[];
/** Use the 2025 `initialize` handshake instead of probing for 2026-07-28. */
legacy?: boolean;
/**
* Negotiate exactly this protocol revision: a known 2025-era value runs the legacy
* handshake offering only that revision; anything else is pinned via the modern
* handshake, which fails loudly unless the server offers it.
*/
protocolVersion?: string;
/** Fixed loopback port for the OAuth callback (default: an OS-assigned free port). Useful over SSH port-forwarding. */
oauthCallbackPort?: number;
}

/** The version-negotiation slice of the SDK client options every connection this host makes shares. */
export interface VersionOptions {
versionNegotiation: VersionNegotiationOptions;
supportedProtocolVersions?: string[];
}

/**
* Map the era toggle and optional pinned revision onto the SDK's negotiation options.
* A known 2025-era revision runs the legacy handshake offering exactly that revision
* (the client rejects a server that answers with any other version); everything else
* becomes a modern pin, and the SDK's own typed error covers strings that are neither.
*/
export function resolveVersionOptions(legacy: boolean, protocolVersion?: string): VersionOptions {
if (protocolVersion === undefined) {
return { versionNegotiation: { mode: legacy ? 'legacy' : 'auto' } };
}
if (SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) {
return { versionNegotiation: { mode: 'legacy' }, supportedProtocolVersions: [protocolVersion] };
}
if (legacy) {
throw new Error(
`--legacy conflicts with --protocol-version ${protocolVersion}: the 2025 handshake can only negotiate ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')}`
);
}
return { versionNegotiation: { mode: { pin: protocolVersion } } };
}

function unwrapUnauthorized(error: unknown): UnauthorizedError | undefined {
if (error instanceof UnauthorizedError) return error;
// Under versionNegotiation 'auto', a connect-time 401 surfaces as
Expand Down Expand Up @@ -94,7 +129,7 @@ function samplingContentToParts(content: CreateMessageRequest['params']['message
export class McpHost {
private readonly ui: HostUI;
private readonly provider: LLMProvider;
private readonly legacy: boolean;
private readonly versionOptions: VersionOptions;
private roots: string[];
private readonly watches: McpSubscription[] = [];
private readonly oauthCallbackPort?: number;
Expand All @@ -103,7 +138,7 @@ export class McpHost {
constructor(options: McpHostOptions) {
this.ui = options.ui;
this.provider = options.provider;
this.legacy = options.legacy ?? false;
this.versionOptions = resolveVersionOptions(options.legacy ?? false, options.protocolVersion);
this.oauthCallbackPort = options.oauthCallbackPort;
this.roots = (options.roots ?? [process.cwd()]).map(root => path.resolve(root));
}
Expand Down Expand Up @@ -309,7 +344,7 @@ export class McpHost {

private buildClient(name: string): Client {
const client = new Client(CLIENT_INFO, {
versionNegotiation: { mode: this.legacy ? 'legacy' : 'auto' },
...this.versionOptions,
capabilities: {
// Both elicitation modes are declared because the handler below implements both.
elicitation: { form: {}, url: {} },
Expand Down
Loading
Loading