Skip to content

Commit db83829

Browse files
feat(core): custom-method support (3-arg setRequestHandler + request schema overload) (#1974)
1 parent e15a8ef commit db83829

15 files changed

Lines changed: 600 additions & 70 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@modelcontextprotocol/core': minor
3+
'@modelcontextprotocol/client': minor
4+
'@modelcontextprotocol/server': minor
5+
---
6+
7+
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.
8+
9+
Response result-schema validation failure now rejects with `SdkError(InvalidResult)` instead of a raw `ZodError`. Adds `SdkErrorCode.InvalidResult`.

docs/migration-SKILL.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ Two error classes now exist:
120120
| 403 after upscoping | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpForbidden` |
121121
| Unexpected content type | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpUnexpectedContent` |
122122
| Session termination failed | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpFailedToTerminateSession` |
123+
| Response result fails schema | `ZodError` (raw) | `SdkError` with `SdkErrorCode.InvalidResult` |
123124

124125
New `SdkErrorCode` enum values:
125126

@@ -130,6 +131,7 @@ New `SdkErrorCode` enum values:
130131
- `SdkErrorCode.RequestTimeout` = `'REQUEST_TIMEOUT'`
131132
- `SdkErrorCode.ConnectionClosed` = `'CONNECTION_CLOSED'`
132133
- `SdkErrorCode.SendFailed` = `'SEND_FAILED'`
134+
- `SdkErrorCode.InvalidResult` = `'INVALID_RESULT'`
133135
- `SdkErrorCode.ClientHttpNotImplemented` = `'CLIENT_HTTP_NOT_IMPLEMENTED'`
134136
- `SdkErrorCode.ClientHttpAuthentication` = `'CLIENT_HTTP_AUTHENTICATION'`
135137
- `SdkErrorCode.ClientHttpForbidden` = `'CLIENT_HTTP_FORBIDDEN'`
@@ -351,6 +353,28 @@ server.setRequestHandler('initialize', async (request) => { ... });
351353
server.setNotificationHandler('notifications/message', (notification) => { ... });
352354
```
353355

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

356380
| v1 Schema | v2 Method String |
@@ -406,9 +430,9 @@ Request/notification params remain fully typed. Remove unused schema imports aft
406430
| `ctx.mcpReq.elicitInput(params, options?)` | Elicit user input (form or URL) | `server.elicitInput(...)` from within handler |
407431
| `ctx.mcpReq.requestSampling(params, options?)` | Request LLM sampling from client | `server.createMessage(...)` from within handler |
408432

409-
## 11. Schema parameter removed from `request()`, `send()`, and `callTool()`
433+
## 11. Schema parameter removed from `request()`, `send()`, and `callTool()` (spec methods)
410434

411-
`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.
435+
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.
412436

413437
```typescript
414438
// v1: schema required
@@ -432,6 +456,8 @@ const tool = await client.callTool({ name: 'my-tool', arguments: {} });
432456
| `client.callTool(params, CompatibilityCallToolResultSchema)` | `client.callTool(params)` |
433457
| `client.callTool(params, schema, options)` | `client.callTool(params, options)` |
434458

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

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

docs/migration.md

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

367367
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.
368368

369+
#### Custom (non-spec) methods
370+
371+
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).
372+
373+
**Before (v1):**
374+
375+
```typescript
376+
const AcmeSearch = z.object({
377+
method: z.literal('acme/search'),
378+
params: z.object({ query: z.string(), limit: z.number().int() })
379+
});
380+
server.setRequestHandler(AcmeSearch, async request => {
381+
return { items: [/* ... */] };
382+
});
383+
```
384+
385+
**After (v2):**
386+
387+
```typescript
388+
const SearchParams = z.object({ query: z.string(), limit: z.number().int() });
389+
const SearchResult = z.object({ items: z.array(z.string()) });
390+
391+
server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => {
392+
return { items: [/* ... */] };
393+
});
394+
```
395+
396+
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`.
397+
398+
For `setNotificationHandler`, the 3-arg handler is `(params, notification) => void`. The raw notification is the second argument, so `_meta` is recoverable via `notification.params?._meta`.
399+
400+
#### Sending custom-method requests
401+
402+
`request()` and `ctx.mcpReq.send()` accept a result schema as the second argument; for custom methods this is required:
403+
404+
```typescript
405+
const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult);
406+
result.items; // string[]
407+
```
408+
409+
For spec methods the 1-arg form still works and the result type is inferred from the method name.
410+
369411
Common method string replacements:
370412

371413
| Schema (v1) | Method string (v2) |
@@ -384,10 +426,10 @@ Common method string replacements:
384426
| `ResourceListChangedNotificationSchema` | `'notifications/resources/list_changed'` |
385427
| `PromptListChangedNotificationSchema` | `'notifications/prompts/list_changed'` |
386428

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

389-
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
390-
like `CallToolResultSchema` or `ElicitResultSchema` when making requests.
431+
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
432+
like `CallToolResultSchema` or `ElicitResultSchema` when making spec-method requests.
391433

392434
**`client.request()` — Before (v1):**
393435

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

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

489+
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.
490+
447491
If you were using `CallToolResultSchema` for **runtime validation** (not just in `request()`/`callTool()` calls), use the new `isCallToolResult` type guard instead:
448492

449493
```typescript
@@ -658,6 +702,7 @@ The new `SdkErrorCode` enum contains string-valued codes for local SDK errors:
658702
| `SdkErrorCode.RequestTimeout` | Request timed out waiting for response |
659703
| `SdkErrorCode.ConnectionClosed` | Connection was closed |
660704
| `SdkErrorCode.SendFailed` | Failed to send message |
705+
| `SdkErrorCode.InvalidResult` | Response result failed local schema validation |
661706
| `SdkErrorCode.ClientHttpNotImplemented` | HTTP POST request failed |
662707
| `SdkErrorCode.ClientHttpAuthentication` | Server returned 401 after re-authentication |
663708
| `SdkErrorCode.ClientHttpForbidden` | Server returned 403 after trying upscoping |
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Custom (non-spec) method example: a client that sends `acme/search` and
3+
* listens for `acme/searchProgress` notifications.
4+
*
5+
* Build `examples/server` first; this client spawns the server via stdio.
6+
*/
7+
import { Client } from '@modelcontextprotocol/client';
8+
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
9+
import { z } from 'zod/v4';
10+
11+
const SearchResult = z.object({ items: z.array(z.string()) });
12+
const SearchProgressParams = z.object({ stage: z.string(), pct: z.number() });
13+
14+
const client = new Client({ name: 'acme-search-client', version: '0.0.0' });
15+
16+
client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => {
17+
console.log(`[progress] ${params.stage} ${Math.round(params.pct * 100)}%`);
18+
});
19+
20+
await client.connect(new StdioClientTransport({ command: 'node', args: ['../server/dist/customMethodExample.js'] }));
21+
22+
const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult);
23+
console.log('items:', result.items);
24+
25+
await client.close();
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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+
* Spawned via stdio by `examples/client/src/customMethodExample.ts`; do not run standalone.
6+
*/
7+
import { McpServer } from '@modelcontextprotocol/server';
8+
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
9+
import { z } from 'zod/v4';
10+
11+
const SearchParams = z.object({ query: z.string(), limit: z.number().int().default(10) });
12+
const SearchResult = z.object({ items: z.array(z.string()) });
13+
14+
const mcp = new McpServer({ name: 'acme-search', version: '0.0.0' });
15+
16+
mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => {
17+
await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } });
18+
const items = Array.from({ length: params.limit }, (_, i) => `${params.query}-${i}`);
19+
await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'done', pct: 1 } });
20+
return { items };
21+
});
22+
23+
await mcp.connect(new StdioServerTransport());

packages/client/src/client/client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,7 @@ export class Client extends Protocol<ClientContext> {
570570
return this._instructions;
571571
}
572572

573-
protected assertCapabilityForMethod(method: RequestMethod): void {
573+
protected assertCapabilityForMethod(method: RequestMethod | string): void {
574574
switch (method as ClientRequest['method']) {
575575
case 'logging/setLevel': {
576576
if (!this._serverCapabilities?.logging) {
@@ -633,7 +633,7 @@ export class Client extends Protocol<ClientContext> {
633633
}
634634
}
635635

636-
protected assertNotificationCapability(method: NotificationMethod): void {
636+
protected assertNotificationCapability(method: NotificationMethod | string): void {
637637
switch (method as ClientNotification['method']) {
638638
case 'notifications/roots/list_changed': {
639639
if (!this._capabilities.roots?.listChanged) {

packages/core/src/errors/sdkErrors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export enum SdkErrorCode {
2626
ConnectionClosed = 'CONNECTION_CLOSED',
2727
/** Failed to send message */
2828
SendFailed = 'SEND_FAILED',
29+
/** Response result failed local schema validation */
30+
InvalidResult = 'INVALID_RESULT',
2931

3032
// Transport errors
3133
ClientHttpNotImplemented = 'CLIENT_HTTP_NOT_IMPLEMENTED',

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

Lines changed: 2 additions & 1 deletion
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';
@@ -137,7 +138,7 @@ export { isTerminal } from '../../experimental/tasks/interfaces.js';
137138
export { InMemoryTaskMessageQueue, InMemoryTaskStore } from '../../experimental/tasks/stores/inMemory.js';
138139

139140
// Validator types and classes
140-
export type { StandardSchemaWithJSON } from '../../util/standardSchema.js';
141+
export type { StandardSchemaV1, StandardSchemaWithJSON } from '../../util/standardSchema.js';
141142
export { AjvJsonSchemaValidator } from '../../validators/ajvProvider.js';
142143
export type { CfWorkerSchemaDraft } from '../../validators/cfWorkerProvider.js';
143144
// fromJsonSchema is intentionally NOT exported here — the server and client packages
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)