Skip to content

feat(core): add custom request/notification handler API to Protocol#1846

Draft
felixweinberger wants to merge 11 commits intomainfrom
fweinberger/custom-method-handlers
Draft

feat(core): add custom request/notification handler API to Protocol#1846
felixweinberger wants to merge 11 commits intomainfrom
fweinberger/custom-method-handlers

Conversation

@felixweinberger
Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger commented Apr 2, 2026

Adds an explicit API on Protocol for registering handlers and sending messages for non-standard / vendor-specific methods, without reintroducing the class-level generic type parameters removed in #1451.

Motivation and Context

#1446 changed setRequestHandler to take string method names constrained to the closed RequestMethod union, and #1451 removed the <SendRequestT, SendNotificationT, SendResultT> generics from Protocol/Server/Client. Together these closed the door on custom protocol extensions: there is no longer a typed way to register a handler for e.g. mcp-ui/initialize or acme/search.

This PR adds a small explicit surface instead:

server.setCustomRequestHandler('acme/search', SearchParamsSchema, (params, ctx) => ({ hits: [...] }));
const result = await client.sendCustomRequest('acme/search', { query: 'x' }, { params: SearchParamsSchema, result: SearchResultSchema });
  • Custom handlers share the existing _requestHandlers/_notificationHandlers maps, so they get the full dispatch path (context, cancellation, tasks, error wrapping) for free
  • A collision guard rejects standard MCP methods (e.g. 'ping', 'tools/call') and points to setRequestHandler instead
  • sendCustomNotification routes through notification() so debouncing and task-queued delivery apply
  • sendCustomRequest/sendCustomNotification accept an optional schema bundle ({params, result}) for typed outbound params with pre-send validation — closes the typing gap vs v1's class-level generics
  • Capability checks are no-ops for custom methods regardless of enforceStrictCapabilities (the assertCapabilityForMethod/assertNotificationCapability switches have no default case)

The primary consumer is ext-apps, which currently extends v1's Protocol<SendRequestT, SendNotificationT, SendResultT> to register ~15 mcp-ui/* methods. The included customMethodExtAppsExample.ts demonstrates that pattern is fully expressible on this API.

How Has This Been Tested?

  • 21 unit tests in packages/core/test/shared/customMethods.test.ts (typed params/results, full ctx, validation errors, collision guard, removal, not-connected, last-wins, prototype-key regression, schema-bundle overloads, debouncing, strict-caps)
  • pnpm build:all, pnpm typecheck:all, pnpm lint:all, core tests 510/510
  • Two runnable examples via npx tsx examples/server/src/customMethod{,ExtApps}Example.ts

Breaking Changes

None. Purely additive to Protocol. Not exposed on McpServer (use mcpServer.server.* per existing guidance).

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

  • Adds isRequestMethod/isNotificationMethod runtime predicates in schemas.ts (using Object.hasOwn)
  • Adds @modelcontextprotocol/client path mapping to examples/server/tsconfig.json
  • Migration guide entries in docs/migration.md and docs/migration-SKILL.md
  • ext-apps migration delta: v1 took whole-request schemas; this API takes (methodString, paramsSchema) separately
  • Also exports InMemoryTransport from @modelcontextprotocol/core/public (needed by the examples; one-line overlap with fix(core): export InMemoryTransport, tighten migration docs #1834)

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 2, 2026

🦋 Changeset detected

Latest commit: b52bc34

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 2, 2026

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/server

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: b52bc34

@felixweinberger felixweinberger force-pushed the fweinberger/custom-method-handlers branch from a2558ec to e4a5c5c Compare April 2, 2026 13:29
@felixweinberger felixweinberger changed the base branch from main to fweinberger/migration-doc-fixes April 2, 2026 13:29
@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

Adds setCustomRequestHandler, setCustomNotificationHandler, sendCustomRequest,
sendCustomNotification (plus remove* variants) to the Protocol class. These
allow registering handlers for vendor-specific methods outside the standard
RequestMethod/NotificationMethod unions, with user-provided Zod schemas for
param/result validation.

Custom handlers share the existing _requestHandlers map and dispatch path,
so they receive full context (cancellation, task support, send/notify) for
free. Capability checks are skipped for custom methods.

Also exports InMemoryTransport from core/public so examples and tests can
use createLinkedPair() without depending on the internal core barrel, and
adds examples/server/src/customMethodExample.ts demonstrating the API.
- Guard setCustom*/removeCustom* against standard MCP method names
  (throws directing users to setRequestHandler/setNotificationHandler)
- Add isRequestMethod/isNotificationMethod runtime predicates
- Add comprehensive unit tests (15 cases) for all 6 custom-method APIs
- Add ext-apps style example demonstrating mcp-ui/* methods and
  DOM-style event listeners built on setCustomNotificationHandler
- Add @modelcontextprotocol/client path mapping to examples/server
  tsconfig so the example resolves source instead of dist
…typed-params overloads; migration docs

- sendCustomNotification now delegates to notification() so debouncing and
  task-queued delivery apply to custom methods
- sendCustomRequest/sendCustomNotification gain a {params, result}/{params}
  schema-bundle overload that validates outbound params before sending
- clarify JSDoc: capability checks are a no-op for custom methods regardless
  of enforceStrictCapabilities
- add migration.md / migration-SKILL.md sections for custom protocol methods
setRequestHandler is overridden in Client/Server, so {@linkcode Protocol.setRequestHandler}
resolves to the undocumented base. Use unqualified {@linkcode setRequestHandler} instead.
assertCapabilityForMethod/assertNotificationCapability are protected — use plain backticks.
@felixweinberger felixweinberger force-pushed the fweinberger/custom-method-handlers branch from 6509f2f to 07c5491 Compare April 9, 2026 13:05
@felixweinberger felixweinberger changed the base branch from fweinberger/migration-doc-fixes to main April 9, 2026 13:05
The {params, result} schema bundle in sendCustomRequest/sendCustomNotification
is a type guard, not a transformer — the caller-provided value is sent as-is,
matching request()/v1 behavior. Transforms/defaults on the params schema are
not applied outbound (parsed.data is intentionally unused on the send path).
Adds JSDoc and a test asserting params are sent verbatim.
@felixweinberger felixweinberger force-pushed the fweinberger/custom-method-handlers branch from 1f5fa16 to 98d7742 Compare April 9, 2026 13:29
felixweinberger added a commit that referenced this pull request Apr 9, 2026
…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.
@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
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/core/src/exports/public/index.ts:133-135 — The "InMemoryTransport removed from public API" section in docs/migration.md (lines ~514-528) actively contradicts this PR: it tells migrating users that InMemoryTransport was removed and that they must import it from the internal @modelcontextprotocol/core package, but this PR re-adds it to the public barrel via packages/core/src/exports/public/index.ts. After this PR merges, users following the migration guide will unnecessarily import from an internal-only package path (which the guide itself marks as "not for production use") when they could simply use @modelcontextprotocol/server or @modelcontextprotocol/client.

    Extended reasoning...

    What the bug is and how it manifests

    docs/migration.md contains a section titled "InMemoryTransport removed from public API" (roughly lines 514–528). It reads: "InMemoryTransport has been removed from the public API surface" and instructs users to import it from the internal @modelcontextprotocol/core package with the caveat "(testing only — @modelcontextprotocol/core is internal, not for production use)". This section was accurate before this PR, but this PR intentionally re-adds InMemoryTransport to the public exports without updating the migration guide.

    The specific code path that triggers it

    This PR adds export { InMemoryTransport } from '../../util/inMemory.js'; to packages/core/src/exports/public/index.ts. Since @modelcontextprotocol/server and @modelcontextprotocol/client both do export * from '@modelcontextprotocol/core/public', InMemoryTransport is now importable from either of the public packages. The PR's own example files confirm this: both customMethodExample.ts and customMethodExtAppsExample.ts use import { InMemoryTransport, Server } from '@modelcontextprotocol/server'. The PR description also explicitly acknowledges the intent: "Also exports InMemoryTransport from @modelcontextprotocol/core/public (needed by the examples; one-line overlap with #1834)".

    Why existing content does not prevent it

    The migration guide was written before InMemoryTransport was re-added. The PR only adds a new "Custom (non-standard) protocol methods" section to migration.md; the pre-existing "InMemoryTransport removed from public API" section was left unchanged. There is no mechanism in the PR review flow to automatically detect that a code change contradicts prose in documentation.

    What the impact would be

    Any developer migrating from v1 who reads migration.md will follow the incorrect guidance and write import { InMemoryTransport } from '@modelcontextprotocol/core', importing from a package the guide itself labels as internal and not for production use. The correct, public import — import { InMemoryTransport } from '@modelcontextprotocol/server' (or @modelcontextprotocol/client) — goes undiscovered until they stumble on the PR examples or source code.

    How to fix it

    The "InMemoryTransport removed from public API" section in migration.md should be updated (or removed) to reflect that InMemoryTransport is now part of the public API again. The corrected guidance should direct users to import from @modelcontextprotocol/server or @modelcontextprotocol/client, matching the pattern used in the PR's own examples.

    Step-by-step proof

    1. User reads migration.md to migrate a v1 codebase that used InMemoryTransport for in-process testing.
    2. User finds the "InMemoryTransport removed from public API" section and follows its v2 guidance.
    3. User writes: import { InMemoryTransport } from '@modelcontextprotocol/core';
    4. The guide notes this import is "testing only — @modelcontextprotocol/core is internal, not for production use".
    5. After this PR merges, the correct import is: import { InMemoryTransport } from '@modelcontextprotocol/server'; (a public, stable package).
    6. The user has been actively misled into using the internal package path that the guide itself discourages.

Comment on lines +167 to +172
const result = await client.sendCustomRequest('acme/noargs', undefined, z.object({ ok: z.boolean() }));
expect(result.ok).toBe(true);
});

test('result validated against resultSchema', async () => {
const [client, server] = await linkedPair();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 sendCustomRequest throws a plain Error('Not connected') when not connected, while sendCustomNotification correctly throws SdkError(SdkErrorCode.NotConnected) — making the two new symmetric-looking methods behave asymmetrically on a common error path. The test at line 169 uses .rejects.toThrow(/Not connected/) which matches any error type, masking the inconsistency; the sendCustomNotification test correctly uses .rejects.toSatisfy(e => e instanceof SdkError && e.code === SdkErrorCode.NotConnected). The migration guide explicitly documents not-connected as throwing SdkError with SdkErrorCode.NotConnected, so users following the guide and writing catch(e) { if (e instanceof SdkError && e.code === SdkErrorCode.NotConnected) } will silently miss sendCustomRequest errors. The fix is to change _requestWithSchema (protocol.ts line 814) to earlyReject(new SdkError(SdkErrorCode.NotConnected, 'Not connected')), or add a guard in sendCustomRequest, and tighten the test assertion to match sendCustomNotification's.

Extended reasoning...

What the bug is and how it manifests

This PR introduces two symmetric-looking methods, sendCustomRequest and sendCustomNotification, as the new API surface for custom protocol methods. However, these two methods behave differently on a common error path: when the transport is not connected.

  • sendCustomRequest → _requestWithSchema (line 814): earlyReject(new Error('Not connected')) — a plain Error
  • sendCustomNotification → notification() (line 952): throw new SdkError(SdkErrorCode.NotConnected, 'Not connected') — an SdkError

The test at line 169 uses .rejects.toThrow(/Not connected/) which matches any error whose message contains that string, regardless of whether it is a plain Error or SdkError. This masks the inconsistency at testing time.

The specific code path that triggers it

  1. User calls sendCustomRequest('acme/x', {}, schema) on a disconnected Protocol instance
  2. sendCustomRequest calls _requestWithSchema, which checks if (!this._transport) and calls earlyReject(new Error('Not connected'))
  3. The rejected promise contains a plain Error object (not SdkError)
  4. By contrast, sendCustomNotification calls notification(), which checks if (!this._transport) and throws new SdkError(SdkErrorCode.NotConnected, 'Not connected')
  5. The test passes because .rejects.toThrow(/Not connected/) matches both plain Error and SdkError

Why existing code does not prevent it

The test uses .rejects.toThrow(/Not connected/) — a weak regex-based assertion that only checks the error message string. The sendCustomNotification test uses .rejects.toSatisfy((e) => e instanceof SdkError && e.code === SdkErrorCode.NotConnected), which is the correct, strict assertion. The asymmetry in test quality means the sendCustomRequest type mismatch goes undetected.

What the impact would be

The migration guide (docs/migration.md) includes an error hierarchy table that explicitly documents the not-connected scenario as: v2 type = SdkError with SdkErrorCode.NotConnected. Users who read this guide and write unified error handling for both new methods will silently miss errors from sendCustomRequest:

This is not a theoretical concern: the PR itself documents sendCustomRequest and sendCustomNotification side-by-side as equivalent APIs, and the migration table explicitly says NotConnected → SdkError with SdkErrorCode.NotConnected.

How to fix it

Option A (preferred): Change _requestWithSchema line 814 to:

This makes all request-path not-connected errors consistent (custom and standard).

Option B: Add a guard in sendCustomRequest specifically:

before calling _requestWithSchema.

Also tighten the test at line 169 to match sendCustomNotification's assertion style:

Step-by-step proof

  1. Create a TestProtocol instance without connecting it: const proto = new TestProtocol()
  2. Call proto.sendCustomRequest('acme/x', {}, z.object({})) — routes to _requestWithSchema
  3. _requestWithSchema checks if (!this._transport) → true → earlyReject(new Error('Not connected'))
  4. Catch the error: error instanceof SdkError → false (it is a plain Error)
  5. error instanceof SdkError && error.code === SdkErrorCode.NotConnected → false
  6. Meanwhile, proto.sendCustomNotification('acme/x', {}) → notification() → throws SdkError(SdkErrorCode.NotConnected)
  7. e instanceof SdkError && e.code === SdkErrorCode.NotConnected → true
  8. Both methods appear equivalent in the docs; only one actually behaves as documented.

felixweinberger added a commit that referenced this pull request Apr 9, 2026
…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.
… example notification to request stream; add changeset

- setCustomRequestHandler/setCustomNotificationHandler now strip _meta from
  params before validating against the user schema, so .strict() schemas do
  not reject SDK-injected fields like progressToken. _meta remains available
  via ctx.mcpReq._meta. Adds regression test.
- examples/server/src/customMethodExample.ts: pass relatedRequestId so the
  acme/statusUpdate notification routes to the request response stream as
  the comment claims (was going to the standalone SSE stream).
- Add .changeset/custom-method-handlers.md (minor bump for client+server).
felixweinberger added a commit that referenced this pull request Apr 9, 2026
…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.
@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

1 similar comment
@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

Comment on lines +1080 to +1082
const { _meta, ...userParams } = (request.params ?? {}) as Record<string, unknown>;
void _meta;
const parsed = parseSchema(paramsSchema, userParams);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 In both setCustomRequestHandler and setCustomNotificationHandler, missing params are normalized to {} via (request.params ?? {}) before schema validation, causing z.undefined() to always fail with InvalidParamsparseSchema(z.undefined(), {}) rejects because {} is not undefined. The test at line 179 works around this by using z.undefined().or(z.object({})) rather than z.undefined() alone, but the test name "undefined params accepted" is misleading. Either document that undefined params are normalized to {} (so z.object({}) is the correct no-args schema), or special-case undefined params to skip the destructuring.

Extended reasoning...

What the bug is and how it manifests

In setCustomRequestHandler (protocol.ts lines 1080–1082) and setCustomNotificationHandler (lines ~1115–1117), the code does:

const { _meta, ...userParams } = (request.params ?? {}) as Record<string, unknown>;
const parsed = parseSchema(paramsSchema, userParams);

When the sender provides no params (request.params is undefined), the ?? {} coalesces it to {}. After destructuring _meta, userParams becomes {} — not undefined. Calling parseSchema(z.undefined(), {}) then fails because {} does not satisfy z.undefined(), resulting in a ProtocolError(InvalidParams) thrown before the user handler is ever invoked.

The specific code path that triggers it

  1. Client calls sendCustomRequest("acme/noargs", undefined, ResultSchema).
  2. Wire message: { method: "acme/noargs" } — no params field.
  3. Server closure evaluates (request.params ?? {}){}.
  4. Destructuring: userParams = {}.
  5. parseSchema(z.undefined(), {}){ success: false }.
  6. Handler throws ProtocolError(InvalidParams) — user handler never runs.

Why existing code does not prevent it

The ?? {} normalization is necessary to safely destructure _meta from potentially-undefined params. However, its side effect of converting undefined to {} breaks z.undefined() as a params schema. No test exercises z.undefined() alone: the test at line 179 uses z.undefined().or(z.object({})) as an explicit workaround, and the debouncing test at line ~254 does the same. Both test names imply z.undefined() is an accepted schema, but neither validates it in isolation. The normalization is also undocumented in the JSDoc for either method.

Impact

Any user who writes setCustomRequestHandler("acme/noargs", z.undefined(), handler) gets a handler that always returns InvalidParams — even for requests explicitly sent with no params. The failure is not silent (an error is returned), but the error message describes the schema mismatch rather than the normalization root cause, making diagnosis non-obvious. The misleading test name "undefined params accepted" actively suggests this pattern should work.

Practical severity is low: z.object({}) is the correct JSON-RPC idiom for no-args handlers (MCP params are always objects), and the workaround z.undefined().or(z.object({})) is demonstrated in the test. But the API contract (accepts AnySchema) implies z.undefined() should work, and the test name reinforces that expectation.

How to fix it

Option A — special-case undefined: Before destructuring, check for null/undefined and pass it through as-is so z.undefined() can match:

const userParams = request.params == null
  ? request.params
  : (({ _meta, ...rest }) => rest)(request.params as Record<string, unknown>);
const parsed = parseSchema(paramsSchema, userParams);

Option B — document the normalization: Add a JSDoc note explaining that absent params are normalized to {} before schema validation, so z.object({}) (not z.undefined()) is the correct no-args schema. Rename the misleading test to reflect the workaround, or switch it to use z.object({}) directly.

Step-by-step proof

  1. server.setCustomRequestHandler("acme/noargs", z.undefined(), handler) — registers with z.undefined() paramsSchema.
  2. Client calls client.sendCustomRequest("acme/noargs", undefined, z.object({ ok: z.boolean() })).
  3. Wire message arrives: request.params is undefined.
  4. Handler closure: (undefined ?? {}){}; destructuring gives userParams = {}.
  5. z.undefined().safeParse({}){ success: false }.
  6. throw new ProtocolError(InvalidParams, ...) — handler is never invoked.
  7. Client receives InvalidParams for a request explicitly sent with no params.

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