-
Notifications
You must be signed in to change notification settings - Fork 1.9k
feat: extension capability helpers + ctx.clientCapabilities #2379
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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 |
|---|---|---|
|
|
@@ -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
|
||
|
Comment on lines
+23
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Extended reasoning...What the comment claims vs. what the code does. The PR rewrites the adjudication-ledger bullet in |
||
| * - `CreateMessageRequestParams.metadata`: 2025 wire `object`; neutral | ||
| * `JSONObject`. | ||
| * - SEP-2106: `CallToolResult.structuredContent` / the `tool_result` | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ | |
| Icon, | ||
| Implementation, | ||
| InputRequiredResult, | ||
| JSONObject, | ||
| ListPromptsResult, | ||
| ListResourcesResult, | ||
| ListToolsResult, | ||
|
|
@@ -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
|
||
|
Comment on lines
+160
to
+186
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Design/docs question on the four new one-line helpers ( Extended reasoning...What this is. The PR adds five new public API surface points: 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 Mitigating factors (for the maintainers to weigh). The helpers are spec-anchored (SEP-2133 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 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 How to address it. Two reasonable resolutions: (a) keep the helpers and add a short prose section in |
||
| private _toolHandlersInitialized = false; | ||
|
|
||
| private setToolRequestHandlers() { | ||
|
|
||
| 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(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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
versionNegotiationat its default lets aClientsubclass over a non-MCP wire "behave exactly like a v1Protocolsubclass", but the default posture islegacy, andClient.connect()'s legacy branch still runs the full MCPinitializehandshake (_legacyHandshake), which a non-MCP peer cannot answer — soconnect()hangs/fails and closes the transport. The default only skips theserver/discoverprobe; the section should either tell readers to overrideconnect()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→ subclassClientorServer" section says: "If the link is genuinely not an MCP wire, keep every handler on the 3-arg custom form and skipconnect()-time version negotiation by leavingversionNegotiationat its default — the subclass then behaves exactly like a v1Protocolsubclass with string-keyed handlers."What the code actually does
The default
versionNegotiationposture is'legacy'(DEFAULT_VERSION_NEGOTIATION_MODE = 'legacy'inpackages/client/src/client/versionNegotiation.ts:100, applied byresolveVersionNegotiationwhen the option is absent). InClient.connect()(packages/client/src/client/client.ts:950-985), the legacy branch callssuper.connect(transport)and then_legacyHandshake()(client.ts:993-1058), which:initializerequest carryingprotocolVersion/capabilities/clientInfo,InitializeResultwhoseprotocolVersionis in the legacy supported list (otherwise it throws"Server's protocol version is not supported"),notifications/initialized, andthis.close()and rethrows — tearing down the transport.So leaving
versionNegotiationat its default only avoids theserver/discovernegotiation probe; the MCPinitializehandshake still runs atconnect()time.Step-by-step proof
class MyRuntime extends Client { ... }, registers handlers via the 3-arg custom form, leavesversionNegotiationunset.await runtime.connect(myCustomTransport)against their non-MCP peer (the same call their v1Protocolsubclass made).resolveVersionNegotiationreturns kind'legacy'→connect()calls_legacyHandshake().{"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 validInitializeResult(the result fails validation / the supported-version check throws).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 v1Client.connect, notProtocol), so a v1Protocolsubclass 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
versionNegotiationvalue that disables the handshake entirely onClient—'legacy'runsinitialize, the negotiating modes additionally runserver/discover. The only ways to get a v1-Protocol-likeconnect()are to overrideconnect()in the subclass (e.g. calling the protected baseProtocol.connect) or to not useClient.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 MCPinitializehandshake is not sent, or (b) drop the "skip connect()-time version negotiation … behaves exactly like a v1Protocolsubclass" 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.