Skip to content

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

Draft
felixweinberger wants to merge 8 commits intofweinberger/migration-doc-fixesfrom
fweinberger/custom-method-handlers
Draft

feat(core): add custom request/notification handler API to Protocol#1846
felixweinberger wants to merge 8 commits intofweinberger/migration-doc-fixesfrom
fweinberger/custom-method-handlers

Conversation

@felixweinberger
Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger commented Apr 2, 2026

Stacked on #1834 (InMemoryTransport export + migration doc fixes).

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 509/509
  • 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

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 2, 2026

⚠️ No Changeset found

Latest commit: 6509f2f

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
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: 6509f2f

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
@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
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
Copy link
Copy Markdown
Contributor Author

@claude review

Comment on lines +1159 to +1185
async sendCustomRequest(
method: string,
params: Record<string, unknown> | undefined,
schemaOrBundle: AnySchema | { params: AnySchema; result: AnySchema },
options?: RequestOptions
): Promise<unknown> {
let resultSchema: AnySchema;
if (isSchemaBundle(schemaOrBundle)) {
const parsed = parseSchema(schemaOrBundle.params, params);
if (!parsed.success) {
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`);
}
resultSchema = schemaOrBundle.result;
} else {
resultSchema = schemaOrBundle;
}
return this._requestWithSchema({ method, params } as Request, resultSchema, options);
}

/**
* Sends a custom (non-standard) notification.
*
* Unlike {@linkcode Protocol.notification | notification}, this accepts any method string. It
* routes through {@linkcode Protocol.notification | notification}, so debouncing and task-queued
* delivery apply. Capability checks are a no-op for custom methods since
* `assertNotificationCapability` only covers
* standard MCP notifications.
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 sendCustomRequest and sendCustomNotification, the schema-bundle overload validates params via parseSchema(...) but then discards parsed.data and sends the original untransformed params over the wire. Any Zod transforms (e.g. .trim(), .toLowerCase()) or defaults (e.g. .default(1)) applied by the schema are silently dropped. Fix: replace params with parsed.data in the _requestWithSchema call and the this.notification(...) call after successful schema-bundle validation.

Extended reasoning...

What the bug is and how it manifests

In sendCustomRequest (around line 1175 of protocol.ts) and sendCustomNotification (around line 1213), when the schema-bundle overload is used, parseSchema(schemaOrBundle.params, params) is called to validate. This invokes z.safeParse internally, which applies all Zod transforms and defaults and returns the transformed value in parsed.data. However, after the !parsed.success guard, parsed.data is immediately discarded. The original params variable containing the untransformed caller-supplied value is what actually gets sent to _requestWithSchema / this.notification.

The specific code path that triggers it

In sendCustomRequest:

if (isSchemaBundle(schemaOrBundle)) {
    const parsed = parseSchema(schemaOrBundle.params, params);
    if (!parsed.success) { throw ... }
    resultSchema = schemaOrBundle.result;
    // parsed.data is never used
}
// BUG: sends original params, not parsed.data
return this._requestWithSchema({ method, params } as Request, resultSchema, options);

In sendCustomNotification:

if (schemasOrOptions && 'params' in schemasOrOptions) {
    const parsed = parseSchema(schemasOrOptions.params, params);
    if (!parsed.success) { throw ... }
    // parsed.data is never used
    options = maybeOptions;
}
// BUG: sends original params, not parsed.data
return this.notification({ method, params } as Notification, options);

Why existing code does not prevent it

All tests in customMethods.test.ts use schemas like z.object({ query: z.string(), limit: z.number().optional() }) — pure validators with no transforms or defaults. For such schemas parsed.data is structurally identical to params, so the bug is invisible. The schema-bundle test at line ~196 passes { query: 'q' } against a schema with no transforms, so parsed.data === params and the test passes.

What the impact would be

Any user who uses the schema-bundle overload with a schema containing transforms or defaults will silently get incorrect wire data:

  • z.string().trim() — caller passes ' hello ', wire sends ' hello ' (untrimmed)
  • z.number().default(1) — caller omits page, wire sends undefined (not 1)
  • z.string().toLowerCase() — wire sends original casing
  • z.coerce.number() — wire sends the uncoerced string

This also creates an asymmetry: inbound handlers (setCustomRequestHandler, lines ~1081-1084) correctly use parsed.data, while the outbound senders do not. Sender and receiver operate on inconsistent data.

How to fix it

Two one-line changes:

// sendCustomRequest: use parsed.data instead of params
return this._requestWithSchema({ method, params: parsed.data } as Request, resultSchema, options);

// sendCustomNotification: use parsed.data instead of params
return this.notification({ method, params: parsed.data } as Notification, options);

The parsed variable must remain in scope at the call site (either hoist it or move the call inside the if block).

Step-by-step proof

  1. Caller defines const Schema = z.object({ query: z.string().trim(), page: z.number().default(1) }).
  2. Caller calls client.sendCustomRequest('acme/search', { query: ' hello ' }, { params: Schema, result: ResultSchema }).
  3. isSchemaBundle returns true; parseSchema(Schema, { query: ' hello ' }) runs z.safeParse.
  4. parsed.success === true; parsed.data is { query: 'hello', page: 1 } (trimmed + default applied).
  5. parsed.data is unused; code reaches _requestWithSchema({ method, params: { query: ' hello ' } }, ...).
  6. Message sent over the wire: { query: ' hello ' } — no page, untrimmed string.
  7. Server handler (which also parses with Schema) receives { query: 'hello', page: 1 } — inconsistent with what was sent.

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.

2 participants