docs(server,examples): document extension capabilities with a runnable example#2387
Conversation
|
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
There was a problem hiding this comment.
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.
4426cf1 to
5758252
Compare
There was a problem hiding this comment.
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.
| 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. |
There was a problem hiding this comment.
🟡 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.ts—ClientCapabilities/ServerCapabilitieshave noextensionsmember (the only "extension" hits are unrelated comments about unofficialparamsextensions).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 theWire2025*Capabilitiestypes 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.
Documents
capabilities.extensions— a "Extension capabilities" section in the server guide (declaring viaregisterCapabilities), a client-guide subsection (reading the negotiated map), and a runnableexamples/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
Checklist
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 onmain,f2464f11d)Worktree:
scratch/wt-ext-floors— NOT pushed; Felix ships.Adds
Server.registerExtensionCapability(id, settings, { minProtocolRevision })and turns the legacyinitializecapability advertisement into a per-extension floor projection:capabilities.extensionsentries 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.tsadjudicates theextensionscapability key as "2026-only; absent from the 2025 wire view" (Wire2025ServerCapabilitiesomits it), yet the runtime advertises every registeredextensionsentry on a legacyinitializehandshake — 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: theextensionsKEY 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 thelegacyAdvertisedCapabilitiesJSDoc.This enables revision-gated extensions (e.g. upcoming extension packages).
Design notes:
Server._oninitializeseam via the purelegacyAdvertisedCapabilities(capabilities, negotiatedProtocolVersion, extensionFloors)helper — NOT in the 2025 codec, whose encode stays the byte-frozen identity._oninitializeis the singleInitializeResultconstruction site, so no other path needs wiring.extensionskey itself is omitted — the advertisement is byte-identical to a pre-extensions server's.server/discoveradvertisement is unchanged and always carries every registered entry; a modern client sees the full set regardless of floors.registerExtensionCapabilitywithoutminProtocolRevisionrecords no floor and is exactly equivalent toregisterCapabilities({ extensions: { [id]: settings } }).How Has This Been Tested?
packages/server/test/server/legacyCapabilityProjection.test.ts(12 cells):initializecells: byte-identity against anInitializeResultcaptured 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-lessregisterExtensionCapabilityequals the v1.x surface;server/discovercontrol cells: full set advertised,discoverAdvertisedCapabilitiesuntouched.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
Checklist
Additional context
@modelcontextprotocol/serverminor (.changeset/extension-capability-floors.md).legacyAdvertisedCapabilitiesis exported from theserver.tsmodule for direct unit coverage but deliberately NOT added to the packageindex.tspublic surface (mirrorsdiscoverAdvertisedCapabilities).registerExtensionCapabilitycalls accumulate entries (mergeCapabilitiesmerges theextensionsmap one level deep); the floor map is only written afterregisterCapabilitiessucceeds, so the after-connect throw leaves no partial state.