Skip to content

feat: extension capability helpers + ctx.clientCapabilities#2379

Closed
felixweinberger wants to merge 1 commit into
mainfrom
fweinberger/apps
Closed

feat: extension capability helpers + ctx.clientCapabilities#2379
felixweinberger wants to merge 1 commit into
mainfrom
fweinberger/apps

Conversation

@felixweinberger

Copy link
Copy Markdown
Contributor

Generic helpers for declaring and reading extension capabilities, plus per-request access to the client's declared capabilities.

// before: extension authors reached into capabilities by hand
server.registerCapabilities({ extensions: { 'io.modelcontextprotocol/ui': { ... } } });
const ui = server.getClientCapabilities()?.extensions?.['io.modelcontextprotocol/ui'];

// after:
server.enableExtension('io.modelcontextprotocol/ui', { ... });
const ui = server.getClientExtension('io.modelcontextprotocol/ui');
// and inside a createMcpHandler factory, the client's capabilities are on the context:
createMcpHandler((ctx) => {
  if (ctx.clientCapabilities?.extensions?.['io.modelcontextprotocol/ui']) { ... }
  return buildServer();
});

Client-side mirrors: client.enableExtension(id, settings) / client.getServerExtension(id).

Also adds a migration section for code that subclassed Protocol in v1 (the class is internal in v2; subclass Client or Server instead and override buildContext()).

How Has This Been Tested?

server 354, client 699, e2e 2629. New extensionHelpers.test.ts in both packages.

Breaking Changes

None (additive).

Types of changes

  • New feature (non-breaking change which adds functionality)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

…tegration, #76)

SEP-2133 generic extension declaration helpers, plus the per-request
factory-context capability hook. Minimum SDK delta from
docs/2026-06-25-mcp-apps-2026-interaction.md §6 step 1 — unblocks the
ext-apps v2 refactor without re-exporting Protocol.

- McpRequestContext.clientCapabilities — populated on the modern HTTP
  path from the validated envelope so a createMcpHandler factory can
  branch on extension support at construction time.
- McpServer.enableExtension(id, settings?) / getClientExtension(id) —
  thin convenience over registerCapabilities / getClientCapabilities.
- Client.enableExtension(id, settings?) / getServerExtension(id) — the
  client-side mirror.
- migration: 'Subclassing Protocol → subclass Client/Server' section
  (the documented SEP-2133 extension-author path; Protocol stays
  internal).
- wireTypes.ts adjudication ledger: clarify extensions is type-view-only
  absent on 2025; runtime pass-through as an open-set key.

examples/apps/ is deliberately deferred to step 2 (the ext-apps v2
refactor) per the design doc's sequencing — it depends on
@modelcontextprotocol/ext-apps which still imports v1 paths.
@felixweinberger felixweinberger requested a review from a team as a code owner June 26, 2026 14:22
@changeset-bot

changeset-bot Bot commented Jun 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6371609

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/server Minor
@modelcontextprotocol/client Minor
@modelcontextprotocol/express Major
@modelcontextprotocol/fastify Major
@modelcontextprotocol/hono Major
@modelcontextprotocol/node Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jun 26, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2379

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2379

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2379

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2379

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2379

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2379

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2379

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2379

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2379

commit: 6371609

Comment on lines +160 to +186
/**
* 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];
}

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.

Comment on lines +362 to +367

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

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.

Comment on lines +23 to +25
* - `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.

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.

@felixweinberger felixweinberger marked this pull request as draft June 26, 2026 14:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant