Skip to content

Commit 05a01a1

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 5e538a6 commit 05a01a1

File tree

11 files changed

+275
-63
lines changed

11 files changed

+275
-63
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: 17 additions & 4 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,
@@ -343,22 +344,34 @@ export class Client extends Protocol<ClientContext> {
343344
method: M,
344345
handler: (request: RequestTypeMap[M], ctx: ClientContext) => ResultTypeMap[M] | Promise<ResultTypeMap[M]>
345346
): void;
346-
/** @deprecated For spec methods, pass the method string instead. */
347+
public override setRequestHandler<P extends StandardSchemaV1>(
348+
method: string,
349+
paramsSchema: P,
350+
handler: (params: StandardSchemaV1.InferOutput<P>, ctx: ClientContext) => Result | Promise<Result>
351+
): void;
352+
/** @deprecated For spec methods, pass the method string instead; for custom methods, prefer the 3-arg form. */
347353
public override setRequestHandler<T extends ZodLikeRequestSchema>(
348354
requestSchema: T,
349355
handler: (request: ReturnType<T['parse']>, ctx: ClientContext) => Result | Promise<Result>
350356
): void;
351-
public override setRequestHandler(methodOrSchema: string | ZodLikeRequestSchema, schemaHandler: unknown): void {
357+
public override setRequestHandler(
358+
methodOrSchema: string | ZodLikeRequestSchema,
359+
schemaOrHandler: unknown,
360+
maybeHandler?: (params: unknown, ctx: ClientContext) => unknown
361+
): void {
352362
let method: string;
353363
let handler: (request: Request, ctx: ClientContext) => ClientResult | Promise<ClientResult>;
354364
if (isZodLikeSchema(methodOrSchema)) {
355365
const schema = methodOrSchema;
356-
const userHandler = schemaHandler as (request: unknown, ctx: ClientContext) => Result | Promise<Result>;
366+
const userHandler = schemaOrHandler as (request: unknown, ctx: ClientContext) => Result | Promise<Result>;
357367
method = extractMethodLiteral(schema);
358368
handler = (req, ctx) => userHandler(schema.parse(req), ctx);
369+
} else if (maybeHandler === undefined) {
370+
method = methodOrSchema;
371+
handler = schemaOrHandler as (request: Request, ctx: ClientContext) => ClientResult | Promise<ClientResult>;
359372
} else {
360373
method = methodOrSchema;
361-
handler = schemaHandler as (request: Request, ctx: ClientContext) => ClientResult | Promise<ClientResult>;
374+
handler = this._wrapParamsSchemaHandler(method, schemaOrHandler as StandardSchemaV1, maybeHandler);
362375
}
363376
if (method === 'elicitation/create') {
364377
const wrappedHandler = async (request: Request, ctx: ClientContext): Promise<ClientResult> => {

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: 88 additions & 16 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,72 @@ 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;
1033-
/** @deprecated For spec methods, pass the method string instead. */
1040+
setRequestHandler<P extends StandardSchemaV1>(
1041+
method: string,
1042+
paramsSchema: P,
1043+
handler: (params: StandardSchemaV1.InferOutput<P>, ctx: ContextT) => Result | Promise<Result>
1044+
): void;
1045+
/** @deprecated For spec methods, pass the method string instead; for custom methods, prefer the 3-arg form. */
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._setRequestHandlerByMethod(method, this._wrapParamsSchemaHandler(method, schemaOrHandler as StandardSchemaV1, maybeHandler));
1069+
}
1070+
1071+
/**
1072+
* Builds a request handler from a `paramsSchema` + params-only user handler. Strips `_meta`,
1073+
* validates `params` against the schema, and invokes the user handler with the parsed params.
1074+
* Shared by {@linkcode setRequestHandler}'s 3-arg dispatch and `Client`/`Server` overrides
1075+
* so that per-method wrapping can be applied uniformly to the normalized handler.
1076+
*/
1077+
protected _wrapParamsSchemaHandler(
1078+
method: string,
1079+
paramsSchema: StandardSchemaV1,
1080+
userHandler: (params: unknown, ctx: ContextT) => unknown
1081+
): (request: Request, ctx: ContextT) => Promise<Result> {
1082+
return async (request, ctx) => {
1083+
const { _meta, ...userParams } = (request.params ?? {}) as Record<string, unknown>;
1084+
void _meta;
1085+
const parsed = await parseStandardSchema(paramsSchema, userParams);
1086+
if (!parsed.success) {
1087+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`);
1088+
}
1089+
return userHandler(parsed.data, ctx) as Result;
1090+
};
10431091
}
10441092

10451093
/**
@@ -1089,31 +1137,55 @@ export abstract class Protocol<ContextT extends BaseContext> {
10891137
* Registers a handler to invoke when this protocol object receives a notification with the
10901138
* given method. Replaces any previous handler for the same method.
10911139
*
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).
1140+
* Mirrors {@linkcode setRequestHandler}: a two-arg spec-method form (handler receives the full
1141+
* notification object), a three-arg form with a `paramsSchema` (handler receives validated
1142+
* `params`), and a Zod-schema form (method read from the schema's `method` literal).
10941143
*/
10951144
setNotificationHandler<M extends NotificationMethod>(
10961145
method: M,
10971146
handler: (notification: NotificationTypeMap[M]) => void | Promise<void>
10981147
): void;
1099-
/** @deprecated For spec methods, pass the method string instead. */
1148+
setNotificationHandler<P extends StandardSchemaV1>(
1149+
method: string,
1150+
paramsSchema: P,
1151+
handler: (params: StandardSchemaV1.InferOutput<P>) => void | Promise<void>
1152+
): void;
1153+
/** @deprecated For spec methods, pass the method string instead; for custom methods, prefer the 3-arg form. */
11001154
setNotificationHandler<T extends ZodLikeRequestSchema>(
11011155
notificationSchema: T,
11021156
handler: (notification: ReturnType<T['parse']>) => void | Promise<void>
11031157
): void;
1104-
setNotificationHandler(method: string | ZodLikeRequestSchema, handler: (notification: Notification) => void | Promise<void>): void {
1158+
setNotificationHandler(
1159+
method: string | ZodLikeRequestSchema,
1160+
schemaOrHandler: StandardSchemaV1 | ((notification: Notification) => void | Promise<void>),
1161+
maybeHandler?: (params: unknown) => void | Promise<void>
1162+
): void {
11051163
if (isZodLikeSchema(method)) {
11061164
const notificationSchema = method;
11071165
const methodStr = extractMethodLiteral(notificationSchema);
1108-
this._notificationHandlers.set(methodStr, n =>
1109-
Promise.resolve((handler as (n: unknown) => void | Promise<void>)(notificationSchema.parse(n)))
1110-
);
1166+
const handler = schemaOrHandler as (notification: unknown) => void | Promise<void>;
1167+
this._notificationHandlers.set(methodStr, n => Promise.resolve(handler(notificationSchema.parse(n))));
11111168
return;
11121169
}
1113-
const schema = getNotificationSchema(method as NotificationMethod);
1114-
this._notificationHandlers.set(method, notification => {
1115-
const parsed = schema ? schema.parse(notification) : notification;
1116-
return Promise.resolve(handler(parsed));
1170+
if (maybeHandler === undefined) {
1171+
const handler = schemaOrHandler as (notification: Notification) => void | Promise<void>;
1172+
const schema = getNotificationSchema(method as NotificationMethod);
1173+
this._notificationHandlers.set(method, notification => {
1174+
const parsed = schema ? schema.parse(notification) : notification;
1175+
return Promise.resolve(handler(parsed));
1176+
});
1177+
return;
1178+
}
1179+
1180+
const paramsSchema = schemaOrHandler as StandardSchemaV1;
1181+
this._notificationHandlers.set(method, async notification => {
1182+
const { _meta, ...userParams } = (notification.params ?? {}) as Record<string, unknown>;
1183+
void _meta;
1184+
const parsed = await parseStandardSchema(paramsSchema, userParams);
1185+
if (!parsed.success) {
1186+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`);
1187+
}
1188+
return maybeHandler(parsed.data);
11171189
});
11181190
}
11191191

0 commit comments

Comments
 (0)