Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions .changeset/custom-method-handlers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/server': minor
---

Add `setCustomRequestHandler` / `setCustomNotificationHandler` / `sendCustomRequest` / `sendCustomNotification` (plus `remove*` variants) on `Protocol` for non-standard JSON-RPC methods. Restores typed registration for vendor-specific methods (e.g. `mcp-ui/*`) that #1446/#1451 closed off, without reintroducing class-level generics. Handlers share the standard dispatch path (context, cancellation, tasks); a collision guard rejects standard MCP methods.
13 changes: 13 additions & 0 deletions docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,19 @@ Schema to method string mapping:

Request/notification params remain fully typed. Remove unused schema imports after migration.

**Custom (non-standard) methods** — vendor extensions or sub-protocols whose method strings are not in the MCP spec — are no longer accepted by `setRequestHandler`/`setNotificationHandler`. Use the `*Custom*` API instead:

| v1 | v2 |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| `setRequestHandler(CustomReqSchema, (req, extra) => ...)` | `setCustomRequestHandler('vendor/method', ParamsSchema, (params, ctx) => ...)` |
| `setNotificationHandler(CustomNotifSchema, n => ...)` | `setCustomNotificationHandler('vendor/method', ParamsSchema, params => ...)` |
| `this.request({ method: 'vendor/x', params }, ResultSchema)` | `this.sendCustomRequest('vendor/x', params, ResultSchema)` |
| `this.notification({ method: 'vendor/x', params })` | `this.sendCustomNotification('vendor/x', params)` |
| `class X extends Protocol<Req, Notif, Res>` | `class X extends Client` (or `Server`), or compose a `Client` instance |

The v1 schema's `.shape.params` becomes the `ParamsSchema` argument; the `method: z.literal('...')` value becomes the string argument.


## 10. Request Handler Context Types

`RequestHandlerExtra` → structured context types with nested groups. Rename `extra` → `ctx` in all handler callbacks.
Expand Down
52 changes: 52 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,58 @@ Common method string replacements:
| `ResourceListChangedNotificationSchema` | `'notifications/resources/list_changed'` |
| `PromptListChangedNotificationSchema` | `'notifications/prompts/list_changed'` |

### Custom (non-standard) protocol methods

In v1, `setRequestHandler` accepted any Zod schema with a `method: z.literal('...')` shape, so vendor-specific methods (e.g. `mcp-ui/initialize`) could be registered the same way as spec methods. The `Protocol<SendRequestT, SendNotificationT, SendResultT>` generics widened the
send-side types to match.

In v2, `setRequestHandler`/`setNotificationHandler` accept only standard MCP method strings, and the class-level send-side generics have been removed. For methods outside the MCP spec, use the dedicated `*Custom*` methods on `Client` and `Server` (inherited from `Protocol`):

**Before (v1):**

```typescript
import { Protocol } from '@modelcontextprotocol/sdk/shared/protocol.js';

const SearchRequestSchema = z.object({
method: z.literal('acme/search'),
params: z.object({ query: z.string() })
});

class App extends Protocol<AppRequest, AppNotification, AppResult> {
constructor() {
super();
this.setRequestHandler(SearchRequestSchema, req => ({ hits: [req.params.query] }));
}
search(query: string) {
return this.request({ method: 'acme/search', params: { query } }, SearchResultSchema);
}
}
```

**After (v2):**

```typescript
import { Client } from '@modelcontextprotocol/client';

const SearchParams = z.object({ query: z.string() });
const SearchResult = z.object({ hits: z.array(z.string()) });

class App extends Client {
constructor() {
super({ name: 'app', version: '1.0.0' });
this.setCustomRequestHandler('acme/search', SearchParams, params => ({ hits: [params.query] }));
}
search(query: string) {
return this.sendCustomRequest('acme/search', { query }, { params: SearchParams, result: SearchResult });
}
}
```

Custom handlers share the same dispatch path as standard handlers — context, cancellation, task delivery, and error wrapping all apply. Passing a `{ params, result }` schema bundle to `sendCustomRequest` (or `{ params }` to `sendCustomNotification`) validates outbound params
before sending and gives typed `params`; passing a bare result schema sends params unvalidated.

For larger sub-protocols where neither side is semantically an MCP client or server, prefer composition: hold a `Client` (or `Server`) instance, register custom handlers on it, and expose typed facade methods. See `examples/server/src/customMethodExample.ts` and `examples/client/src/customMethodExample.ts` for runnable examples.

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

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
Expand Down
1 change: 1 addition & 0 deletions examples/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Most clients expect a server to be running. Start one from [`../server/README.md
| Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) |
| URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) |
| Task interactive client | Demonstrates task-based execution + interactive server→client requests. | [`src/simpleTaskInteractiveClient.ts`](src/simpleTaskInteractiveClient.ts) |
| Custom (non-standard) methods client | Sends `acme/*` custom requests and handles custom server notifications. | [`src/customMethodExample.ts`](src/customMethodExample.ts) |

## URL elicitation example (server + client)

Expand Down
80 changes: 80 additions & 0 deletions examples/client/src/customMethodExample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Run with: pnpm tsx src/customMethodExample.ts
//
// Demonstrates sending custom (non-standard) requests and receiving custom
// notifications from the server.
//
// The Protocol class exposes sendCustomRequest / setCustomNotificationHandler for
// vendor-specific methods that are not part of the MCP spec. The schema-bundle
// overload of sendCustomRequest gives typed params with pre-send validation.
//
// Pair with: examples/server/src/customMethodExample.ts (start the server first).

import { Client, ProtocolError, ProtocolErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { z } from 'zod';

const SearchParamsSchema = z.object({
query: z.string(),
limit: z.number().int().positive().optional()
});

const SearchResultSchema = z.object({
results: z.array(z.object({ id: z.string(), title: z.string() })),
total: z.number()
});

const AnalyticsResultSchema = z.object({ recorded: z.boolean() });

const StatusUpdateParamsSchema = z.object({
status: z.enum(['idle', 'busy', 'error']),
detail: z.string().optional()
});

const serverUrl = process.argv[2] ?? 'http://localhost:3000/mcp';

async function main(): Promise<void> {
const client = new Client({ name: 'custom-method-client', version: '1.0.0' });

// Register handler for custom server→client notifications before connecting.
client.setCustomNotificationHandler('acme/statusUpdate', StatusUpdateParamsSchema, params => {
console.log(`[client] acme/statusUpdate status=${params.status} detail=${params.detail ?? '<none>'}`);
});

const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
await client.connect(transport);
console.log(`[client] connected to ${serverUrl}`);

// Schema-bundle overload: typed params + pre-send validation, typed result.
const searchResult = await client.sendCustomRequest(
'acme/search',
{ query: 'widgets', limit: 5 },
{ params: SearchParamsSchema, result: SearchResultSchema }
);
console.log(`[client] acme/search → ${searchResult.total} results, first: "${searchResult.results[0]?.title}"`);

// Loose overload: bare result schema, untyped params.
const analyticsResult = await client.sendCustomRequest('acme/analytics', { event: 'page_view' }, AnalyticsResultSchema);
console.log(`[client] acme/analytics → recorded=${analyticsResult.recorded}`);

// Pre-send validation: schema-bundle overload rejects bad params before the round-trip.
try {
await client.sendCustomRequest(
'acme/search',
{ query: 'widgets', limit: 'five' } as unknown as z.output<typeof SearchParamsSchema>,
{ params: SearchParamsSchema, result: SearchResultSchema }
);
console.error('[client] expected validation error but request succeeded');
} catch (error) {
const code = error instanceof ProtocolError && error.code === ProtocolErrorCode.InvalidParams ? 'InvalidParams' : 'unknown';
console.log(`[client] pre-send validation error (expected, ${code}): ${(error as Error).message}`);
}

await transport.close();
}

try {
await main();
} catch (error) {
console.error('[client] error:', error);
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
}
1 change: 1 addition & 0 deletions examples/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pnpm tsx src/simpleStreamableHttp.ts
| Task interactive server | Task-based execution with interactive server→client requests. | [`src/simpleTaskInteractive.ts`](src/simpleTaskInteractive.ts) |
| Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) |
| SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) |
| Custom (non-standard) methods server | Registers `acme/*` custom request handlers and sends custom notifications. | [`src/customMethodExample.ts`](src/customMethodExample.ts) |

## OAuth demo flags (Streamable HTTP server)

Expand Down
123 changes: 123 additions & 0 deletions examples/server/src/customMethodExample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Run with: pnpm tsx src/customMethodExample.ts
//
// Demonstrates registering handlers for custom (non-standard) request methods
// and sending custom notifications back to the client.
//
// The Protocol class exposes setCustomRequestHandler / sendCustomNotification for
// vendor-specific methods that are not part of the MCP spec. Params are validated
// against user-provided Zod schemas, and handlers receive the same context
// (cancellation, bidirectional send/notify) as standard handlers.
//
// Pair with: examples/client/src/customMethodExample.ts

import { randomUUID } from 'node:crypto';

import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import { isInitializeRequest, Server } from '@modelcontextprotocol/server';
import type { Request, Response } from 'express';
import { z } from 'zod';

Check warning on line 19 in examples/server/src/customMethodExample.ts

View check run for this annotation

Claude / Claude Code Review

Examples use bare zod import instead of zod/v4

nit: these two new example files use `import { z } from 'zod'` while every other example in the repo (and the SDK internals) uses `import * as z from 'zod/v4'`. Consider switching to `import * as z from 'zod/v4'` here and in `examples/client/src/customMethodExample.ts:13` for consistency with the codebase convention.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit: these two new example files use import { z } from 'zod' while every other example in the repo (and the SDK internals) uses import * as z from 'zod/v4'. Consider switching to import * as z from 'zod/v4' here and in examples/client/src/customMethodExample.ts:13 for consistency with the codebase convention.

Extended reasoning...

What the issue is

The two new example files introduced in this PR — examples/server/src/customMethodExample.ts:19 and examples/client/src/customMethodExample.ts:13 — import zod via import { z } from 'zod'. Every other example file in the repository (9 files total: simpleStreamableHttp.ts, mcpServerOutputSchema.ts, etc.) and the SDK internals use import * as z from 'zod/v4'. CLAUDE.md §Zod Schemas explicitly states "The SDK uses zod/v4 internally", and docs/migration.md shows import * as z from 'zod/v4' in its v2 examples.

Why it matters (mildly)

The bare 'zod' specifier and the 'zod/v4' subpath are not guaranteed to resolve identically. Zod 4.x ships both entry points and they currently re-export the same module, so this works fine at runtime in this repo. However, in a downstream project that has both zod v3 and v4 installed (e.g. via transitive deps), the bare 'zod' specifier may resolve to v3 while 'zod/v4' pins to v4. Since these are example files that users copy-paste, propagating an inconsistent import style undermines the convention the rest of the codebase establishes.

Why this isn't a functional bug

This is purely a consistency/style issue in example code. The repo's own zod dependency is v4, so both import forms resolve to the same module here, and the examples build and run correctly. No SDK behavior is affected.

How to fix

Change line 19 of examples/server/src/customMethodExample.ts and line 13 of examples/client/src/customMethodExample.ts from:

import { z } from 'zod';

to:

import * as z from 'zod/v4';

Step-by-step proof

  1. grep -rn "from 'zod" examples/ shows 11 zod imports across the examples directory.
  2. 9 of them (all pre-existing files) use import * as z from 'zod/v4'.
  3. The 2 new files in this PR are the only ones using import { z } from 'zod'.
  4. grep -rn "from 'zod'" packages/ shows zero matches in SDK source — internals exclusively use 'zod/v4' (e.g. packages/core/src/types/schemas.ts:1, packages/core/src/util/schema.ts:6).
  5. Therefore the two new files are the sole deviation from an otherwise repo-wide convention.


const SearchParamsSchema = z.object({
query: z.string(),
limit: z.number().int().positive().optional()
});

const AnalyticsParamsSchema = z.object({
event: z.string(),
properties: z.record(z.string(), z.unknown()).optional()
});

const getServer = () => {
const server = new Server({ name: 'custom-method-server', version: '1.0.0' }, { capabilities: {} });

server.setCustomRequestHandler('acme/search', SearchParamsSchema, async (params, ctx) => {
console.log(`[server] acme/search query="${params.query}" limit=${params.limit ?? 'unset'} (req ${ctx.mcpReq.id})`);

// Send a custom server→client notification on the same SSE stream as this response
// (relatedRequestId routes it to the request's stream rather than the standalone SSE stream).
await server.sendCustomNotification(
'acme/statusUpdate',
{ status: 'busy', detail: `searching "${params.query}"` },
{ relatedRequestId: ctx.mcpReq.id }
);

return {
results: [
{ id: 'r1', title: `Result for "${params.query}"` },
{ id: 'r2', title: 'Another result' }
],
total: 2
};
});

server.setCustomRequestHandler('acme/analytics', AnalyticsParamsSchema, async params => {
console.log(`[server] acme/analytics event="${params.event}"`);
return { recorded: true };
});

return server;
};

const PORT = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000;
const app = createMcpExpressApp();
const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {};

app.post('/mcp', async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
try {
let transport: NodeStreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {
transport = transports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: sid => {
transports[sid] = transport;
}
});
transport.onclose = () => {
const sid = transport.sessionId;
if (sid) delete transports[sid];
};
const server = getServer();
await server.connect(transport);
} else {
res.status(400).json({ jsonrpc: '2.0', error: { code: -32_000, message: 'No valid session ID' }, id: null });
return;
}
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('Error handling MCP request:', error);
if (!res.headersSent) {
res.status(500).json({ jsonrpc: '2.0', error: { code: -32_603, message: 'Internal server error' }, id: null });
}
}
});

const handleSessionRequest = async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}
await transports[sessionId].handleRequest(req, res);
};

app.get('/mcp', handleSessionRequest);
app.delete('/mcp', handleSessionRequest);

app.listen(PORT, error => {
if (error) {
console.error('Failed to start server:', error);
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
}
console.log(`Custom-method example server listening on http://localhost:${PORT}/mcp`);
console.log('Custom methods: acme/search, acme/analytics');
});

process.on('SIGINT', async () => {
for (const sid in transports) await transports[sid]!.close();
process.exit(0);
});
Loading
Loading