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';

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