Skip to content

docs(server,examples): document extension capabilities with a runnable example#2387

Merged
felixweinberger merged 1 commit into
mainfrom
fweinberger/extension-capability-floors
Jun 29, 2026
Merged

docs(server,examples): document extension capabilities with a runnable example#2387
felixweinberger merged 1 commit into
mainfrom
fweinberger/extension-capability-floors

Conversation

@felixweinberger

@felixweinberger felixweinberger commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Documents capabilities.extensions — a "Extension capabilities" section in the server guide (declaring via registerCapabilities), a client-guide subsection (reading the negotiated map), and a runnable examples/extension-capabilities/ story that asserts the advertisement on both era legs.

Motivation and Context

Extension capabilities shipped on v1.x, but the SDK has zero docs or examples for them — neither how a server declares an entry nor how a client reads the negotiated map. This adds both. No code changes.

How Has This Been Tested?

The example pair is a self-verifying e2e test and runs in the CI examples matrix (stdio + http × modern + legacy); both legs assert the extension entry and its settings are advertised. Full local gates: run:examples (73/73 legs), docs:check, sync:snippets --check, lint, typecheck.

Breaking Changes

None.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • 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

Additional context

No changeset: docs and examples only — @mcp-examples/* and the example quickstarts are in the changeset ignore list, and no published package changes.


--- superseded draft ---

PR draft: feat(server): per-extension version floors for capability advertisement

Branch: fweinberger/extension-capability-floors (one commit on main, f2464f11d)
Worktree: scratch/wt-ext-floors — NOT pushed; Felix ships.


Adds Server.registerExtensionCapability(id, settings, { minProtocolRevision }) and turns the legacy initialize capability advertisement into a per-extension floor projection: capabilities.extensions entries whose declared floor exceeds the negotiated protocol revision are stripped, while floor-less entries pass through unchanged.

Motivation and Context

An extension entry that requires a newer protocol revision must not be advertised to a peer that negotiated an older one — the older wire has no vocabulary for the extension's methods, so advertising it invites requests the connection cannot express.

This also resolves an inconsistency in the current tree: packages/core-internal/src/wire/rev2025-11-25/wireTypes.ts adjudicates the extensions capability key as "2026-only; absent from the 2025 wire view" (Wire2025ServerCapabilities omits it), yet the runtime advertises every registered extensions entry on a legacy initialize handshake — behavior the integration suite pins ("should register and advertise server extensions capability"), and which matches the ecosystem: the field shipped on v1.x and extensions are negotiated from 2025-era handshakes today. The resolution is per-entry, not per-key: the extensions KEY remains legitimate v1.x-preserved vocabulary (floor-less entries keep their advertisement byte-for-byte), while an individual ENTRY can opt in to revision gating by declaring a floor. The conservative alternative — treat every extension as modern-only and strip the whole key on legacy — was rejected because it silently breaks every extension consumer negotiating over a 2025-era handshake; it is noted as the rejected alternative in the legacyAdvertisedCapabilities JSDoc.

This enables revision-gated extensions (e.g. upcoming extension packages).

Design notes:

  • The projection lives at the Server._oninitialize seam via the pure legacyAdvertisedCapabilities(capabilities, negotiatedProtocolVersion, extensionFloors) helper — NOT in the 2025 codec, whose encode stays the byte-frozen identity. _oninitialize is the single InitializeResult construction site, so no other path needs wiring.
  • Strip only when the floor EXCEEDS the negotiated revision: a floor equal to the negotiated revision is advertised. Revision identifiers are ISO dates, so lexicographic comparison orders them chronologically.
  • When every entry is floored out, the extensions key itself is omitted — the advertisement is byte-identical to a pre-extensions server's.
  • The server/discover advertisement is unchanged and always carries every registered entry; a modern client sees the full set regardless of floors.
  • registerExtensionCapability without minProtocolRevision records no floor and is exactly equivalent to registerCapabilities({ extensions: { [id]: settings } }).

How Has This Been Tested?

  • New unit suite packages/server/test/server/legacyCapabilityProjection.test.ts (12 cells):
    • pure-helper cells: floored-above stripped without input mutation, floor-less pass-through, mixed-map per-entry projection, floor==negotiated boundary (advertised), all-stripped key omission, no-extensions identity;
    • wire-level initialize cells: byte-identity against an InitializeResult captured on the unmodified SDK for a server with no extension registrations (oracle pinned as an exact JSON string), floored entry stripped while floor-less entry survives, key omitted when the only entry is floored, floor-less registerExtensionCapability equals the v1.x surface;
    • server/discover control cells: full set advertised, discoverAdvertisedCapabilities untouched.
  • Full gates: server package typecheck + lint + tests (362 passed, 34 files); full integration suite 348/348 with the existing extensions-advertisement test unmodified; client package suite 698/698 (uses getServerCapabilities, untouched).

Breaking Changes

None. Servers that never declare a floor — including everything registered via plain registerCapabilities({ extensions }) — advertise byte-identically to before; servers without extensions are pinned byte-identical by the oracle test.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • 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

Additional context

  • Changeset: @modelcontextprotocol/server minor (.changeset/extension-capability-floors.md).
  • legacyAdvertisedCapabilities is exported from the server.ts module for direct unit coverage but deliberately NOT added to the package index.ts public surface (mirrors discoverAdvertisedCapabilities).
  • Repeated registerExtensionCapability calls accumulate entries (mergeCapabilities merges the extensions map one level deep); the floor map is only written after registerCapabilities succeeds, so the after-connect throw leaves no partial state.

@felixweinberger felixweinberger requested a review from a team as a code owner June 29, 2026 15:12
@changeset-bot

changeset-bot Bot commented Jun 29, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 5758252

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

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

@pkg-pr-new

pkg-pr-new Bot commented Jun 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/codemod

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

@modelcontextprotocol/core

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

@modelcontextprotocol/server

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

@modelcontextprotocol/server-legacy

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 5758252

Comment thread packages/server/src/server/server.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Update on my earlier review: the current revision does include prose docs (docs/server.md / docs/client.md sections) and a runnable extension-capabilities example, so the documentation/example portion of that comment is addressed — please disregard it. The remaining open item is the design-level one: whether a public registerExtensionCapability + minProtocolRevision floor should land ahead of a concrete revision-gated extension consumer, which is a maintainer call.

Extended reasoning...

Overview

PR #2387 adds Server.registerExtensionCapability(id, settings, { minProtocolRevision }) and a per-extension floor projection applied in _oninitialize via the pure legacyAdvertisedCapabilities helper, plus a new unit suite, prose docs in docs/server.md / docs/client.md, guide snippets, and a runnable examples/extension-capabilities/ story. The implementation is small and self-contained: floor-less entries (and servers without extensions) advertise byte-identically to before, floored entries are stripped only when the floor exceeds the negotiated revision, and server/discover is untouched.

Security risks

None identified. The change only narrows what capability entries are advertised on a legacy handshake; it does not touch auth, transport lifecycle, or request handling, and the helper is pure (no input mutation).

Level of scrutiny

This adds new public, semver-bound API surface to @modelcontextprotocol/server and an SDK-only concept (minProtocolRevision) with no anchor in the spec schema. Per the repo conventions (burden of proof on addition, concrete consumer required), that design question is the gating concern and is a maintainer decision — not something to shadow-approve, even though I found no correctness bugs in the projection logic or tests.

Other factors

My earlier review claimed the feature shipped without prose docs or an example; the current PR contents contradict that (docs sections, guide snippets, and the extension-capabilities/ example are all in the diff), so this comment retracts that portion rather than leaving an inaccurate claim standing. The new tests look thorough (boundary at floor==negotiated, key omission when all entries are floored, byte-identity oracle for no-extension servers, discover control cells). The remaining open item for the author/maintainers is solely whether the public API should land ahead of a real floored extension consumer.

@felixweinberger felixweinberger force-pushed the fweinberger/extension-capability-floors branch from 4426cf1 to 5758252 Compare June 29, 2026 15:57
@felixweinberger felixweinberger changed the title feat(server): per-extension version floors for capability advertisement docs(server,examples): document extension capabilities with a runnable example Jun 29, 2026
@felixweinberger felixweinberger merged commit 49aeb8e into main Jun 29, 2026
19 checks passed
@felixweinberger felixweinberger deleted the fweinberger/extension-capability-floors branch June 29, 2026 16:02

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM — docs and a self-verifying example only, no published-package changes; the inline nit about the spec link is non-blocking.

Extended reasoning...

Overview

This PR is now docs/examples-only: an "Extension capabilities" section in docs/server.md, a matching subsection in docs/client.md, the synced guide snippets, a new examples/extension-capabilities/ story (server declares capabilities.extensions via registerCapabilities, client asserts the negotiated map on both era legs), the examples README row, and the pnpm-lock entry for the new workspace package. The earlier registerExtensionCapability / minProtocolRevision floor API from the superseded draft is gone, so the prior design concern no longer applies — this revision documents an existing v1.x feature without adding any SDK surface.

Security risks

None. No published package code changes; the example uses only existing public APIs (registerCapabilities, getServerCapabilities, serveStdio, createMcpHandler) and binds to localhost like the other example stories.

Level of scrutiny

Low. Documentation prose plus a private @mcp-examples/* workspace package that is auto-discovered by the CI examples runner and self-asserts the advertised extension entry on both legs. I verified the documented behavior against the in-repo schemas: the 2025-revision ServerCapabilitiesSchema carries extensions: z.record(z.string(), JSONObjectSchema).optional(), so the legacy-initialize advertisement the docs and example rely on is real existing behavior, not new code.

Other factors

The single bug-hunter finding is a documentation nit (spec link points at specification/latest rather than draft for a draft-only capability key, and "free-form JSON" vs "free-form JSON object") — worth a one-line tweak but not blocking. The changeset bot warning is expected since example packages are in the changeset ignore list. Snippet sync (source= regions) and lock-file changes follow the established repo patterns.

Comment thread docs/server.md
Comment on lines +363 to +373
A server advertises support for [MCP extensions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#capability-negotiation) through `capabilities.extensions` — a map from extension identifier to that extension's settings object. Declare entries with
{@linkcode @modelcontextprotocol/server!server/server.Server#registerCapabilities | server.server.registerCapabilities()} before connecting:

```ts source="../examples/guides/serverGuide.examples.ts#extensionCapabilities_register"
server.server.registerCapabilities({
extensions: { 'com.example/feature-flags': { flags: ['dark-mode', 'beta-search'] } }
});
```

The map is advertised in the `initialize` result on legacy connections and in the `server/discover` response on 2026-07-28 ones. Identifiers are prefix-qualified per the spec's `_meta` key naming rules (e.g. `com.example/feature-flags`); each value is free-form JSON for
that extension's settings — `{}` means supported with no settings.

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 "Extension capabilities" section links "MCP extensions" to specification/latest, but the capabilities.extensions key only exists in the 2026-07-28 draft revision (it is absent from the 2025-11-25 spec types and the 2025 wire view), so the link should point at specification/draft per the repo's convention for draft-only material — or the prose should note the spec revision. Secondarily, "each value is free-form JSON" on line 372 would be more precise as "free-form JSON object", since the schema (z.record(z.string(), JSONObjectSchema)) requires each extension's settings value to be a JSON object.

Extended reasoning...

1. The spec link targets latest, but the feature is draft-only.

Line 363 introduces the section with a link to https://modelcontextprotocol.io/specification/latest/basic/lifecycle#capability-negotiation. However, the extensions capability key is not part of any published spec revision — it exists only in the 2026-07-28 draft schema. The repo's own spec mirrors are decisive here:

  • packages/core-internal/src/types/spec.types.2025-11-25.tsClientCapabilities/ServerCapabilities have no extensions member (the only "extension" hits are unrelated comments about unofficial params extensions).
  • packages/core-internal/src/types/spec.types.2026-07-28.ts (~lines 744–754 and 841–851) — extensions?: { [key: string]: JSONObject } is declared on both capability types, including the _meta-key-naming rule the new paragraph paraphrases.
  • packages/core-internal/src/wire/rev2025-11-25/wireTypes.ts:23 — the adjudication ledger states: "'extensions' capability key: 2026-only; absent from the 2025 wire view", and the Wire2025*Capabilities types omit the key.

Since 2026-07-28 is the draft revision (the docs themselves call it that throughout), the specification/latest alias resolves to the latest published revision (2025-11-25), whose lifecycle/capability-negotiation page does not define MCP extensions — so the introductory link sends readers to a spec page that doesn't document the feature being introduced.

Step-by-step: a reader hits the new section → clicks "MCP extensions" → lands on the 2025-11-25 lifecycle capability-negotiation section → finds no mention of an extensions capability map or the identifier-naming rule the paragraph references → has no spec anchor for the feature. Every other specification/latest link in docs/ (instructions, progress, logging, authorization) points at features present in the published revision, while draft-only content (the SEP-2577 deprecated-features registry) is consistently linked via specification/draft (e.g. docs/server.md lines ~380/486/586 and docs/client.md ~594/625/672). Fix: change the link to specification/draft/basic/lifecycle#capability-negotiation, or keep latest and add a parenthetical noting the capability is defined in the 2026-07-28 draft revision. (Caveat: modelcontextprotocol.io could not be fetched from the review environment; the claim rests on the in-repo spec twins and the wire adjudication ledger, which pin extensions as 2026-only.)

2. "free-form JSON" → "free-form JSON object".

Line 372 says "each value is free-form JSON for that extension's settings". The implementation constrains each value to a JSON object: ServerCapabilitiesSchema declares extensions: z.record(z.string(), JSONObjectSchema) in both packages/core-internal/src/wire/rev2025-11-25/schemas.ts:492 and rev2026-07-28/schemas.ts:254, and JSONObjectSchema is z.record(z.string(), JSONValueSchema) — a bare string, number, or array value is rejected by the Zod schema (and by the TypeScript capability type at compile time). One reviewer noted the surrounding sentence already says "settings object" twice and shows {}, so the misread risk is low and TypeScript would catch a wrong value anyway — fair, which is why this half is purely a one-word precision tweak ("free-form JSON object") rather than anything blocking; it just keeps the prose strictly consistent with what the schema accepts (the contents of the object are free-form, the value's top-level type is not).

Both points are documentation-accuracy fixes in a docs-only PR — small, mechanical, and non-blocking.

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