-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(core): add custom request/notification handler API to Protocol #1846
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
felixweinberger
wants to merge
13
commits into
main
Choose a base branch
from
fweinberger/custom-method-handlers
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
141b69b
feat(core): add custom request/notification handler API to Protocol
felixweinberger ab148e5
feat(core): harden custom method handlers for production
felixweinberger a12b19f
fix: use Object.hasOwn in isRequestMethod/isNotificationMethod to avo…
felixweinberger 96bbd2f
feat(core): route sendCustomNotification through notification(); add …
felixweinberger fc5d10c
docs: fix typedoc link warnings in custom method handler JSDoc
felixweinberger 4a2bf03
fix(core): exclude StandardSchema values from isSchemaBundle discrimi…
felixweinberger 07c5491
docs: minimize migration doc diff to custom-methods sections only
felixweinberger 98d7742
docs(core): clarify schema-bundle overload validates only; add test
felixweinberger 89db8c6
refactor(examples): split customMethod example into server/client pai…
felixweinberger 3575410
fix(core): strip _meta before custom-handler schema validation; route…
felixweinberger b52bc34
fix(core): use SdkError(NotConnected) in request() to match notificat…
felixweinberger cdaca38
docs(core): note params normalization to {} in setCustom* handlers
felixweinberger e4cb1a9
feat(core): accept StandardSchemaV1 in setCustom*/sendCustom* (not ju…
felixweinberger File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| '@modelcontextprotocol/client': minor | ||
| '@modelcontextprotocol/server': minor | ||
| --- | ||
|
|
||
| Add `setCustomRequestHandler` / `setCustomNotificationHandler` / `sendCustomRequest` / `sendCustomNotification` (plus `remove*` variants) on `Protocol` for non-standard JSON-RPC methods. Restores typed registration for vendor-specific methods (e.g. `mcp-ui/*`) that #1446/#1451 closed off, without reintroducing class-level generics. Handlers share the standard dispatch path (context, cancellation, tasks); a collision guard rejects standard MCP methods. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| // Run with: pnpm tsx src/customMethodExample.ts | ||
| // | ||
| // Demonstrates sending custom (non-standard) requests and receiving custom | ||
| // notifications from the server. | ||
| // | ||
| // The Protocol class exposes sendCustomRequest / setCustomNotificationHandler for | ||
| // vendor-specific methods that are not part of the MCP spec. The schema-bundle | ||
| // overload of sendCustomRequest gives typed params with pre-send validation. | ||
| // | ||
| // Pair with: examples/server/src/customMethodExample.ts (start the server first). | ||
|
|
||
| import { Client, ProtocolError, ProtocolErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; | ||
| import { z } from 'zod'; | ||
|
|
||
| const SearchParamsSchema = z.object({ | ||
| query: z.string(), | ||
| limit: z.number().int().positive().optional() | ||
| }); | ||
|
|
||
| const SearchResultSchema = z.object({ | ||
| results: z.array(z.object({ id: z.string(), title: z.string() })), | ||
| total: z.number() | ||
| }); | ||
|
|
||
| const AnalyticsResultSchema = z.object({ recorded: z.boolean() }); | ||
|
|
||
| const StatusUpdateParamsSchema = z.object({ | ||
| status: z.enum(['idle', 'busy', 'error']), | ||
| detail: z.string().optional() | ||
| }); | ||
|
|
||
| const serverUrl = process.argv[2] ?? 'http://localhost:3000/mcp'; | ||
|
|
||
| async function main(): Promise<void> { | ||
| const client = new Client({ name: 'custom-method-client', version: '1.0.0' }); | ||
|
|
||
| // Register handler for custom server→client notifications before connecting. | ||
| client.setCustomNotificationHandler('acme/statusUpdate', StatusUpdateParamsSchema, params => { | ||
| console.log(`[client] acme/statusUpdate status=${params.status} detail=${params.detail ?? '<none>'}`); | ||
| }); | ||
|
|
||
| const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); | ||
| await client.connect(transport); | ||
| console.log(`[client] connected to ${serverUrl}`); | ||
|
|
||
| // Schema-bundle overload: typed params + pre-send validation, typed result. | ||
| const searchResult = await client.sendCustomRequest( | ||
| 'acme/search', | ||
| { query: 'widgets', limit: 5 }, | ||
| { params: SearchParamsSchema, result: SearchResultSchema } | ||
| ); | ||
| console.log(`[client] acme/search → ${searchResult.total} results, first: "${searchResult.results[0]?.title}"`); | ||
|
|
||
| // Loose overload: bare result schema, untyped params. | ||
| const analyticsResult = await client.sendCustomRequest('acme/analytics', { event: 'page_view' }, AnalyticsResultSchema); | ||
| console.log(`[client] acme/analytics → recorded=${analyticsResult.recorded}`); | ||
|
|
||
| // Pre-send validation: schema-bundle overload rejects bad params before the round-trip. | ||
| try { | ||
| await client.sendCustomRequest( | ||
| 'acme/search', | ||
| { query: 'widgets', limit: 'five' } as unknown as z.output<typeof SearchParamsSchema>, | ||
| { params: SearchParamsSchema, result: SearchResultSchema } | ||
| ); | ||
| console.error('[client] expected validation error but request succeeded'); | ||
| } catch (error) { | ||
| const code = error instanceof ProtocolError && error.code === ProtocolErrorCode.InvalidParams ? 'InvalidParams' : 'unknown'; | ||
| console.log(`[client] pre-send validation error (expected, ${code}): ${(error as Error).message}`); | ||
| } | ||
|
|
||
| await transport.close(); | ||
| } | ||
|
|
||
| try { | ||
| await main(); | ||
| } catch (error) { | ||
| console.error('[client] error:', error); | ||
| // eslint-disable-next-line unicorn/no-process-exit | ||
| process.exit(1); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| // Run with: pnpm tsx src/customMethodExample.ts | ||
| // | ||
| // Demonstrates registering handlers for custom (non-standard) request methods | ||
| // and sending custom notifications back to the client. | ||
| // | ||
| // The Protocol class exposes setCustomRequestHandler / sendCustomNotification for | ||
| // vendor-specific methods that are not part of the MCP spec. Params are validated | ||
| // against user-provided Zod schemas, and handlers receive the same context | ||
| // (cancellation, bidirectional send/notify) as standard handlers. | ||
| // | ||
| // Pair with: examples/client/src/customMethodExample.ts | ||
|
|
||
| import { randomUUID } from 'node:crypto'; | ||
|
|
||
| import { createMcpExpressApp } from '@modelcontextprotocol/express'; | ||
| import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; | ||
| import { isInitializeRequest, Server } from '@modelcontextprotocol/server'; | ||
| import type { Request, Response } from 'express'; | ||
| import { z } from 'zod'; | ||
|
Check warning on line 19 in examples/server/src/customMethodExample.ts
|
||
|
|
||
| const SearchParamsSchema = z.object({ | ||
| query: z.string(), | ||
| limit: z.number().int().positive().optional() | ||
| }); | ||
|
|
||
| const AnalyticsParamsSchema = z.object({ | ||
| event: z.string(), | ||
| properties: z.record(z.string(), z.unknown()).optional() | ||
| }); | ||
|
|
||
| const getServer = () => { | ||
| const server = new Server({ name: 'custom-method-server', version: '1.0.0' }, { capabilities: {} }); | ||
|
|
||
| server.setCustomRequestHandler('acme/search', SearchParamsSchema, async (params, ctx) => { | ||
| console.log(`[server] acme/search query="${params.query}" limit=${params.limit ?? 'unset'} (req ${ctx.mcpReq.id})`); | ||
|
|
||
| // Send a custom server→client notification on the same SSE stream as this response | ||
| // (relatedRequestId routes it to the request's stream rather than the standalone SSE stream). | ||
| await server.sendCustomNotification( | ||
| 'acme/statusUpdate', | ||
| { status: 'busy', detail: `searching "${params.query}"` }, | ||
| { relatedRequestId: ctx.mcpReq.id } | ||
| ); | ||
|
|
||
| return { | ||
| results: [ | ||
| { id: 'r1', title: `Result for "${params.query}"` }, | ||
| { id: 'r2', title: 'Another result' } | ||
| ], | ||
| total: 2 | ||
| }; | ||
| }); | ||
|
|
||
| server.setCustomRequestHandler('acme/analytics', AnalyticsParamsSchema, async params => { | ||
| console.log(`[server] acme/analytics event="${params.event}"`); | ||
| return { recorded: true }; | ||
| }); | ||
|
|
||
| return server; | ||
| }; | ||
|
|
||
| const PORT = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000; | ||
| const app = createMcpExpressApp(); | ||
| const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; | ||
|
|
||
| app.post('/mcp', async (req: Request, res: Response) => { | ||
| const sessionId = req.headers['mcp-session-id'] as string | undefined; | ||
| try { | ||
| let transport: NodeStreamableHTTPServerTransport; | ||
| if (sessionId && transports[sessionId]) { | ||
| transport = transports[sessionId]; | ||
| } else if (!sessionId && isInitializeRequest(req.body)) { | ||
| transport = new NodeStreamableHTTPServerTransport({ | ||
| sessionIdGenerator: () => randomUUID(), | ||
| onsessioninitialized: sid => { | ||
| transports[sid] = transport; | ||
| } | ||
| }); | ||
| transport.onclose = () => { | ||
| const sid = transport.sessionId; | ||
| if (sid) delete transports[sid]; | ||
| }; | ||
| const server = getServer(); | ||
| await server.connect(transport); | ||
| } else { | ||
| res.status(400).json({ jsonrpc: '2.0', error: { code: -32_000, message: 'No valid session ID' }, id: null }); | ||
| return; | ||
| } | ||
| await transport.handleRequest(req, res, req.body); | ||
| } catch (error) { | ||
| console.error('Error handling MCP request:', error); | ||
| if (!res.headersSent) { | ||
| res.status(500).json({ jsonrpc: '2.0', error: { code: -32_603, message: 'Internal server error' }, id: null }); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| const handleSessionRequest = async (req: Request, res: Response) => { | ||
| const sessionId = req.headers['mcp-session-id'] as string | undefined; | ||
| if (!sessionId || !transports[sessionId]) { | ||
| res.status(400).send('Invalid or missing session ID'); | ||
| return; | ||
| } | ||
| await transports[sessionId].handleRequest(req, res); | ||
| }; | ||
|
|
||
| app.get('/mcp', handleSessionRequest); | ||
| app.delete('/mcp', handleSessionRequest); | ||
|
|
||
| app.listen(PORT, error => { | ||
| if (error) { | ||
| console.error('Failed to start server:', error); | ||
| // eslint-disable-next-line unicorn/no-process-exit | ||
| process.exit(1); | ||
| } | ||
| console.log(`Custom-method example server listening on http://localhost:${PORT}/mcp`); | ||
| console.log('Custom methods: acme/search, acme/analytics'); | ||
| }); | ||
|
|
||
| process.on('SIGINT', async () => { | ||
| for (const sid in transports) await transports[sid]!.close(); | ||
| process.exit(0); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 nit: these two new example files use
import { z } from 'zod'while every other example in the repo (and the SDK internals) usesimport * as z from 'zod/v4'. Consider switching toimport * as z from 'zod/v4'here and inexamples/client/src/customMethodExample.ts:13for consistency with the codebase convention.Extended reasoning...
What the issue is
The two new example files introduced in this PR —
examples/server/src/customMethodExample.ts:19andexamples/client/src/customMethodExample.ts:13— import zod viaimport { z } from 'zod'. Every other example file in the repository (9 files total:simpleStreamableHttp.ts,mcpServerOutputSchema.ts, etc.) and the SDK internals useimport * as z from 'zod/v4'. CLAUDE.md §Zod Schemas explicitly states "The SDK uses zod/v4 internally", anddocs/migration.mdshowsimport * as z from 'zod/v4'in its v2 examples.Why it matters (mildly)
The bare
'zod'specifier and the'zod/v4'subpath are not guaranteed to resolve identically. Zod 4.x ships both entry points and they currently re-export the same module, so this works fine at runtime in this repo. However, in a downstream project that has both zod v3 and v4 installed (e.g. via transitive deps), the bare'zod'specifier may resolve to v3 while'zod/v4'pins to v4. Since these are example files that users copy-paste, propagating an inconsistent import style undermines the convention the rest of the codebase establishes.Why this isn't a functional bug
This is purely a consistency/style issue in example code. The repo's own
zoddependency is v4, so both import forms resolve to the same module here, and the examples build and run correctly. No SDK behavior is affected.How to fix
Change line 19 of
examples/server/src/customMethodExample.tsand line 13 ofexamples/client/src/customMethodExample.tsfrom:to:
Step-by-step proof
grep -rn "from 'zod" examples/shows 11 zod imports across the examples directory.import * as z from 'zod/v4'.import { z } from 'zod'.grep -rn "from 'zod'" packages/shows zero matches in SDK source — internals exclusively use'zod/v4'(e.g.packages/core/src/types/schemas.ts:1,packages/core/src/util/schema.ts:6).