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).
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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

<!-- prettier-ignore -->
> [!WARNING]
> **We're limiting pull requests to contributors while we land the [2026-07-28 spec](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) implementation.**
> **We're limiting pull requests to 1 per new contributor while we land the [2026-07-28 spec](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) implementation.**
>
> [Issues](https://github.com/modelcontextprotocol/typescript-sdk/issues/new?template=v2-feedback.yml) are the most useful feedback right now — we'll reopen PRs as v2 stabilizes.

Expand Down
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
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
23 changes: 23 additions & 0 deletions examples/cli-client/test/host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';

import { configFromTargets, interpolateEnv, isHttpServer, parseConfig } from '../host/config';
import { contentBlockToParts, resourceToContextText, stripAnsi, toolResultToParts, truncate } from '../host/content';
import { resolveVersionOptions } from '../host/host';
import { namespaceTool, routeNamespacedTool, sanitizeServerName } from '../host/naming';

describe('tool namespacing and routing', () => {
Expand Down Expand Up @@ -94,3 +95,25 @@ describe('config parsing', () => {
expect(() => configFromTargets([])).toThrow();
});
});

describe('protocol version selection', () => {
it('defaults to auto probing, --legacy to the plain 2025 handshake', () => {
expect(resolveVersionOptions(false)).toEqual({ versionNegotiation: { mode: 'auto' } });
expect(resolveVersionOptions(true)).toEqual({ versionNegotiation: { mode: 'legacy' } });
});

it('runs a known 2025-era revision through the legacy handshake, offering only that revision', () => {
const expected = { versionNegotiation: { mode: 'legacy' }, supportedProtocolVersions: ['2025-06-18'] };
expect(resolveVersionOptions(false, '2025-06-18')).toEqual(expected);
// --legacy alongside a 2025-era revision is redundant but consistent.
expect(resolveVersionOptions(true, '2025-06-18')).toEqual(expected);
});

it('pins anything newer via the modern handshake', () => {
expect(resolveVersionOptions(false, '2026-07-28')).toEqual({ versionNegotiation: { mode: { pin: '2026-07-28' } } });
});

it('rejects --legacy combined with a revision the 2025 handshake cannot reach', () => {
expect(() => resolveVersionOptions(true, '2026-07-28')).toThrow(/--legacy conflicts with --protocol-version/);
});
});
2 changes: 1 addition & 1 deletion examples/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"dependencies": {
"@modelcontextprotocol/server": "workspace:^",
"@modelcontextprotocol/express": "workspace:^",
"better-auth": "^1.4.17",
"better-auth": "^1.6.2",
"better-sqlite3": "^12.6.2",
"cors": "catalog:runtimeServerOnly",
"express": "catalog:runtimeServerOnly"
Expand Down
23 changes: 19 additions & 4 deletions packages/core-internal/src/shared/inboundClassification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,9 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor
* the ladder (or a pre-handler protocol gate) produced. Errors produced by
* request handlers — whatever their code — stay in-band on HTTP 200, and are
* never mapped to an HTTP status by this table; in particular `-32603` and
* domain-specific codes never become a blanket 500.
* domain-specific codes never become a blanket 500. The single exception is
* `MissingRequiredClientCapability` (-32021) — see
* {@linkcode httpStatusForErrorCode}.
*
* `-32602` (invalid params) deliberately has NO entry: the only invalid-params
* rejection that maps to HTTP 400 is the classifier's own envelope rung
Expand All @@ -390,11 +392,24 @@ export const LADDER_ERROR_HTTP_STATUS: Readonly<Record<number, number>> = {
/**
* The HTTP status to answer a JSON-RPC error with, keyed on the error's
* origin. `in-band` errors (anything produced by a request handler) are
* always HTTP 200 — the JSON-RPC error response is the payload, not an HTTP
* failure. `ladder` errors map through {@linkcode LADDER_ERROR_HTTP_STATUS}.
* HTTP 200 — the JSON-RPC error response is the payload, not an HTTP
* failure — with ONE exception: `MissingRequiredClientCapability` (-32021),
* whose 400 the spec mandates on the error itself with no origin condition,
* and which the SDK genuinely produces after dispatch (the `input_required`
* capability gate). A handler relaying some downstream peer's `-32020`/`-32022`
* is NOT that peer's spec error and stays in-band like every other handler
* code. `ladder` errors map through {@linkcode LADDER_ERROR_HTTP_STATUS}.
*
* The per-request transport intentionally does NOT delegate to this function:
* its `?? 400` ladder fallback is only correct for entry-gate codes known to
* the table, and would wrongly map dispatch-window errors outside it (a
* window `-32602` must stay in-band on 200). The transport indexes the table
* directly; keep the two in agreement when editing either.
*/
export function httpStatusForErrorCode(code: number, origin: 'ladder' | 'in-band'): number {
if (origin === 'in-band') return 200;
if (origin === 'in-band') {
return code === ProtocolErrorCode.MissingRequiredClientCapability ? 400 : 200;
}
return LADDER_ERROR_HTTP_STATUS[code] ?? 400;
}

Expand Down
Loading
Loading