Skip to content

Commit 9da988e

Browse files
feat(core): 3-arg setRequestHandler/setNotificationHandler with validator-agnostic paramsSchema
Adds the three-arg form: setRequestHandler('vendor/method', paramsSchema, (params, ctx) => …), where paramsSchema is any Standard Schema (Zod, Valibot, ArkType, etc.). Handler receives validated params; absent params normalize to {} (after stripping _meta). Same for setNotificationHandler. Ergonomic alternative to writing a full Zod request schema with method literal.
1 parent 1c01219 commit 9da988e

File tree

11 files changed

+267
-59
lines changed

11 files changed

+267
-59
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@modelcontextprotocol/client': minor
3+
'@modelcontextprotocol/server': minor
4+
---
5+
6+
`setRequestHandler`/`setNotificationHandler` gain a 3-arg `(method: string, paramsSchema, handler)` form for custom (non-spec) methods. `paramsSchema` is any Standard Schema (Zod, Valibot, ArkType, etc.); the handler receives validated `params`.

docs/migration-SKILL.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -377,14 +377,16 @@ Schema to method string mapping:
377377

378378
Request/notification params remain fully typed. Remove unused schema imports after migration.
379379

380-
**Custom (non-standard) methods** — vendor extensions or sub-protocols whose method strings are not in the MCP spec — work on `Client`/`Server` directly using the same v1 Zod-schema form:
381-
382-
| Form | Notes |
383-
| ------------------------------------------------------------ | --------------------------------------------------------------------- |
384-
| `setRequestHandler(CustomReqSchema, (req, ctx) => ...)` | unchanged |
385-
| `setNotificationHandler(CustomNotifSchema, n => ...)` | unchanged |
386-
| `this.request({ method: 'vendor/x', params }, ResultSchema)` | unchanged |
387-
| `this.notification({ method: 'vendor/x', params })` | unchanged |
380+
**Custom (non-standard) methods** — vendor extensions or sub-protocols whose method strings are not in the MCP spec — work on `Client`/`Server` directly. The v1 Zod-schema forms continue to work; the three-arg `(method, paramsSchema, handler)` form is the alternative:
381+
382+
| v1 (still supported) | v2 alternative |
383+
| ------------------------------------------------------------ | ------------------------------------------------------------------------ |
384+
| `setRequestHandler(CustomReqSchema, (req, ctx) => ...)` | `setRequestHandler('vendor/method', ParamsSchema, (params, ctx) => ...)` |
385+
| `setNotificationHandler(CustomNotifSchema, n => ...)` | `setNotificationHandler('vendor/method', ParamsSchema, params => ...)` |
386+
| `this.request({ method: 'vendor/x', params }, ResultSchema)` | unchanged |
387+
| `this.notification({ method: 'vendor/x', params })` | unchanged |
388+
389+
For the three-arg form, the v1 schema's `.shape.params` becomes the `ParamsSchema` argument and the `method: z.literal('...')` value becomes the string argument.
388390

389391
## 10. Request Handler Context Types
390392

docs/migration.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,15 +384,19 @@ Common method string replacements:
384384

385385
### Custom (non-standard) protocol methods
386386

387-
Vendor-specific methods are registered directly on `Client` or `Server` using the same Zod-schema form as v1: `setRequestHandler(zodSchemaWithMethodLiteral, handler)`. `request({ method, params }, ResultSchema)` and `notification({ method, params })` are unchanged from v1.
387+
Vendor-specific methods are registered directly on `Client` or `Server`. The v1 form `setRequestHandler(zodSchemaWithMethodLiteral, handler)` continues to work; the three-arg `(methodString, paramsSchema, handler)` form is the v2 alternative. `request({ method, params }, ResultSchema)` and `notification({ method, params })` are unchanged from v1.
388388

389389
```typescript
390390
import { Server } from '@modelcontextprotocol/server';
391391

392392
const server = new Server({ name: 'app', version: '1.0.0' }, { capabilities: {} });
393393

394+
// v1 form (still supported):
394395
server.setRequestHandler(SearchRequestSchema, req => ({ hits: [req.params.query] }));
395396

397+
// v2 alternative — pass method string + params schema; handler receives validated params:
398+
server.setRequestHandler('acme/search', SearchParams, params => ({ hits: [params.query] }));
399+
396400
// Calling from a Client — unchanged from v1:
397401
const result = await client.request({ method: 'acme/search', params: { query: 'x' } }, SearchResult);
398402
```

examples/client/src/customMethodExample.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
* Calling vendor-specific (non-spec) JSON-RPC methods from a `Client`.
44
*
55
* - Send a custom request: `client.request({ method, params }, resultSchema)`
6-
* - Send a custom notification: `client.notification({ method, params })`
7-
* - Receive a custom notification: `client.setNotificationHandler(ZodSchemaWithMethodLiteral, handler)`
6+
* - Send a custom notification: `client.notification({ method, params })` (unchanged from v1)
7+
* - Receive a custom notification: 3-arg `client.setNotificationHandler(method, paramsSchema, handler)`
8+
*
9+
* These overloads are on `Client` and `Server` directly — you do NOT need a raw
10+
* `Protocol` instance for custom methods.
811
*
912
* Pair with the server in examples/server/src/customMethodExample.ts.
1013
*/
@@ -13,16 +16,12 @@ import { Client, StdioClientTransport } from '@modelcontextprotocol/client';
1316
import { z } from 'zod';
1417

1518
const SearchResult = z.object({ hits: z.array(z.string()) });
16-
17-
const ProgressNotification = z.object({
18-
method: z.literal('acme/searchProgress'),
19-
params: z.object({ stage: z.string(), pct: z.number() })
20-
});
19+
const ProgressParams = z.object({ stage: z.string(), pct: z.number() });
2120

2221
const client = new Client({ name: 'custom-method-client', version: '1.0.0' }, { capabilities: {} });
2322

24-
client.setNotificationHandler(ProgressNotification, n => {
25-
console.log(`[client] progress: ${n.params.stage} ${n.params.pct}%`);
23+
client.setNotificationHandler('acme/searchProgress', ProgressParams, p => {
24+
console.log(`[client] progress: ${p.stage} ${p.pct}%`);
2625
});
2726

2827
await client.connect(new StdioClientTransport({ command: 'node', args: ['../server/dist/customMethodExample.js'] }));

examples/server/src/customMethodExample.ts

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
/**
33
* Registering vendor-specific (non-spec) JSON-RPC methods on a `Server`.
44
*
5-
* Custom methods use the Zod-schema form of `setRequestHandler` / `setNotificationHandler`:
6-
* pass a Zod object schema whose `method` field is `z.literal('<method>')`. The same overload
7-
* is available on `Client` (for server→client custom methods).
5+
* Custom methods use the 3-arg form of `setRequestHandler` / `setNotificationHandler`:
6+
* pass the method string, a params schema, and the handler. The same overload is
7+
* available on `Client` (for server→client custom methods) — you do NOT need a raw
8+
* `Protocol` instance for this.
89
*
910
* To call these from the client side, use:
1011
* await client.request({ method: 'acme/search', params: { query: 'widgets' } }, SearchResult)
@@ -15,28 +16,21 @@
1516
import { Server, StdioServerTransport } from '@modelcontextprotocol/server';
1617
import { z } from 'zod';
1718

18-
const SearchRequest = z.object({
19-
method: z.literal('acme/search'),
20-
params: z.object({ query: z.string() })
21-
});
22-
23-
const TickNotification = z.object({
24-
method: z.literal('acme/tick'),
25-
params: z.object({ n: z.number() })
26-
});
19+
const SearchParams = z.object({ query: z.string() });
20+
const TickParams = z.object({ n: z.number() });
2721

2822
const server = new Server({ name: 'custom-method-server', version: '1.0.0' }, { capabilities: {} });
2923

30-
server.setRequestHandler(SearchRequest, async (request, ctx) => {
31-
console.log('[server] acme/search query=' + request.params.query);
24+
server.setRequestHandler('acme/search', SearchParams, async (params, ctx) => {
25+
console.log('[server] acme/search query=' + params.query);
3226
await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } });
33-
const hits = [request.params.query, request.params.query + '-result'];
27+
const hits = [params.query, params.query + '-result'];
3428
await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'done', pct: 100 } });
3529
return { hits };
3630
});
3731

38-
server.setNotificationHandler(TickNotification, n => {
39-
console.log('[server] acme/tick n=' + n.params.n);
32+
server.setNotificationHandler('acme/tick', TickParams, p => {
33+
console.log('[server] acme/tick n=' + p.n);
4034
});
4135

4236
await server.connect(new StdioServerTransport());

packages/client/src/client/client.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type {
3232
Result,
3333
ResultTypeMap,
3434
ServerCapabilities,
35+
StandardSchemaV1,
3536
SubscribeRequest,
3637
TaskManagerOptions,
3738
Tool,
@@ -342,19 +343,35 @@ export class Client extends Protocol<ClientContext> {
342343
method: M,
343344
handler: (request: RequestTypeMap[M], ctx: ClientContext) => ResultTypeMap[M] | Promise<ResultTypeMap[M]>
344345
): void;
346+
public override setRequestHandler<P extends StandardSchemaV1>(
347+
method: string,
348+
paramsSchema: P,
349+
handler: (params: StandardSchemaV1.InferOutput<P>, ctx: ClientContext) => Result | Promise<Result>
350+
): void;
345351
/** @deprecated Pass the method string instead. */
346352
public override setRequestHandler<T extends ZodLikeRequestSchema>(
347353
requestSchema: T,
348354
handler: (request: ReturnType<T['parse']>, ctx: ClientContext) => Result | Promise<Result>
349355
): void;
350-
public override setRequestHandler(method: string | ZodLikeRequestSchema, schemaHandler: unknown): void {
356+
public override setRequestHandler(
357+
method: string | ZodLikeRequestSchema,
358+
schemaOrHandler: unknown,
359+
maybeHandler?: (params: unknown, ctx: ClientContext) => unknown
360+
): void {
351361
if (isZodLikeSchema(method)) {
352362
return this._registerCompatRequestHandler(
353363
method,
354-
schemaHandler as (request: unknown, ctx: ClientContext) => Result | Promise<Result>
364+
schemaOrHandler as (request: unknown, ctx: ClientContext) => Result | Promise<Result>
365+
);
366+
}
367+
if (maybeHandler !== undefined) {
368+
return super.setRequestHandler(
369+
method,
370+
schemaOrHandler as StandardSchemaV1,
371+
maybeHandler as (params: unknown, ctx: ClientContext) => Result | Promise<Result>
355372
);
356373
}
357-
const handler = schemaHandler as (request: Request, ctx: ClientContext) => ClientResult | Promise<ClientResult>;
374+
const handler = schemaOrHandler as (request: Request, ctx: ClientContext) => ClientResult | Promise<ClientResult>;
358375
if (method === 'elicitation/create') {
359376
const wrappedHandler = async (request: Request, ctx: ClientContext): Promise<ClientResult> => {
360377
const validatedRequest = parseSchema(ElicitRequestSchema, request);

packages/core/src/exports/public/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ export { isTerminal } from '../../experimental/tasks/interfaces.js';
137137
export { InMemoryTaskMessageQueue, InMemoryTaskStore } from '../../experimental/tasks/stores/inMemory.js';
138138

139139
// Validator types and classes
140-
export type { StandardSchemaWithJSON } from '../../util/standardSchema.js';
140+
export type { StandardSchemaV1, StandardSchemaWithJSON } from '../../util/standardSchema.js';
141141
export { AjvJsonSchemaValidator } from '../../validators/ajvProvider.js';
142142
export type { CfWorkerSchemaDraft } from '../../validators/cfWorkerProvider.js';
143143
// fromJsonSchema is intentionally NOT exported here — the server and client packages

packages/core/src/shared/protocol.ts

Lines changed: 74 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ import type { ZodLikeRequestSchema } from '../util/compatSchema.js';
4848
import { extractMethodLiteral, isZodLikeSchema } from '../util/compatSchema.js';
4949
import type { AnySchema, SchemaOutput } from '../util/schema.js';
5050
import { parseSchema } from '../util/schema.js';
51+
import type { StandardSchemaV1 } from '../util/standardSchema.js';
52+
import { parseStandardSchema } from '../util/standardSchema.js';
5153
import type { TaskContext, TaskManagerHost, TaskManagerOptions, TaskRequestOptions } from './taskManager.js';
5254
import { NullTaskManager, TaskManager } from './taskManager.js';
5355
import type { Transport, TransportSendOptions } from './transport.js';
@@ -1020,26 +1022,60 @@ export abstract class Protocol<ContextT extends BaseContext> {
10201022
* method. Replaces any previous handler for the same method.
10211023
*
10221024
* Call forms:
1023-
* - **Spec method** — `setRequestHandler('tools/call', (request, ctx) => …)`.
1025+
* - **Spec method, two args** — `setRequestHandler('tools/call', (request, ctx) => …)`.
10241026
* The full `RequestTypeMap[M]` request object is validated by the SDK and passed to the
10251027
* handler. This is the form `Client`/`Server` use and override.
1028+
* - **Three args** — `setRequestHandler('vendor/custom', paramsSchema, (params, ctx) => …)`.
1029+
* Any method string; the supplied schema validates incoming `params`. Absent or undefined
1030+
* `params` are normalized to `{}` (after stripping `_meta`) before validation, so for
1031+
* no-params methods use `z.object({})`. `paramsSchema` may be any Standard Schema (Zod,
1032+
* Valibot, ArkType, etc.).
10261033
* - **Zod schema** — `setRequestHandler(RequestZodSchema, (request, ctx) => …)`. The method
10271034
* name is read from the schema's `method` literal; the handler receives the parsed request.
10281035
*/
10291036
setRequestHandler<M extends RequestMethod>(
10301037
method: M,
10311038
handler: (request: RequestTypeMap[M], ctx: ContextT) => Result | Promise<Result>
10321039
): void;
1040+
setRequestHandler<P extends StandardSchemaV1>(
1041+
method: string,
1042+
paramsSchema: P,
1043+
handler: (params: StandardSchemaV1.InferOutput<P>, ctx: ContextT) => Result | Promise<Result>
1044+
): void;
10331045
/** @deprecated Pass the method string instead. */
10341046
setRequestHandler<T extends ZodLikeRequestSchema>(
10351047
requestSchema: T,
10361048
handler: (request: ReturnType<T['parse']>, ctx: ContextT) => Result | Promise<Result>
10371049
): void;
1038-
setRequestHandler(method: string | ZodLikeRequestSchema, handler: (request: Request, ctx: ContextT) => Result | Promise<Result>): void {
1050+
setRequestHandler(
1051+
method: string | ZodLikeRequestSchema,
1052+
schemaOrHandler: StandardSchemaV1 | ((request: Request, ctx: ContextT) => Result | Promise<Result>),
1053+
maybeHandler?: (params: unknown, ctx: ContextT) => unknown
1054+
): void {
10391055
if (isZodLikeSchema(method)) {
1040-
return this._registerCompatRequestHandler(method, handler as (request: unknown, ctx: ContextT) => Result | Promise<Result>);
1056+
return this._registerCompatRequestHandler(
1057+
method,
1058+
schemaOrHandler as (request: unknown, ctx: ContextT) => Result | Promise<Result>
1059+
);
1060+
}
1061+
if (maybeHandler === undefined) {
1062+
return this._setRequestHandlerByMethod(
1063+
method,
1064+
schemaOrHandler as (request: Request, ctx: ContextT) => Result | Promise<Result>
1065+
);
10411066
}
1042-
this._setRequestHandlerByMethod(method, handler);
1067+
1068+
this.assertRequestHandlerCapability(method);
1069+
const paramsSchema = schemaOrHandler as StandardSchemaV1;
1070+
this._requestHandlers.set(method, async (request, ctx) => {
1071+
const { _meta, ...userParams } = (request.params ?? {}) as Record<string, unknown>;
1072+
void _meta;
1073+
const parsed = await parseStandardSchema(paramsSchema, userParams);
1074+
if (!parsed.success) {
1075+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`);
1076+
}
1077+
return maybeHandler(parsed.data, ctx) as Result | Promise<Result>;
1078+
});
10431079
}
10441080

10451081
/**
@@ -1089,31 +1125,55 @@ export abstract class Protocol<ContextT extends BaseContext> {
10891125
* Registers a handler to invoke when this protocol object receives a notification with the
10901126
* given method. Replaces any previous handler for the same method.
10911127
*
1092-
* Mirrors {@linkcode setRequestHandler}: a spec-method form (handler receives the full
1093-
* notification object) and a Zod-schema form (method read from the schema's `method` literal).
1128+
* Mirrors {@linkcode setRequestHandler}: a two-arg spec-method form (handler receives the full
1129+
* notification object), a three-arg form with a `paramsSchema` (handler receives validated
1130+
* `params`), and a Zod-schema form (method read from the schema's `method` literal).
10941131
*/
10951132
setNotificationHandler<M extends NotificationMethod>(
10961133
method: M,
10971134
handler: (notification: NotificationTypeMap[M]) => void | Promise<void>
10981135
): void;
1136+
setNotificationHandler<P extends StandardSchemaV1>(
1137+
method: string,
1138+
paramsSchema: P,
1139+
handler: (params: StandardSchemaV1.InferOutput<P>) => void | Promise<void>
1140+
): void;
10991141
/** @deprecated Pass the method string instead. */
11001142
setNotificationHandler<T extends ZodLikeRequestSchema>(
11011143
notificationSchema: T,
11021144
handler: (notification: ReturnType<T['parse']>) => void | Promise<void>
11031145
): void;
1104-
setNotificationHandler(method: string | ZodLikeRequestSchema, handler: (notification: Notification) => void | Promise<void>): void {
1146+
setNotificationHandler(
1147+
method: string | ZodLikeRequestSchema,
1148+
schemaOrHandler: StandardSchemaV1 | ((notification: Notification) => void | Promise<void>),
1149+
maybeHandler?: (params: unknown) => void | Promise<void>
1150+
): void {
11051151
if (isZodLikeSchema(method)) {
11061152
const notificationSchema = method;
11071153
const methodStr = extractMethodLiteral(notificationSchema);
1108-
this._notificationHandlers.set(methodStr, n =>
1109-
Promise.resolve((handler as (n: unknown) => void | Promise<void>)(notificationSchema.parse(n)))
1110-
);
1154+
const handler = schemaOrHandler as (notification: unknown) => void | Promise<void>;
1155+
this._notificationHandlers.set(methodStr, n => Promise.resolve(handler(notificationSchema.parse(n))));
1156+
return;
1157+
}
1158+
if (maybeHandler === undefined) {
1159+
const handler = schemaOrHandler as (notification: Notification) => void | Promise<void>;
1160+
const schema = getNotificationSchema(method as NotificationMethod);
1161+
this._notificationHandlers.set(method, notification => {
1162+
const parsed = schema.parse(notification);
1163+
return Promise.resolve(handler(parsed));
1164+
});
11111165
return;
11121166
}
1113-
const schema = getNotificationSchema(method as NotificationMethod);
1114-
this._notificationHandlers.set(method, notification => {
1115-
const parsed = schema.parse(notification);
1116-
return Promise.resolve(handler(parsed));
1167+
1168+
const paramsSchema = schemaOrHandler as StandardSchemaV1;
1169+
this._notificationHandlers.set(method, async notification => {
1170+
const { _meta, ...userParams } = (notification.params ?? {}) as Record<string, unknown>;
1171+
void _meta;
1172+
const parsed = await parseStandardSchema(paramsSchema, userParams);
1173+
if (!parsed.success) {
1174+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`);
1175+
}
1176+
return maybeHandler(parsed.data);
11171177
});
11181178
}
11191179

0 commit comments

Comments
 (0)