Skip to content

Commit daab2e2

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 0daaa15 commit daab2e2

File tree

11 files changed

+264
-59
lines changed

11 files changed

+264
-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,25 +16,18 @@
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, request => {
31-
console.log('[server] acme/search query=' + request.params.query);
32-
return { hits: [request.params.query, request.params.query + '-result'] };
24+
server.setRequestHandler('acme/search', SearchParams, params => {
25+
console.log('[server] acme/search query=' + params.query);
26+
return { hits: [params.query, params.query + '-result'] };
3327
});
3428

35-
server.setNotificationHandler(TickNotification, n => {
36-
console.log('[server] acme/tick n=' + n.params.n);
29+
server.setNotificationHandler('acme/tick', TickParams, p => {
30+
console.log('[server] acme/tick n=' + p.n);
3731
});
3832

3933
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,18 +343,34 @@ 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
public override setRequestHandler<T extends ZodLikeRequestSchema>(
346352
requestSchema: T,
347353
handler: (request: ReturnType<T['parse']>, ctx: ClientContext) => Result | Promise<Result>
348354
): void;
349-
public override setRequestHandler(method: string | ZodLikeRequestSchema, schemaHandler: unknown): void {
355+
public override setRequestHandler(
356+
method: string | ZodLikeRequestSchema,
357+
schemaOrHandler: unknown,
358+
maybeHandler?: (params: unknown, ctx: ClientContext) => unknown
359+
): void {
350360
if (isZodLikeSchema(method)) {
351361
return this._registerCompatRequestHandler(
352362
method,
353-
schemaHandler as (request: unknown, ctx: ClientContext) => Result | Promise<Result>
363+
schemaOrHandler as (request: unknown, ctx: ClientContext) => Result | Promise<Result>
364+
);
365+
}
366+
if (maybeHandler !== undefined) {
367+
return super.setRequestHandler(
368+
method,
369+
schemaOrHandler as StandardSchemaV1,
370+
maybeHandler as (params: unknown, ctx: ClientContext) => Result | Promise<Result>
354371
);
355372
}
356-
const handler = schemaHandler as (request: Request, ctx: ClientContext) => ClientResult | Promise<ClientResult>;
373+
const handler = schemaOrHandler as (request: Request, ctx: ClientContext) => ClientResult | Promise<ClientResult>;
357374
if (method === 'elicitation/create') {
358375
const wrappedHandler = async (request: Request, ctx: ClientContext): Promise<ClientResult> => {
359376
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: 71 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';
@@ -1019,25 +1021,56 @@ export abstract class Protocol<ContextT extends BaseContext> {
10191021
* method. Replaces any previous handler for the same method.
10201022
*
10211023
* Call forms:
1022-
* - **Spec method** — `setRequestHandler('tools/call', (request, ctx) => …)`.
1024+
* - **Spec method, two args** — `setRequestHandler('tools/call', (request, ctx) => …)`.
10231025
* The full `RequestTypeMap[M]` request object is validated by the SDK and passed to the
10241026
* handler. This is the form `Client`/`Server` use and override.
1027+
* - **Three args** — `setRequestHandler('vendor/custom', paramsSchema, (params, ctx) => …)`.
1028+
* Any method string; the supplied schema validates incoming `params`. Absent or undefined
1029+
* `params` are normalized to `{}` (after stripping `_meta`) before validation, so for
1030+
* no-params methods use `z.object({})`. `paramsSchema` may be any Standard Schema (Zod,
1031+
* Valibot, ArkType, etc.).
10251032
* - **Zod schema** — `setRequestHandler(RequestZodSchema, (request, ctx) => …)`. The method
10261033
* name is read from the schema's `method` literal; the handler receives the parsed request.
10271034
*/
10281035
setRequestHandler<M extends RequestMethod>(
10291036
method: M,
10301037
handler: (request: RequestTypeMap[M], ctx: ContextT) => Result | Promise<Result>
10311038
): void;
1039+
setRequestHandler<P extends StandardSchemaV1>(
1040+
method: string,
1041+
paramsSchema: P,
1042+
handler: (params: StandardSchemaV1.InferOutput<P>, ctx: ContextT) => Result | Promise<Result>
1043+
): void;
10321044
setRequestHandler<T extends ZodLikeRequestSchema>(
10331045
requestSchema: T,
10341046
handler: (request: ReturnType<T['parse']>, ctx: ContextT) => Result | Promise<Result>
10351047
): void;
1036-
setRequestHandler(method: string | ZodLikeRequestSchema, handler: (request: Request, ctx: ContextT) => Result | Promise<Result>): void {
1048+
setRequestHandler(
1049+
method: string | ZodLikeRequestSchema,
1050+
schemaOrHandler: StandardSchemaV1 | ((request: Request, ctx: ContextT) => Result | Promise<Result>),
1051+
maybeHandler?: (params: unknown, ctx: ContextT) => unknown
1052+
): void {
10371053
if (isZodLikeSchema(method)) {
1038-
return this._registerCompatRequestHandler(method, handler as (request: unknown, ctx: ContextT) => Result | Promise<Result>);
1054+
return this._registerCompatRequestHandler(
1055+
method,
1056+
schemaOrHandler as (request: unknown, ctx: ContextT) => Result | Promise<Result>
1057+
);
1058+
}
1059+
if (maybeHandler === undefined) {
1060+
return this._setRequestHandlerByMethod(method, schemaOrHandler as (request: Request, ctx: ContextT) => Result | Promise<Result>);
10391061
}
1040-
this._setRequestHandlerByMethod(method, handler);
1062+
1063+
this.assertRequestHandlerCapability(method);
1064+
const paramsSchema = schemaOrHandler as StandardSchemaV1;
1065+
this._requestHandlers.set(method, async (request, ctx) => {
1066+
const { _meta, ...userParams } = (request.params ?? {}) as Record<string, unknown>;
1067+
void _meta;
1068+
const parsed = await parseStandardSchema(paramsSchema, userParams);
1069+
if (!parsed.success) {
1070+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`);
1071+
}
1072+
return maybeHandler(parsed.data, ctx) as Result | Promise<Result>;
1073+
});
10411074
}
10421075

10431076
/**
@@ -1087,30 +1120,54 @@ export abstract class Protocol<ContextT extends BaseContext> {
10871120
* Registers a handler to invoke when this protocol object receives a notification with the
10881121
* given method. Replaces any previous handler for the same method.
10891122
*
1090-
* Mirrors {@linkcode setRequestHandler}: a spec-method form (handler receives the full
1091-
* notification object) and a Zod-schema form (method read from the schema's `method` literal).
1123+
* Mirrors {@linkcode setRequestHandler}: a two-arg spec-method form (handler receives the full
1124+
* notification object), a three-arg form with a `paramsSchema` (handler receives validated
1125+
* `params`), and a Zod-schema form (method read from the schema's `method` literal).
10921126
*/
10931127
setNotificationHandler<M extends NotificationMethod>(
10941128
method: M,
10951129
handler: (notification: NotificationTypeMap[M]) => void | Promise<void>
10961130
): void;
1131+
setNotificationHandler<P extends StandardSchemaV1>(
1132+
method: string,
1133+
paramsSchema: P,
1134+
handler: (params: StandardSchemaV1.InferOutput<P>) => void | Promise<void>
1135+
): void;
10971136
setNotificationHandler<T extends ZodLikeRequestSchema>(
10981137
notificationSchema: T,
10991138
handler: (notification: ReturnType<T['parse']>) => void | Promise<void>
11001139
): void;
1101-
setNotificationHandler(method: string | ZodLikeRequestSchema, handler: (notification: Notification) => void | Promise<void>): void {
1140+
setNotificationHandler(
1141+
method: string | ZodLikeRequestSchema,
1142+
schemaOrHandler: StandardSchemaV1 | ((notification: Notification) => void | Promise<void>),
1143+
maybeHandler?: (params: unknown) => void | Promise<void>
1144+
): void {
11021145
if (isZodLikeSchema(method)) {
11031146
const notificationSchema = method;
11041147
const methodStr = extractMethodLiteral(notificationSchema);
1105-
this._notificationHandlers.set(methodStr, n =>
1106-
Promise.resolve((handler as (n: unknown) => void | Promise<void>)(notificationSchema.parse(n)))
1107-
);
1148+
const handler = schemaOrHandler as (notification: unknown) => void | Promise<void>;
1149+
this._notificationHandlers.set(methodStr, n => Promise.resolve(handler(notificationSchema.parse(n))));
1150+
return;
1151+
}
1152+
if (maybeHandler === undefined) {
1153+
const handler = schemaOrHandler as (notification: Notification) => void | Promise<void>;
1154+
const schema = getNotificationSchema(method as NotificationMethod);
1155+
this._notificationHandlers.set(method, notification => {
1156+
const parsed = schema.parse(notification);
1157+
return Promise.resolve(handler(parsed));
1158+
});
11081159
return;
11091160
}
1110-
const schema = getNotificationSchema(method as NotificationMethod);
1111-
this._notificationHandlers.set(method, notification => {
1112-
const parsed = schema.parse(notification);
1113-
return Promise.resolve(handler(parsed));
1161+
1162+
const paramsSchema = schemaOrHandler as StandardSchemaV1;
1163+
this._notificationHandlers.set(method, async notification => {
1164+
const { _meta, ...userParams } = (notification.params ?? {}) as Record<string, unknown>;
1165+
void _meta;
1166+
const parsed = await parseStandardSchema(paramsSchema, userParams);
1167+
if (!parsed.success) {
1168+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`);
1169+
}
1170+
return maybeHandler(parsed.data);
11141171
});
11151172
}
11161173

0 commit comments

Comments
 (0)