Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/custom-methods-minimal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@modelcontextprotocol/core': minor
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/server': minor
---
Comment thread
claude[bot] marked this conversation as resolved.

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.

Response result-schema validation failure now rejects with `SdkError(InvalidResult)` instead of a raw `ZodError`. Adds `SdkErrorCode.InvalidResult`.
30 changes: 28 additions & 2 deletions docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ Two error classes now exist:
| 403 after upscoping | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpForbidden` |
| Unexpected content type | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpUnexpectedContent` |
| Session termination failed | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpFailedToTerminateSession` |
| Response result fails schema | `ZodError` (raw) | `SdkError` with `SdkErrorCode.InvalidResult` |

New `SdkErrorCode` enum values:

Expand All @@ -130,6 +131,7 @@ New `SdkErrorCode` enum values:
- `SdkErrorCode.RequestTimeout` = `'REQUEST_TIMEOUT'`
- `SdkErrorCode.ConnectionClosed` = `'CONNECTION_CLOSED'`
- `SdkErrorCode.SendFailed` = `'SEND_FAILED'`
- `SdkErrorCode.InvalidResult` = `'INVALID_RESULT'`
- `SdkErrorCode.ClientHttpNotImplemented` = `'CLIENT_HTTP_NOT_IMPLEMENTED'`
- `SdkErrorCode.ClientHttpAuthentication` = `'CLIENT_HTTP_AUTHENTICATION'`
- `SdkErrorCode.ClientHttpForbidden` = `'CLIENT_HTTP_FORBIDDEN'`
Expand Down Expand Up @@ -351,6 +353,28 @@ server.setRequestHandler('initialize', async (request) => { ... });
server.setNotificationHandler('notifications/message', (notification) => { ... });
```

For custom (non-spec) methods, use the 3-arg form `(method, schemas, handler)`:

```typescript
// v1: Zod schema with method literal
server.setRequestHandler(z.object({ method: z.literal('acme/search'), params: P }), async req => { ... });

// v2: method string + schemas object; handler receives parsed params
server.setRequestHandler('acme/search', { params: P, result: R }, async (params, ctx) => { ... });
client.setNotificationHandler('acme/progress', { params: P }, (params, notification) => { ... });
```

The 3-arg notification handler receives the raw notification as its second argument, so `_meta` is recoverable via `notification.params?._meta`.

To send a custom-method request, pass a result schema as the second argument to `request()` (and `ctx.mcpReq.send()`):

```typescript
// v1
await client.request({ method: 'acme/search', params }, ResultSchema);
// v2 (unchanged; now any Standard Schema, not Zod-only)
await client.request({ method: 'acme/search', params }, ResultSchema);
```

Comment thread
claude[bot] marked this conversation as resolved.
Schema to method string mapping:

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

## 11. Schema parameter removed from `request()`, `send()`, and `callTool()`
## 11. Schema parameter removed from `request()`, `send()`, and `callTool()` (spec methods)

`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.
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.

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

For **custom (non-spec)** methods, keep the result-schema argument — see §9. Only apply the rewrites above when `req.method` is a spec method.

Remove unused schema imports: `CallToolResultSchema`, `CompatibilityCallToolResultSchema`, `ElicitResultSchema`, `CreateMessageResultSchema`, etc., when they were only used in `request()`/`send()`/`callTool()` calls.

If `CallToolResultSchema` was used for **runtime validation** (not just as a `request()` argument), replace with the `isCallToolResult` type guard:
Expand Down
51 changes: 48 additions & 3 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,48 @@ server.setNotificationHandler('notifications/message', notification => {

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.

#### Custom (non-spec) methods

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).

**Before (v1):**

```typescript
const AcmeSearch = z.object({
method: z.literal('acme/search'),
params: z.object({ query: z.string(), limit: z.number().int() })
});
server.setRequestHandler(AcmeSearch, async request => {
return { items: [/* ... */] };
});
```

**After (v2):**

```typescript
const SearchParams = z.object({ query: z.string(), limit: z.number().int() });
const SearchResult = z.object({ items: z.array(z.string()) });

server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => {
return { items: [/* ... */] };
});
```

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`.

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`.

#### Sending custom-method requests

`request()` and `ctx.mcpReq.send()` accept a result schema as the second argument; for custom methods this is required:

```typescript
const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult);
result.items; // string[]
```

For spec methods the 1-arg form still works and the result type is inferred from the method name.

Common method string replacements:

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

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

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
like `CallToolResultSchema` or `ElicitResultSchema` when making requests.
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
like `CallToolResultSchema` or `ElicitResultSchema` when making spec-method requests.

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

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

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

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.

If you were using `CallToolResultSchema` for **runtime validation** (not just in `request()`/`callTool()` calls), use the new `isCallToolResult` type guard instead:

```typescript
Expand Down Expand Up @@ -658,6 +702,7 @@ The new `SdkErrorCode` enum contains string-valued codes for local SDK errors:
| `SdkErrorCode.RequestTimeout` | Request timed out waiting for response |
| `SdkErrorCode.ConnectionClosed` | Connection was closed |
| `SdkErrorCode.SendFailed` | Failed to send message |
| `SdkErrorCode.InvalidResult` | Response result failed local schema validation |
| `SdkErrorCode.ClientHttpNotImplemented` | HTTP POST request failed |
| `SdkErrorCode.ClientHttpAuthentication` | Server returned 401 after re-authentication |
| `SdkErrorCode.ClientHttpForbidden` | Server returned 403 after trying upscoping |
Expand Down
24 changes: 24 additions & 0 deletions examples/client/src/customMethodExample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Custom (non-spec) method example: a client that sends `acme/search` and
* listens for `acme/searchProgress` notifications.
*
* Build `examples/server` first; this client spawns the server via stdio.
*/
import { Client, StdioClientTransport } from '@modelcontextprotocol/client';

Check failure on line 7 in examples/client/src/customMethodExample.ts

View check run for this annotation

Claude / Claude Code Review

Stdio transport imports broken by merge of #1871 in both new examples

Both new examples import their stdio transport from the package root, but merge commit 9df35c2 brought in #1871 which moved `StdioClientTransport`/`StdioServerTransport` to the `./stdio` subpath — the root barrels no longer export them. As-is, `examples/client` and `examples/server` will fail typecheck/build with "Module has no exported member 'Stdio…Transport'". Fix: split the imports — `import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';` here and `import { StdioServerTr
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
import { z } from 'zod/v4';

const SearchResult = z.object({ items: z.array(z.string()) });
const SearchProgressParams = z.object({ stage: z.string(), pct: z.number() });

const client = new Client({ name: 'acme-search-client', version: '0.0.0' });

client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => {
console.log(`[progress] ${params.stage} ${Math.round(params.pct * 100)}%`);
});

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

const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult);
console.log('items:', result.items);

await client.close();
22 changes: 22 additions & 0 deletions examples/server/src/customMethodExample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Custom (non-spec) method example: a server that handles a vendor-prefixed
* `acme/search` request and emits `acme/searchProgress` notifications.
*
* Spawned via stdio by `examples/client/src/customMethodExample.ts`; do not run standalone.
*/
import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server';
import { z } from 'zod/v4';

const SearchParams = z.object({ query: z.string(), limit: z.number().int().default(10) });
const SearchResult = z.object({ items: z.array(z.string()) });

const mcp = new McpServer({ name: 'acme-search', version: '0.0.0' });

mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => {
await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } });
const items = Array.from({ length: params.limit }, (_, i) => `${params.query}-${i}`);
await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'done', pct: 1 } });
return { items };
});

await mcp.connect(new StdioServerTransport());
4 changes: 2 additions & 2 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,7 @@ export class Client extends Protocol<ClientContext> {
return this._instructions;
}

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

protected assertNotificationCapability(method: NotificationMethod): void {
protected assertNotificationCapability(method: NotificationMethod | string): void {
switch (method as ClientNotification['method']) {
case 'notifications/roots/list_changed': {
if (!this._capabilities.roots?.listChanged) {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/errors/sdkErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export enum SdkErrorCode {
ConnectionClosed = 'CONNECTION_CLOSED',
/** Failed to send message */
SendFailed = 'SEND_FAILED',
/** Response result failed local schema validation */
InvalidResult = 'INVALID_RESULT',
Comment thread
claude[bot] marked this conversation as resolved.

// Transport errors
ClientHttpNotImplemented = 'CLIENT_HTTP_NOT_IMPLEMENTED',
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/exports/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export type {
NotificationOptions,
ProgressCallback,
ProtocolOptions,
RequestHandlerSchemas,
Comment thread
claude[bot] marked this conversation as resolved.
RequestOptions,
ServerContext
} from '../../shared/protocol.js';
Expand Down Expand Up @@ -137,7 +138,7 @@ export { isTerminal } from '../../experimental/tasks/interfaces.js';
export { InMemoryTaskMessageQueue, InMemoryTaskStore } from '../../experimental/tasks/stores/inMemory.js';

// Validator types and classes
export type { StandardSchemaWithJSON } from '../../util/standardSchema.js';
export type { StandardSchemaV1, StandardSchemaWithJSON } from '../../util/standardSchema.js';
export { AjvJsonSchemaValidator } from '../../validators/ajvProvider.js';
export type { CfWorkerSchemaDraft } from '../../validators/cfWorkerProvider.js';
// fromJsonSchema is intentionally NOT exported here — the server and client packages
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/shared/protocol.examples.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Type-checked examples for `protocol.ts`.
*
* These examples are synced into JSDoc comments via the sync-snippets script.
* Each function's region markers define the code snippet that appears in the docs.
*
* @module
*/

import * as z from 'zod/v4';

import type { BaseContext, Protocol } from './protocol.js';

/**
* Example: registering a handler for a custom (non-spec) request method.
*/
function Protocol_setRequestHandler_customMethod(protocol: Protocol<BaseContext>) {
//#region Protocol_setRequestHandler_customMethod
const SearchParams = z.object({ query: z.string(), limit: z.number().optional() });
const SearchResult = z.object({ hits: z.array(z.string()) });

protocol.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, _ctx) => {
return { hits: [`result for ${params.query}`] };
});
//#endregion Protocol_setRequestHandler_customMethod
void protocol;
}

void Protocol_setRequestHandler_customMethod;
Loading
Loading