Skip to content

Commit c3382eb

Browse files
feat(core): 3-arg setRequestHandler/setNotificationHandler and request()/mcpReq.send schema overloads for custom methods
1 parent e69c6a7 commit c3382eb

14 files changed

Lines changed: 539 additions & 72 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@modelcontextprotocol/client': minor
3+
'@modelcontextprotocol/server': minor
4+
---
5+
6+
Add custom (non-spec) method support: a 3-arg `setRequestHandler(method, schemas, handler)` / `setNotificationHandler(method, schemas, handler)` form for vendor-prefixed methods, and a `request(req, resultSchema)` overload (also on `ctx.mcpReq.send`) for typed custom-method results. Spec-method calls are unchanged.
7+
8+
Response result-schema validation failure now rejects with `ProtocolError(InternalError)` instead of a raw `ZodError`.

docs/migration-SKILL.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,26 @@ server.setRequestHandler('initialize', async (request) => { ... });
352352
server.setNotificationHandler('notifications/message', (notification) => { ... });
353353
```
354354

355+
For custom (non-spec) methods, use the 3-arg form `(method, schemas, handler)`:
356+
357+
```typescript
358+
// v1: Zod schema with method literal
359+
server.setRequestHandler(z.object({ method: z.literal('acme/search'), params: P }), async req => { ... });
360+
361+
// v2: method string + schemas object; handler receives parsed params
362+
server.setRequestHandler('acme/search', { params: P, result: R }, async (params, ctx) => { ... });
363+
client.setNotificationHandler('acme/progress', { params: P }, params => { ... });
364+
```
365+
366+
To send a custom-method request, pass a result schema as the second argument to `request()` (and `ctx.mcpReq.send()`):
367+
368+
```typescript
369+
// v1
370+
await client.request({ method: 'acme/search', params }, ResultSchema);
371+
// v2 (unchanged; now any Standard Schema, not Zod-only)
372+
await client.request({ method: 'acme/search', params }, ResultSchema);
373+
```
374+
355375
Schema to method string mapping:
356376

357377
| v1 Schema | v2 Method String |
@@ -407,9 +427,9 @@ Request/notification params remain fully typed. Remove unused schema imports aft
407427
| `ctx.mcpReq.elicitInput(params, options?)` | Elicit user input (form or URL) | `server.elicitInput(...)` from within handler |
408428
| `ctx.mcpReq.requestSampling(params, options?)` | Request LLM sampling from client | `server.createMessage(...)` from within handler |
409429

410-
## 11. Schema parameter removed from `request()`, `send()`, and `callTool()`
430+
## 11. Schema parameter removed from `request()`, `send()`, and `callTool()` (spec methods)
411431

412-
`Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` no longer take a Zod result schema argument. The SDK resolves the schema internally from the method name.
432+
For **spec** methods, `Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` no longer require a Zod result schema argument. The SDK resolves the schema internally from the method name.
413433

414434
```typescript
415435
// v1: schema required
@@ -433,6 +453,8 @@ const tool = await client.callTool({ name: 'my-tool', arguments: {} });
433453
| `client.callTool(params, CompatibilityCallToolResultSchema)` | `client.callTool(params)` |
434454
| `client.callTool(params, schema, options)` | `client.callTool(params, options)` |
435455

456+
For **custom (non-spec)** methods, keep the result-schema argument — see §9. Only apply the rewrites above when `req.method` is a spec method.
457+
436458
Remove unused schema imports: `CallToolResultSchema`, `CompatibilityCallToolResultSchema`, `ElicitResultSchema`, `CreateMessageResultSchema`, etc., when they were only used in `request()`/`send()`/`callTool()` calls.
437459

438460
If `CallToolResultSchema` was used for **runtime validation** (not just as a `request()` argument), replace with the `isCallToolResult` type guard:

docs/migration.md

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,46 @@ server.setNotificationHandler('notifications/message', notification => {
364364

365365
The request and notification parameters remain fully typed via `RequestTypeMap` and `NotificationTypeMap`. You no longer need to import the individual `*RequestSchema` or `*NotificationSchema` constants for handler registration.
366366

367+
#### Custom (non-spec) methods
368+
369+
For vendor-prefixed methods (anything not in the MCP spec), use the 3-arg form: pass the method string, a `{ params, result? }` schemas object, and the handler. Any [Standard Schema](https://standardschema.dev) library works (Zod, Valibot, ArkType).
370+
371+
**Before (v1):**
372+
373+
```typescript
374+
const AcmeSearch = z.object({
375+
method: z.literal('acme/search'),
376+
params: z.object({ query: z.string(), limit: z.number().int() })
377+
});
378+
server.setRequestHandler(AcmeSearch, async request => {
379+
return { items: [/* ... */] };
380+
});
381+
```
382+
383+
**After (v2):**
384+
385+
```typescript
386+
const SearchParams = z.object({ query: z.string(), limit: z.number().int() });
387+
const SearchResult = z.object({ items: z.array(z.string()) });
388+
389+
server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => {
390+
return { items: [/* ... */] };
391+
});
392+
```
393+
394+
The handler receives the parsed `params` directly (not the full request envelope). `_meta` is stripped before validation and is available as `ctx.mcpReq._meta`. Supplying `result` types the handler's return value; omit it to return any `Result`.
395+
396+
#### Sending custom-method requests
397+
398+
`request()` and `ctx.mcpReq.send()` accept a result schema as the second argument; for custom methods this is required:
399+
400+
```typescript
401+
const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult);
402+
result.items; // string[]
403+
```
404+
405+
For spec methods the 1-arg form still works and the result type is inferred from the method name.
406+
367407
Common method string replacements:
368408

369409
| Schema (v1) | Method string (v2) |
@@ -382,10 +422,10 @@ Common method string replacements:
382422
| `ResourceListChangedNotificationSchema` | `'notifications/resources/list_changed'` |
383423
| `PromptListChangedNotificationSchema` | `'notifications/prompts/list_changed'` |
384424

385-
### `Protocol.request()`, `ctx.mcpReq.send()`, and `Client.callTool()` no longer take a schema parameter
425+
### `Protocol.request()`, `ctx.mcpReq.send()`, and `Client.callTool()` no longer require a schema parameter for spec methods
386426

387-
The public `Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` methods no longer accept a Zod result schema argument. The SDK now resolves the correct result schema internally based on the method name. This means you no longer need to import result schemas
388-
like `CallToolResultSchema` or `ElicitResultSchema` when making requests.
427+
For **spec** methods, the public `Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` methods no longer require a Zod result schema argument. The SDK now resolves the correct result schema internally based on the method name. This means you no longer need to import result schemas
428+
like `CallToolResultSchema` or `ElicitResultSchema` when making spec-method requests.
389429

390430
**`client.request()` — Before (v1):**
391431

@@ -442,6 +482,8 @@ const result = await client.callTool({ name: 'my-tool', arguments: {} });
442482

443483
The return type is now inferred from the method name via `ResultTypeMap`. For example, `client.request({ method: 'tools/call', ... })` returns `Promise<CallToolResult | CreateTaskResult>`.
444484

485+
For **custom (non-spec)** methods, keep the result-schema argument — see [Sending custom-method requests](#sending-custom-method-requests). Only drop the schema when calling a spec method.
486+
445487
If you were using `CallToolResultSchema` for **runtime validation** (not just in `request()`/`callTool()` calls), use the new `isCallToolResult` type guard instead:
446488

447489
```typescript
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Custom (non-spec) method example: a client that sends `acme/search` and
3+
* listens for `acme/searchProgress` notifications.
4+
*
5+
* Run after starting `examples/server/src/customMethodExample.ts`.
6+
*/
7+
import { Client, StdioClientTransport } from '@modelcontextprotocol/client';
8+
import { z } from 'zod/v4';
9+
10+
const SearchResult = z.object({ items: z.array(z.string()) });
11+
const SearchProgressParams = z.object({ stage: z.string(), pct: z.number() });
12+
13+
const client = new Client({ name: 'acme-search-client', version: '0.0.0' });
14+
15+
client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => {
16+
console.log(`[progress] ${params.stage} ${Math.round(params.pct * 100)}%`);
17+
});
18+
19+
await client.connect(new StdioClientTransport({ command: 'node', args: ['../server/dist/customMethodExample.js'] }));
20+
21+
const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult);
22+
console.log('items:', result.items);
23+
24+
await client.close();
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Custom (non-spec) method example: a server that handles a vendor-prefixed
3+
* `acme/search` request and emits `acme/searchProgress` notifications.
4+
*
5+
* Run alongside `examples/client/src/customMethodExample.ts`.
6+
*/
7+
import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server';
8+
import { z } from 'zod/v4';
9+
10+
const SearchParams = z.object({ query: z.string(), limit: z.number().int().default(10) });
11+
const SearchResult = z.object({ items: z.array(z.string()) });
12+
13+
const mcp = new McpServer({ name: 'acme-search', version: '0.0.0' });
14+
15+
mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => {
16+
await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } });
17+
const items = Array.from({ length: params.limit }, (_, i) => `${params.query}-${i}`);
18+
await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'done', pct: 1 } });
19+
return { items };
20+
});
21+
22+
await mcp.connect(new StdioServerTransport());

packages/client/src/client/client.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import type {
2424
NotificationMethod,
2525
ProtocolOptions,
2626
ReadResourceRequest,
27-
RequestMethod,
2827
RequestOptions,
2928
Result,
3029
ServerCapabilities,
@@ -570,7 +569,7 @@ export class Client extends Protocol<ClientContext> {
570569
return this._instructions;
571570
}
572571

573-
protected assertCapabilityForMethod(method: RequestMethod): void {
572+
protected assertCapabilityForMethod(method: string): void {
574573
switch (method as ClientRequest['method']) {
575574
case 'logging/setLevel': {
576575
if (!this._serverCapabilities?.logging) {
@@ -633,7 +632,7 @@ export class Client extends Protocol<ClientContext> {
633632
}
634633
}
635634

636-
protected assertNotificationCapability(method: NotificationMethod): void {
635+
protected assertNotificationCapability(method: string): void {
637636
switch (method as ClientNotification['method']) {
638637
case 'notifications/roots/list_changed': {
639638
if (!this._capabilities.roots?.listChanged) {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export type {
4545
NotificationOptions,
4646
ProgressCallback,
4747
ProtocolOptions,
48+
RequestHandlerSchemas,
4849
RequestOptions,
4950
ServerContext
5051
} from '../../shared/protocol.js';
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* Type-checked examples for `protocol.ts`.
3+
*
4+
* These examples are synced into JSDoc comments via the sync-snippets script.
5+
* Each function's region markers define the code snippet that appears in the docs.
6+
*
7+
* @module
8+
*/
9+
10+
import * as z from 'zod/v4';
11+
12+
import type { BaseContext, Protocol } from './protocol.js';
13+
14+
/**
15+
* Example: registering a handler for a custom (non-spec) request method.
16+
*/
17+
function Protocol_setRequestHandler_customMethod(protocol: Protocol<BaseContext>) {
18+
//#region Protocol_setRequestHandler_customMethod
19+
const SearchParams = z.object({ query: z.string(), limit: z.number().optional() });
20+
const SearchResult = z.object({ hits: z.array(z.string()) });
21+
22+
protocol.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, _ctx) => {
23+
return { hits: [`result for ${params.query}`] };
24+
});
25+
//#endregion Protocol_setRequestHandler_customMethod
26+
void protocol;
27+
}
28+
29+
void Protocol_setRequestHandler_customMethod;

0 commit comments

Comments
 (0)