Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .changeset/generic-extension-helpers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/server': minor
'@modelcontextprotocol/client': minor
---

Add generic SEP-2133 extension helpers: `McpServer.enableExtension(identifier, settings?)` / `McpServer.getClientExtension(identifier)` and the `Client` mirrors `enableExtension` / `getServerExtension` — thin convenience over `registerCapabilities` / `get*Capabilities` for the `capabilities.extensions` map. `McpRequestContext` now carries `clientCapabilities` (populated on the modern HTTP path from the validated per-request envelope) so a `createMcpHandler` factory can branch on extension support at construction time.
33 changes: 33 additions & 0 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,39 @@
The return type is inferred from the method name via `ResultTypeMap` (e.g.
`client.request({ method: 'tools/call', ... })` returns `Promise<CallToolResult>`).

#### Subclassing `Protocol` → subclass `Client` or `Server`

The abstract `Protocol` base class is **no longer exported** (it stays internal to
`@modelcontextprotocol/core-internal`). Code that did `class X extends Protocol<…>` —
typically extension runtimes that reuse the SDK's JSON-RPC engine for a second,
non-MCP-wire link — must move to subclassing one of the two concrete peers instead:

- `class X extends Client` for the side that **initiates** the link (sends the first
request / handshake).
- `class X extends Server` for the side that **answers** it.

What this buys you over re-exporting `Protocol`: the v2 base has a different generic
shape (`Protocol<ContextT extends BaseContext>` instead of
`Protocol<SendReqT, SendNotifT, SendResT>`) and a new abstract `buildContext()` member,
so a v1 subclass would not have compiled against it anyway. `Client` and `Server`
already implement `buildContext()` and pin `ContextT` to `ClientContext` /
`ServerContext`, so a subclass gets the handler context for free.

Mechanical changes inside the subclass:

| v1 | v2 |
| --- | --- |
| `extends Protocol<Req, Notif, Res>` | `extends Client` (or `extends Server`) |
| `RequestHandlerExtra<Req, Notif>` | `ClientContext` / `ServerContext` |
| `setRequestHandler(MyZodSchema, h)` | `setRequestHandler('my/method', { params: MyParams }, h)` — the [3-arg custom form](#setrequesthandler--setnotificationhandler-use-method-strings) |
| `Parameters<Protocol<…>['setRequestHandler']>` derivations | name `ClientContext` / `ServerContext` directly |

`Transport` (the interface) and `InMemoryTransport` remain public, so a custom transport
that the subclass connects over is unchanged. If the link is genuinely not an MCP wire,
keep every handler on the 3-arg custom form and skip `connect()`-time version
negotiation by leaving `versionNegotiation` at its default — the subclass then behaves
exactly like a v1 `Protocol` subclass with string-keyed handlers.

Check failure on line 367 in docs/migration/upgrade-to-v2.md

View check run for this annotation

Claude / Claude Code Review

Migration doc: default versionNegotiation does not skip the MCP handshake for non-MCP-wire Client subclasses

The new migration paragraph claims that leaving `versionNegotiation` at its default lets a `Client` subclass over a non-MCP wire "behave exactly like a v1 `Protocol` subclass", but the default posture is `legacy`, and `Client.connect()`'s legacy branch still runs the full MCP `initialize` handshake (`_legacyHandshake`), which a non-MCP peer cannot answer — so `connect()` hangs/fails and closes the transport. The default only skips the `server/discover` probe; the section should either tell reade
Comment on lines +362 to +367

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The new migration paragraph claims that leaving versionNegotiation at its default lets a Client subclass over a non-MCP wire "behave exactly like a v1 Protocol subclass", but the default posture is legacy, and Client.connect()'s legacy branch still runs the full MCP initialize handshake (_legacyHandshake), which a non-MCP peer cannot answer — so connect() hangs/fails and closes the transport. The default only skips the server/discover probe; the section should either tell readers to override connect() for non-MCP links or drop the "behaves exactly like a v1 Protocol subclass" claim.

Extended reasoning...

The claim

The closing paragraph of the new "Subclassing Protocol → subclass Client or Server" section says: "If the link is genuinely not an MCP wire, keep every handler on the 3-arg custom form and skip connect()-time version negotiation by leaving versionNegotiation at its default — the subclass then behaves exactly like a v1 Protocol subclass with string-keyed handlers."

What the code actually does

The default versionNegotiation posture is 'legacy' (DEFAULT_VERSION_NEGOTIATION_MODE = 'legacy' in packages/client/src/client/versionNegotiation.ts:100, applied by resolveVersionNegotiation when the option is absent). In Client.connect() (packages/client/src/client/client.ts:950-985), the legacy branch calls super.connect(transport) and then _legacyHandshake() (client.ts:993-1058), which:

  1. sends an initialize request carrying protocolVersion / capabilities / clientInfo,
  2. requires the peer to answer with an InitializeResult whose protocolVersion is in the legacy supported list (otherwise it throws "Server's protocol version is not supported"),
  3. sends notifications/initialized, and
  4. on any failure, the catch block at client.ts:1053-1056 calls this.close() and rethrows — tearing down the transport.

So leaving versionNegotiation at its default only avoids the server/discover negotiation probe; the MCP initialize handshake still runs at connect() time.

Step-by-step proof

  1. An extension-runtime author follows the migration section: class MyRuntime extends Client { ... }, registers handlers via the 3-arg custom form, leaves versionNegotiation unset.
  2. They call await runtime.connect(myCustomTransport) against their non-MCP peer (the same call their v1 Protocol subclass made).
  3. resolveVersionNegotiation returns kind 'legacy'connect() calls _legacyHandshake().
  4. The handshake sends {"method":"initialize", "params":{"protocolVersion":"2025-11-25", ...}} over the wire. The non-MCP peer either ignores it (the request hangs until the request timeout fires) or answers something that is not a valid InitializeResult (the result fails validation / the supported-version check throws).
  5. Either way the catch block runs this.close() and rethrows — connect() rejects and the transport is closed.

By contrast, v1 Protocol.connect() performed no handshake at all (the handshake lived in v1 Client.connect, not Protocol), so a v1 Protocol subclass connected to a non-MCP wire just fine. The doc therefore promises behavior the code does not ship — exactly the audience this section targets (non-MCP-wire extension runtimes) is the audience the advice breaks for.

Why nothing else prevents it

There is no versionNegotiation value that disables the handshake entirely on Client'legacy' runs initialize, the negotiating modes additionally run server/discover. The only ways to get a v1-Protocol-like connect() are to override connect() in the subclass (e.g. calling the protected base Protocol.connect) or to not use Client.connect() for the non-MCP link at all.

Fix

Documentation-only: in this paragraph, either (a) state that for a genuinely non-MCP link the subclass must override connect() (calling the protected base connect) so the MCP initialize handshake is not sent, or (b) drop the "skip connect()-time version negotiation … behaves exactly like a v1 Protocol subclass" sentence and scope the section's advice to MCP-speaking links. All three independent verifiers confirmed this reading against the implementation; no verifier refuted it.


### Server registration API

The deprecated variadic `.tool()`, `.prompt()`, `.resource()` are removed. Use
Expand Down
21 changes: 21 additions & 0 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
GetPromptResult,
Implementation,
InputRequiredOptions,
JSONObject,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
Expand Down Expand Up @@ -741,6 +742,26 @@ export class Client extends Protocol<ClientContext> {
this._capabilities = mergeCapabilities(this._capabilities, capabilities);
}

/**
* Advertise a protocol extension (SEP-2133) under
* `ClientCapabilities.extensions`. The `identifier` is the vendor-prefixed
* extension key (for example `'io.modelcontextprotocol/ui'`); `settings`
* defaults to `{}`. Thin convenience over
* {@linkcode Client.registerCapabilities} — call it before connecting.
*/
public enableExtension(identifier: string, settings: JSONObject = {}): void {
this.registerCapabilities({ extensions: { [identifier]: settings } });
}

/**
* The settings object the connected server advertised for the given
* extension identifier under `ServerCapabilities.extensions`, or
* `undefined` when the server did not declare it.
*/
public getServerExtension(identifier: string): JSONObject | undefined {
return this._serverCapabilities?.extensions?.[identifier];
}

/**
* Configure protocol version negotiation before connecting (equivalent to
* passing `versionNegotiation` at construction time). Can only be called
Expand Down
47 changes: 47 additions & 0 deletions packages/client/test/client/extensionHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* SEP-2133 generic extension helpers — `enableExtension` / `getServerExtension`
* on `Client`. Mirrors the server-side helpers in `@modelcontextprotocol/server`.
*/
import { InMemoryTransport } from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';

import { Client } from '../../src/client/client';

const UI = 'io.modelcontextprotocol/ui';

describe('Client.enableExtension / getServerExtension', () => {
it('writes ClientCapabilities.extensions[identifier] and reads the server mirror after connect', async () => {
const client = new Client({ name: 'c', version: '1.0.0' });
client.enableExtension(UI);
client.enableExtension('vendor.example/thing', { mode: 'fast' });

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
// Minimal hand-rolled server end: answer `initialize`, echoing back an
// `extensions` declaration so `getServerExtension` has something to read.
let sawClientExtensions: unknown;
serverTransport.onmessage = msg => {
if ('method' in msg && msg.method === 'initialize' && 'id' in msg) {
sawClientExtensions = (msg.params as { capabilities?: { extensions?: unknown } }).capabilities?.extensions;
void serverTransport.send({
jsonrpc: '2.0',
id: msg.id,
result: {
protocolVersion: (msg.params as { protocolVersion: string }).protocolVersion,
serverInfo: { name: 's', version: '1.0.0' },
capabilities: { extensions: { [UI]: { contentTypes: ['text/html'] } } }
}
});
}
};
await serverTransport.start();

await client.connect(clientTransport);

expect(sawClientExtensions).toEqual({ [UI]: {}, 'vendor.example/thing': { mode: 'fast' } });
expect(client.getServerExtension(UI)).toEqual({ contentTypes: ['text/html'] });
expect(client.getServerExtension('absent/key')).toBeUndefined();

expect(() => client.enableExtension('late/key')).toThrow(/after connecting/);
await client.close();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
* neutral follows 2026 (`JSONValue`-capable open schema objects).
* - capability blobs (`experimental`, `sampling`, `elicitation`, `tasks`,
* `logging`, `completions`): 2025 wire `object`; neutral `JSONObject`.
* - `extensions` capability key: 2026-only; absent from the 2025 wire view.
* - `extensions` capability key: absent from the 2025 wire-type view only —
* the 2025 codec is identity for capability objects, so the key passes
* through at runtime as an open-set member.

Check warning on line 25 in packages/core-internal/src/wire/rev2025-11-25/wireTypes.ts

View check run for this annotation

Claude / Claude Code Review

wireTypes ledger comment misstates why the extensions key survives the 2025 runtime path

The rewritten ledger bullet (and the parallel comment in the new server extensionHelpers.test.ts) says the `extensions` capability key survives the 2025 runtime path because "the 2025 codec is identity for capability objects, so the key passes through at runtime as an open-set member" — but the frozen 2025 runtime schemas explicitly declare `extensions: z.record(z.string(), JSONObjectSchema).optional()` on both capability schemas, which are strip-mode `z.object` shapes (an undeclared key would a
Comment on lines +23 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The rewritten ledger bullet (and the parallel comment in the new server extensionHelpers.test.ts) says the extensions capability key survives the 2025 runtime path because "the 2025 codec is identity for capability objects, so the key passes through at runtime as an open-set member" — but the frozen 2025 runtime schemas explicitly declare extensions: z.record(z.string(), JSONObjectSchema).optional() on both capability schemas, which are strip-mode z.object shapes (an undeclared key would actually be dropped). The comment should say the key is a declared member of the frozen 2025 runtime schemas and is omitted only from the Wire2025* type views.

Extended reasoning...

What the comment claims vs. what the code does. The PR rewrites the adjudication-ledger bullet in packages/core-internal/src/wire/rev2025-11-25/wireTypes.ts to explain why the extensions capability key still works at runtime on the 2025 path: "the 2025 codec is identity for capability objects, so the key passes through at runtime as an open-set member." The outcome the comment describes (the key reaches consumers at runtime) is correct, but the stated mechanism is not. The frozen 2025 runtime schemas in packages/core-internal/src/wire/rev2025-11-25/schemas.ts explicitly declare the key — extensions: z.record(z.string(), JSONObjectSchema).optional() appears on both ClientCapabilitiesSchema (line ~406) and ServerCapabilitiesSchema (line ~492).\n\nWhy the stated mechanism could not even work. Both capability schemas are plain z.object(...) (lines ~353 and ~428), which under Zod v4 uses strip-mode parsing: undeclared keys are silently dropped. So if extensions really were only an "open-set member" (i.e. undeclared), the 2025-era initialize parse would strip it and getClientExtension would return undefined. This file's own adjacent ledger bullet documents exactly that behavior for PromptArgument.title — an undeclared member that the strip-mode parse drops. The reason extensions survives is the opposite: it is a declared optional schema member, and it is only omitted from the type views (Wire2025ClientCapabilities / Wire2025ServerCapabilities use OmitKnown<…, 'extensions' | …> at wireTypes.ts:100/114) to match the 2025 anchor.\n\nConcrete walk-through. 1) The new server test in extensionHelpers.test.ts sends a 2025-era initialize carrying capabilities: { extensions: { 'io.modelcontextprotocol/ui': { maxWidth: 800 } } }. 2) The 2025 registry routes initialize to InitializeRequestSchema, which is built from the frozen ClientCapabilitiesSchema. 3) Because extensions is a declared key of that strip-mode schema, the parsed params retain it, and server.getClientExtension(UI) returns { maxWidth: 800 } — the test passes. 4) If the declaration were removed (which a reader trusting the comment's "open-set passthrough" explanation might believe is safe), the strip-mode parse would drop the key and step 3 would return undefined, breaking the very test this PR adds.\n\nWhy this matters. This file is explicitly an adjudication ledger whose purpose is to record precisely these wire-vs-runtime distinctions, and the misstatement would mislead someone reasoning about strip-mode behavior — e.g. concluding that the schema declaration is redundant, or that other undeclared keys would similarly "pass through". The same "(open-set; passes through the 2025 codec)" phrasing appears in the new packages/server/test/server/extensionHelpers.test.ts comment and should be corrected in the same way.\n\nHow to fix. Reword the bullet to something like: "extensions capability key: omitted from the 2025 wire-type view only — the frozen 2025 runtime schemas declare it (extensions: z.record(z.string(), JSONObjectSchema).optional() on both capability schemas), so it survives the strip-mode parse and is available to consumers at runtime." Adjust the test comment correspondingly. No behavior change is needed; this is comment accuracy only.

* - `CreateMessageRequestParams.metadata`: 2025 wire `object`; neutral
* `JSONObject`.
* - SEP-2106: `CallToolResult.structuredContent` / the `tool_result`
Expand Down
13 changes: 12 additions & 1 deletion packages/server/src/server/createMcpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ export interface McpRequestContext {
authInfo?: AuthInfo;
/** The original HTTP request being served, when available (HTTP only — `serveStdio` never sets it). */
requestInfo?: Request;
/**
* The client's declared capabilities, when known **before** the factory
* runs. Populated on the modern HTTP path from the validated per-request
* envelope; `undefined` on the legacy fallback and on `serveStdio` (where
* capabilities arrive on `initialize`, after the factory has produced the
* instance). Use this to branch on extension support at construction time
* — for example
* `ctx.clientCapabilities?.extensions?.['io.modelcontextprotocol/ui']`.
*/
clientCapabilities?: ClientCapabilities;
}

/**
Expand Down Expand Up @@ -685,7 +695,8 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
const product = await factory({
era: 'modern',
...(authInfo !== undefined && { authInfo }),
requestInfo: request
requestInfo: request,
clientCapabilities: declaredClientCapabilities
});
const server = product instanceof McpServer ? product.server : product;

Expand Down
28 changes: 28 additions & 0 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
Icon,
Implementation,
InputRequiredResult,
JSONObject,
ListPromptsResult,
ListResourcesResult,
ListToolsResult,
Expand Down Expand Up @@ -156,6 +157,33 @@
await this.server.close();
}

/**
* Advertise a protocol extension (SEP-2133) under
* `ServerCapabilities.extensions`. The `identifier` is the vendor-prefixed
* extension key (for example `'io.modelcontextprotocol/ui'`); `settings`
* defaults to `{}`. Thin convenience over
* {@linkcode Server.registerCapabilities} — call it before connecting (or
* inside the `createMcpHandler` factory).
*/
enableExtension(identifier: string, settings: JSONObject = {}): void {
this.server.registerCapabilities({ extensions: { [identifier]: settings } });
}

/**
* The settings object the connected client advertised for the given
* extension identifier under `ClientCapabilities.extensions`, or
* `undefined` when the client did not declare it. Generic SEP-2133 read
* helper; for factory-time branching prefer
* `McpRequestContext.clientCapabilities`.
*/
getClientExtension(identifier: string): JSONObject | undefined {
// The deprecated accessor remains functional in both eras (modern
// backfills it per request from the validated envelope), so a single
// read path covers stdio, legacy HTTP, and per-request modern HTTP.
// eslint-disable-next-line @typescript-eslint/no-deprecated
return this.server.getClientCapabilities()?.extensions?.[identifier];
}

Check failure on line 186 in packages/server/src/server/mcp.ts

View check run for this annotation

Claude / Claude Code Review

Extension helper API surface: no docs/example and no concrete consumer in the PR

Design/docs question on the four new one-line helpers (`McpServer.enableExtension`/`getClientExtension` and the `Client` mirrors): they are thin wrappers over already-public `registerCapabilities` / `get*Capabilities()?.extensions?.[id]`, the PR has no concrete consumer beyond the new tests, and no prose docs or example are added for them (or for `ctx.clientCapabilities`) under docs/ or examples/ — only JSDoc and the changeset. Either add a short docs section/example showing the SEP-2133 pattern
Comment on lines +160 to +186

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Design/docs question on the four new one-line helpers (McpServer.enableExtension/getClientExtension and the Client mirrors): they are thin wrappers over already-public registerCapabilities / get*Capabilities()?.extensions?.[id], the PR has no concrete consumer beyond the new tests, and no prose docs or example are added for them (or for ctx.clientCapabilities) under docs/ or examples/ — only JSDoc and the changeset. Either add a short docs section/example showing the SEP-2133 pattern (and the intended MCP Apps consumer), or consider whether the registerCapabilities pattern belongs in a cookbook instead of growing the public API; ctx.clientCapabilities itself is well justified.

Extended reasoning...

What this is. The PR adds five new public API surface points: McpServer.enableExtension(identifier, settings?), McpServer.getClientExtension(identifier), the Client mirrors enableExtension / getServerExtension, and McpRequestContext.clientCapabilities. The four helper methods are literal one-liners over already-public surface — enableExtension(id, settings) is registerCapabilities({ extensions: { [id]: settings } }) (packages/server/src/server/mcp.ts:168-170, packages/client/src/client/client.ts:752-753), and getClientExtension / getServerExtension are get*Capabilities()?.extensions?.[id]. This raises two related review questions: API-surface justification, and missing documentation.

API surface / minimalism. The repo's stated principles are minimalism, burden of proof on addition, "helpers users can write themselves belong in a cookbook, not the SDK," and "new abstractions have at least one concrete callsite in the PR." Concretely walking the diff: a grep across packages/ and examples/ shows the only callsites of the four new methods are their own definitions and the two new extensionHelpers.test.ts files — no extension implementation, example, or SDK-internal consumer uses them in this PR. The PR description's own before/after differs by essentially one line per call. The "one way to do things" tension is even visible in the new JSDoc, which steers the primary (factory-time) use case toward McpRequestContext.clientCapabilities rather than getClientExtension. So per the repo's own bar, the four wrappers are at the questionable end; the registerCapabilities({ extensions: ... }) pattern could equally live in a cookbook/example without growing the public API on both Client and McpServer.

Mitigating factors (for the maintainers to weigh). The helpers are spec-anchored (SEP-2133 capabilities.extensions), the commit references MCP Apps integration (#76) as the intended downstream consumer (just not present in this diff), and getClientExtension has partial independent justification: the underlying Server.getClientCapabilities() accessor is @deprecated, so a hand-rolled userland equivalent forces users onto a deprecated API, while the helper takes the eslint-disable internally. None of these is decisive on its own, which is why this is filed as a design question rather than a defect — but it is exactly the highest-priority question the repo's review ordering asks reviewers to lead with.

Documentation gap. The repo's review checklist requires for new features: "verify prose documentation is added (not just JSDoc), and assess whether examples/ needs a new or updated example." Concretely: a grep for enableExtension, getClientExtension, getServerExtension, SEP-2133, or capabilities.extensions across docs/**/*.md returns nothing relevant (the only 'extension' hits are unrelated — auth-extensions, the VS Code extension, file extensions), and examples/ has no extension-related entry. docs/server.md and docs/client.md are comprehensive feature-by-feature prose guides (Tools, Resources, Prompts, Sampling, Elicitation, …), so a new capability feature with four methods plus a new McpRequestContext field is exactly the kind of thing that gets a section there. The only docs change in this PR is the migration-guide section about subclassing Protocol, which is an unrelated v1→v2 topic. The changeset + JSDoc do not satisfy the checklist.

Step-by-step illustration. A user wanting to adopt the new feature today would: (1) search docs/server.md or docs/client.md for "extension" — find nothing; (2) search examples/ — find nothing; (3) eventually find the JSDoc on enableExtension, which itself says it is a "thin convenience over registerCapabilities" and (for reads) points back at McpRequestContext.clientCapabilities as the preferred path; (4) discover that the one-line equivalent server.server.registerCapabilities({ extensions: { 'io.modelcontextprotocol/ui': {} } }) was already available before this PR. That walk-through is the substance of both halves of this comment: the surface is added without the docs/example that would justify and explain it, and without a consumer that demonstrates why the wrapper (rather than the documented pattern) is the right shape.

How to address it. Two reasonable resolutions: (a) keep the helpers and add a short prose section in docs/server.md / docs/client.md (and/or the 2026-07-28 doc for ctx.clientCapabilities, since it is a modern-HTTP-path feature) demonstrating the SEP-2133 pattern and the createMcpHandler factory-time branching, ideally alongside (or in the same release train as) the MCP Apps consumer that motivates them; or (b) drop the four wrappers from this PR and document the registerCapabilities({ extensions: ... }) pattern in a cookbook/example, landing the wrappers later together with their concrete consumer. ctx.clientCapabilities is well justified either way (it exposes information not otherwise reachable at factory time) and is not part of this concern beyond needing the same prose documentation.

private _toolHandlersInitialized = false;

private setToolRequestHandlers() {
Expand Down
14 changes: 14 additions & 0 deletions packages/server/test/server/createMcpHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,20 @@ describe('createMcpHandler — modern path', () => {
expect(state.contexts).toHaveLength(1);
expect(state.contexts[0]?.era).toBe('modern');
expect(state.contexts[0]?.requestInfo).toBeInstanceOf(Request);
expect(state.contexts[0]?.clientCapabilities).toEqual({ elicitation: { form: {} } });
});

it('exposes the envelope-declared extensions on McpRequestContext.clientCapabilities', async () => {
const { factory, state } = testFactory();
const handler = createMcpHandler(factory);

const envelope = {
...ENVELOPE,
[CLIENT_CAPABILITIES_META_KEY]: { extensions: { 'io.modelcontextprotocol/ui': { version: '1' } } }
};
const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'x' }, envelope)));
expect(response.status).toBe(200);
expect(state.contexts[0]?.clientCapabilities?.extensions?.['io.modelcontextprotocol/ui']).toEqual({ version: '1' });
});

it('serves server/discover on the modern path with the modern supported list', async () => {
Expand Down
59 changes: 59 additions & 0 deletions packages/server/test/server/extensionHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* SEP-2133 generic extension helpers — `enableExtension` / `getClientExtension`
* on `McpServer`. Thin convenience over `registerCapabilities` /
* `getClientCapabilities`; the helpers are not Apps-specific.
*/
import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';

import { McpServer } from '../../src/server/mcp';

const UI = 'io.modelcontextprotocol/ui';

describe('McpServer.enableExtension', () => {
it('writes ServerCapabilities.extensions[identifier], defaulting settings to {}', () => {
const server = new McpServer({ name: 's', version: '1.0.0' });
server.enableExtension(UI);
server.enableExtension('vendor.example/thing', { mode: 'fast' });
expect(server.server.getCapabilities().extensions).toEqual({
[UI]: {},
'vendor.example/thing': { mode: 'fast' }
});
});

it('throws after connecting (delegates to registerCapabilities)', async () => {
const server = new McpServer({ name: 's', version: '1.0.0' });
const [, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
expect(() => server.enableExtension(UI)).toThrow(/after connecting/);
await server.close();
});
});

describe('McpServer.getClientExtension', () => {
it('reads ClientCapabilities.extensions[identifier] after a legacy initialize', async () => {
const server = new McpServer({ name: 's', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);

// Drive a 2025-era initialize from the client side carrying an
// `extensions` declaration (open-set; passes through the 2025 codec).
clientTransport.onmessage = () => {};
await clientTransport.start();
await clientTransport.send({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: LATEST_PROTOCOL_VERSION,
clientInfo: { name: 'c', version: '1.0.0' },
capabilities: { extensions: { [UI]: { maxWidth: 800 } } }
}
});
await new Promise(resolve => setTimeout(resolve, 0));

expect(server.getClientExtension(UI)).toEqual({ maxWidth: 800 });
expect(server.getClientExtension('absent/key')).toBeUndefined();
await server.close();
});
});
Loading