Skip to content

feat(core): add extension() registrar for SEP-2133 capability-aware custom methods#1868

Draft
felixweinberger wants to merge 7 commits intofweinberger/custom-method-handlersfrom
fweinberger/extension-registrar
Draft

feat(core): add extension() registrar for SEP-2133 capability-aware custom methods#1868
felixweinberger wants to merge 7 commits intofweinberger/custom-method-handlersfrom
fweinberger/extension-registrar

Conversation

@felixweinberger
Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger commented Apr 9, 2026

Adds Client.extension(id, settings, {peerSchema?}) and Server.extension(...) returning an ExtensionHandle that merges settings into capabilities.extensions[id], exposes getPeerSettings() (optionally schema-validated), and wraps the *Custom* methods from #1846 with peer-capability gating under enforceStrictCapabilities.

Stacked on #1846.

Motivation and Context

SEP-2133 added capabilities.extensions for negotiating protocol extensions, but nothing in the SDK reads it. This connects that field to the custom-method API so extension authors declare once and the SDK handles advertisement and peer checks.

How Has This Been Tested?

Unit tests (extensionHandle.test.ts, 11), integration tests over InMemoryTransport (server/extension.test.ts, 4), and examples/server/src/customMethodExample.ts (server) and examples/client/src/customMethodExample.ts (client) runs end-to-end showing both sides reading the other's extensions[id].

Breaking Changes

None — additive.

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

Flat setCustom*/sendCustom* from #1846 remain available as the ungated path. Method-ownership tracking, removeExtension, and a typed method registry are intentionally deferred.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 9, 2026

🦋 Changeset detected

Latest commit: 9434b46

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

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/client Minor
@modelcontextprotocol/server 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
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 9, 2026

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/server

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 9434b46

@felixweinberger
Copy link
Copy Markdown
Contributor Author

Note: this PR depends on #1846

@felixweinberger felixweinberger marked this pull request as ready for review April 9, 2026 14:31
@felixweinberger felixweinberger requested a review from a team as a code owner April 9, 2026 14:31
@felixweinberger felixweinberger marked this pull request as draft April 9, 2026 14:37
@felixweinberger felixweinberger force-pushed the fweinberger/extension-registrar branch 4 times, most recently from ef775f7 to 63335f1 Compare April 9, 2026 17:19
@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

1 similar comment
@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

@felixweinberger felixweinberger force-pushed the fweinberger/extension-registrar branch from ef775f7 to 63335f1 Compare April 9, 2026 17:41
Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

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


Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 packages/client/test/client/barrelClean.test.ts:10-13 — The 'chunks transitively imported by dist/index.mjs' test only performs a one-level-deep import scan — chunkImportsOf() reads and regex-matches the entry file's direct .mjs imports but never recurses into those chunks to check their own imports. The test description claims transitive coverage but the implementation is shallow; if a shared chunk directly reachable from index.mjs itself imported another chunk containing child_process or cross-spawn, the test would pass silently. The same issue exists in the identical helper in packages/server/test/server/barrelClean.test.ts.

    Extended reasoning...

    What the bug is and how it manifests

    The chunkImportsOf() helper (packages/client/test/client/barrelClean.test.ts lines 10-13) reads a single file and extracts relative .mjs imports via a regex:

    function chunkImportsOf(entryPath: string): string[] {
        const src = readFileSync(entryPath, 'utf8');
        return [...src.matchAll(/from\s+["']\.\/(.+?\.mjs)["']/g)].map(m => join(distDir, m[1]\!));
    }

    The caller feeds it dist/index.mjs and iterates over the result to check each chunk for NODE_ONLY patterns. The function is never called recursively on the discovered chunks. Despite this, the test is named "chunks transitively imported by dist/index.mjs contain no process-spawning runtime imports" — a description that implies multi-level traversal.

    The specific code path that triggers it

    If the bundler produces a two-level chain — index.mjs → chunk-A.mjs → chunk-B.mjs — where chunk-B.mjs contains node:child_process or cross-spawn, then:

    1. chunkImportsOf('\''dist/index.mjs'\'') returns ['\''dist/chunk-A.mjs'\'']
    2. The loop checks chunk-A.mjs — passes (no banned patterns there)
    3. chunk-B.mjs is never read or checked
    4. The test reports success while the banned dependency is reachable from index.mjs

    Why the refutation does not fully resolve this

    One verifier argued that tsdown/rollup in this configuration produces flat shared chunks that are leaves and do not import other chunk files, so the one-level check is functionally adequate. This is plausible for the current output. However:

    1. The claim is a property of the current build output, not enforced by the test or the bundler config. With 6 entry points and noExternal: ['\''@modelcontextprotocol/core'\''], the bundler may produce multi-level chunk chains if the module graph changes (new entry points, deeper shared dependencies, changed split heuristics).
    2. The test description actively misleads future maintainers: a developer who trusts the label "transitively imported" will not know the guard has a depth of 1.
    3. Both packages share identical shallow implementations, doubling the surface where this assumption could silently break.

    What the impact would be

    If the bundle structure changes in a future PR and a multi-level chunk chain forms that includes a Node-only import, this test would continue to pass, defeating the entire purpose of the browser-safety guard.

    How to fix it

    Replace the single-level helper with a BFS/DFS traversal:

    function chunksTransitivelyImportedBy(entryPath: string): string[] {
        const visited = new Set<string>();
        const queue = [entryPath];
        while (queue.length > 0) {
            const file = queue.shift()\!;
            if (visited.has(file)) continue;
            visited.add(file);
            const src = readFileSync(file, '\''utf8'\'');
            for (const m of src.matchAll(/from\s+["']\.\/(.+?\.mjs)["']/g)) {
                queue.push(join(distDir, m[1]\!));
            }
        }
        visited.delete(entryPath);
        return [...visited];
    }

    This is a small, mechanical change that makes the test match its documented behavior and is resilient to future bundler output changes.

    Step-by-step proof

    1. Bundler emits: index.mjs (imports ./chunk-A.mjs), chunk-A.mjs (imports ./chunk-B.mjs), chunk-B.mjs (contains import '\''node:child_process'\'').
    2. chunkImportsOf('\''dist/index.mjs'\'') regex-matches index.mjs and returns ['\''dist/chunk-A.mjs'\''].
    3. Loop reads chunk-A.mjs, finds no NODE_ONLY match — assertion passes.
    4. chunk-B.mjs is never opened.
    5. node:child_process is reachable from the browser-targeted root entry but the test reports green.
  • 🔴 packages/core/src/exports/public/index.ts:77 — The top commit (ef775f7) is explicitly labeled DO NOT PUSH and re-adds export { InMemoryTransport } from '../../util/inMemory.js' to packages/core/src/exports/public/index.ts (line 77). Because both @modelcontextprotocol/client and @modelcontextprotocol/server re-export this file via export * from '@modelcontextprotocol/core/public', merging as-is would silently re-expose InMemoryTransport in the stable public API of both packages — directly contradicting docs/migration.md in this very PR, which states it was intentionally removed.

    Extended reasoning...

    What the bug is and how it manifests

    The HEAD commit ef775f7 ('chore(local): re-add InMemoryTransport to public for ext-apps test linking (DO NOT PUSH)') adds one line to packages/core/src/exports/public/index.ts at line 77:

    export { InMemoryTransport } from '../../util/inMemory.js';

    This line was added as a local workaround to make the integration test files in packages/client/test/client/extension.test.ts and packages/server/test/server/extension.test.ts able to import InMemoryTransport via @modelcontextprotocol/core. The commit message explicitly warns it must not be pushed.

    The specific code path that triggers it

    packages/core/src/exports/public/index.ts is the file pointed to by the @modelcontextprotocol/core/public subpath export. Both packages/client/src/index.ts and packages/server/src/index.ts end with:

    export * from '@modelcontextprotocol/core/public';

    So any symbol added to public/index.ts immediately becomes part of the stable public API of both user-facing packages. The accidental line at line 77 of public/index.ts therefore re-exports InMemoryTransport from both @modelcontextprotocol/client and @modelcontextprotocol/server.

    Why existing guards do not catch it

    The barrelClean.test.ts tests in client and server only check for Node.js process-spawning imports (child_process, node:stream, etc.) — they do not verify the absence of InMemoryTransport. The integration tests that need InMemoryTransport import it from @modelcontextprotocol/core (the internal barrel), not from the public surface, so the DO NOT PUSH line was added purely to satisfy a TypeScript module resolution issue during development.

    What the impact would be

    This PR's own docs/migration.md (added in this same PR) explicitly states:

    InMemoryTransport has been removed from the public API surface. ... @modelcontextprotocol/core is internal, not for production use.

    Merging this commit would directly contradict that documentation and the design decision to keep InMemoryTransport internal. Any downstream package that imports it from @modelcontextprotocol/client or @modelcontextprotocol/server would take a hard dependency on an internal API, and the SDK team would be unable to remove it later without a semver major bump.

    Step-by-step proof

    1. packages/core/src/exports/public/index.ts line 77: export { InMemoryTransport } from '../../util/inMemory.js'; — present in the repo at HEAD.
    2. packages/client/src/index.ts last line: export * from '@modelcontextprotocol/core/public'; — re-exports everything including line 77.
    3. packages/server/src/index.ts last line: export * from '@modelcontextprotocol/core/public'; — same.
    4. Result: import { InMemoryTransport } from '@modelcontextprotocol/client' and import { InMemoryTransport } from '@modelcontextprotocol/server' both resolve — the symbol is part of the stable public API.
    5. docs/migration.md (this PR): 'InMemoryTransport has been removed from the public API surface' — direct contradiction.

    How to fix it

    Remove the line export { InMemoryTransport } from '../../util/inMemory.js'; from packages/core/src/exports/public/index.ts before merging. The integration tests in extension.test.ts already import InMemoryTransport from @modelcontextprotocol/core (the internal barrel, not the public path), so removing this line does not break any test.

@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Thanks for addressing the two previous findings — both the async-throw fix and the removal of the stale cache are confirmed in the current commit. This introduces a new public API surface stacked on #1846, so it merits a human look before merging.

Extended reasoning...

Overview\nThis PR adds / returning an that merges settings into , exposes , and gates / on peer capability under . It touches (new), , , , and public export barrel files — 10 files in total, all additive.\n\n### Prior findings resolved\nBoth 🔴 issues from the first review are confirmed fixed in commit :\n- and are now declared , so capability errors reject through the Promise chain instead of throwing synchronously.\n- calls on every invocation with no intermediate caching; reconnect reflectivity is verified by a dedicated test.\n\n### Remaining bug (nit, posted as inline comment)\nA new nit has been flagged: silently overwrites the wire capabilities for an extension previously registered via , leaving stale relative to what the peer receives. The asymmetry with the duplicate-ID guard in (which throws) makes this a mild API footgun. Severity is nit — the scenario requires an unusual two-step setup — but the JSDoc invariant is technically violated.\n\n### Security risks\nNone. The changes are additive capability negotiation helpers with no auth, crypto, or permission paths involved.\n\n### Level of scrutiny\nModerate-high: this is a new, stable public API (, , ) that will be hard to change once released. The stacked dependency on #1846 means reviewers should verify the custom-method foundation is also in good shape before landing. Test coverage is solid (unit + integration), but the design choices (overload signatures, wiring, validation semantics) benefit from a human second opinion.\n\n### Other factors\nThe PR is additive with no breaking changes. Documentation and changeset are included. The author has been responsive to review feedback.

@felixweinberger
Copy link
Copy Markdown
Contributor Author

On the two "Additional findings (outside current diff)" items above:

@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

…ustom methods

Adds Client.extension(id, settings, {peerSchema?}) and Server.extension(...)
returning an ExtensionHandle that:
- merges settings into capabilities.extensions[id] (advertised in initialize)
- exposes getPeerSettings() with optional schema validation of the peer blob
- wraps setCustom*/sendCustom* with peer-capability gating under
  enforceStrictCapabilities

Connects the SEP-2133 capabilities.extensions field to the custom-method API
from #1846. Declare-before-register is structural (you cannot get a handle
without declaring); peer-gating on send mirrors assertCapabilityForMethod.

Stacked on #1846.
- sendRequest/sendNotification are now async so the strict-mode
  CapabilityNotSupported throw surfaces as a rejection (catchable via
  .catch()) instead of escaping synchronously
- getPeerSettings() no longer caches; reads current peer capabilities on
  each call so it reflects close()/connect() to a different peer
- Client.extension()/Server.extension() throw on duplicate id
- add packages/client/test/client/extension.test.ts covering the
  client-side initialize round-trip and reconnect behavior
…rCapabilities/extension ordering

ExtensionHandle now reads enforceStrictCapabilities via a getter (matching
the existing _getPeerExtensionSettings pattern), so a future setter on the
host would be observed. JSDoc on extension() notes that a later
registerCapabilities({extensions:{id:...}}) overwrites the wire value while
handle.settings reflects the original call.
…ck pre-connect

- duplicate-ID guard now throws SdkError(ExtensionAlreadyRegistered) instead of plain Error
- _assertPeerCapability defers when peer capabilities are not yet populated, so strict-mode
  sendRequest before connect() surfaces NotConnected (from the underlying send) rather than
  a misleading CapabilityNotSupported
Calls peerSchema['~standard'].validate directly (sync path); falls back to
raw blob with a warning if the schema's validation is async, since
getPeerSettings() is a synchronous read of already-received capabilities.
@felixweinberger felixweinberger force-pushed the fweinberger/extension-registrar branch from 9a22a16 to 641cc7a Compare April 13, 2026 17:59
@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

Comment on lines +158 to +168
private _assertPeerCapability(method: string): void {
// If peer capabilities are not yet populated (pre-connect), defer to the
// NotConnected error from the underlying send path rather than misreporting
// CapabilityNotSupported.
if (!this._getPeerCapabilitiesPresent()) return;
if (this._getEnforceStrictCapabilities() && this._getPeerExtensionSettings() === undefined) {
throw new SdkError(
SdkErrorCode.CapabilityNotSupported,
`Peer does not support extension "${this.id}" (required for ${method})`
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 nit: The 9a22a16 fix only covers the pre-first-connect path — _getPeerCapabilitiesPresent() keys off this._serverCapabilities \!== undefined (and _clientCapabilities on Server), but neither field is cleared by close()/_onclose(). So after await client.close(), in strict mode handle.sendRequest() still throws CapabilityNotSupported against the previous peer's caps instead of surfacing NotConnected. Consider keying the getter off live transport state (() => this.transport \!== undefined) to mirror the ordering _requestWithSchema already uses.

Extended reasoning...

What the bug is

Commit 9a22a16 added _getPeerCapabilitiesPresent() so that, before the first connect(), _assertPeerCapability defers to the underlying NotConnected error rather than misreporting CapabilityNotSupported. The getter is wired (client.ts:351, server.ts:265) as () => this._serverCapabilities \!== undefined / () => this._clientCapabilities \!== undefined. However, Protocol._onclose() clears _transport, response handlers, etc. but does not clear _serverCapabilities/_clientCapabilities (those live on Client/Server and are only ever assigned during initialize — never reset). Neither Client nor Server overrides close(). So after close(), _getPeerCapabilitiesPresent() continues to return true and _assertPeerCapability runs the capability check against stale data from the previous connection.

The specific code path that triggers it

In ExtensionHandle._assertPeerCapability (extensionHandle.ts:158–168):

if (\!this._getPeerCapabilitiesPresent()) return;   // ← still true after close()
if (this._getEnforceStrictCapabilities() && this._getPeerExtensionSettings() === undefined) {
    throw new SdkError(SdkErrorCode.CapabilityNotSupported, ...);
}

After close(), the first guard is bypassed because _serverCapabilities still holds the previous peer's caps. If the previous peer did not advertise the extension, _getPeerExtensionSettings() is undefined and the strict-mode throw fires before the call ever reaches sendCustomRequest → _requestWithSchema, which would otherwise surface NotConnected.

Why existing code doesn't prevent it

For standard MCP methods, Protocol._requestWithSchema checks \!this._transport before assertCapabilityForMethod (protocol.ts ~813–820), so post-close standard requests always reject with NotConnected regardless of stale caps. ExtensionHandle inverts this ordering by running its capability check first. The 9a22a16 short-circuit only restores the standard ordering for the never-connected case; it leaves the post-close case behaving like the pre-fix code.

Impact

In strict mode, calling handle.sendRequest()/sendNotification() after close() (and before any reconnect) when the previous peer did not advertise the extension yields SdkError(CapabilityNotSupported, 'Peer does not support extension ...') instead of NotConnected. This is the exact diagnostic confusion the fix was meant to eliminate, just on the post-close path. It's a narrow edge case (strict mode + previous-peer-didn't-advertise + send-while-disconnected) and only affects error diagnostics, not functional correctness — hence nit severity.

How to fix it

Key the presence check off live transport state, which _onclose() does clear:

// client.ts / server.ts
() => this.transport \!== undefined,

This exactly mirrors the \!this._transport check that _requestWithSchema performs first, so ExtensionHandle's capability gate runs only when (and against the peer for which) a live connection exists. Alternatively, clear _serverCapabilities/_clientCapabilities in an _onclose() override, but that's a wider behavior change.

Step-by-step proof

  1. const client = new Client({name:'c',version:'1'}, {enforceStrictCapabilities:true})
  2. const handle = client.extension('io.x/ext', {})
  3. await client.connect(transportA) — server A responds with capabilities: {} (no extensions key). _serverCapabilities = {}.
  4. await client.close()Protocol._onclose() sets _transport = undefined; _serverCapabilities is still {}.
  5. await handle.sendRequest('x/do', {}, Result):
    • _assertPeerCapability('x/do')_getPeerCapabilitiesPresent() = ({}) \!== undefined = true → guard not taken.
    • _getEnforceStrictCapabilities() = true; _getPeerExtensionSettings() = {}?.extensions?.['io.x/ext'] = undefined.
    • Throws SdkError(CapabilityNotSupported, 'Peer does not support extension "io.x/ext" ...').
  6. Expected (and what standard methods do): rejection with NotConnected, since _transport === undefined.
  7. With the proposed fix () => this.transport \!== undefined, step 5's first guard evaluates to false, _assertPeerCapability returns, and sendCustomRequest → _requestWithSchema rejects with NotConnected ✓.

// State errors
/** Transport is not connected */
NotConnected = 'NOT_CONNECTED',
ExtensionAlreadyRegistered = 'EXTENSION_ALREADY_REGISTERED',
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 ExtensionAlreadyRegistered member is missing a JSDoc comment and is wedged between NotConnected and the /** Transport is already connected */ comment for AlreadyConnected, breaking up an obvious pair. Consider adding a JSDoc line (e.g. /** Extension ID is already registered via Client.extension()/Server.extension() */) and moving it after AlreadyConnected.

Extended reasoning...

What the issue is

The new enum member at line 13 is inserted without a JSDoc comment, while every other member in the // State errors group (NotConnected, AlreadyConnected, NotInitialized) has one:

// State errors
/** Transport is not connected */
NotConnected = 'NOT_CONNECTED',
ExtensionAlreadyRegistered = 'EXTENSION_ALREADY_REGISTERED',
/** Transport is already connected */
AlreadyConnected = 'ALREADY_CONNECTED',
/** Protocol is not initialized */
NotInitialized = 'NOT_INITIALIZED',

The placement also splits the obvious NotConnected/AlreadyConnected pair, which reads oddly when scanning the source.

Why this is only a nit

There is no functional or tooling impact: the /** Transport is already connected */ comment still sits directly above AlreadyConnected, so TypeDoc/IDE hover correctly attributes it. Also, the file is not perfectly uniform — the ClientHttp* members at lines 32–37 also lack JSDoc — so the original claim that every member is documented overstates things. But within the State-errors group the pattern is consistent, and this PR also documents ExtensionAlreadyRegistered in docs/migration.md's SdkErrorCode table only by implication (it's actually not listed there either), so a one-liner here is the only place a reader will find what it means.

Step-by-step proof

  1. Open packages/core/src/errors/sdkErrors.ts at the // State errors group.
  2. Line 11–12: /** Transport is not connected */NotConnected. Documented.
  3. Line 13: ExtensionAlreadyRegistered = 'EXTENSION_ALREADY_REGISTERED', — no preceding /** ... */. Undocumented.
  4. Line 14–15: /** Transport is already connected */AlreadyConnected. Documented, and still correctly attached (comment is immediately above its target).
  5. Line 16–17: /** Protocol is not initialized */NotInitialized. Documented.
  6. Result: 3/4 members in the group documented; the new one is the odd one out, and it sits between two transport-connection-state siblings rather than after them.

How to fix

// State errors
/** Transport is not connected */
NotConnected = 'NOT_CONNECTED',
/** Transport is already connected */
AlreadyConnected = 'ALREADY_CONNECTED',
/** Protocol is not initialized */
NotInitialized = 'NOT_INITIALIZED',
/** Extension ID is already registered via Client.extension()/Server.extension() */
ExtensionAlreadyRegistered = 'EXTENSION_ALREADY_REGISTERED',

(Or keep it in the State-errors block but after AlreadyConnected — either way, add the comment.)

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