Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
141b69b
feat(core): add custom request/notification handler API to Protocol
felixweinberger Apr 2, 2026
ab148e5
feat(core): harden custom method handlers for production
felixweinberger Apr 2, 2026
a12b19f
fix: use Object.hasOwn in isRequestMethod/isNotificationMethod to avo…
felixweinberger Apr 2, 2026
96bbd2f
feat(core): route sendCustomNotification through notification(); add …
felixweinberger Apr 2, 2026
fc5d10c
docs: fix typedoc link warnings in custom method handler JSDoc
felixweinberger Apr 2, 2026
4a2bf03
fix(core): exclude StandardSchema values from isSchemaBundle discrimi…
felixweinberger Apr 2, 2026
07c5491
docs: minimize migration doc diff to custom-methods sections only
felixweinberger Apr 9, 2026
98d7742
docs(core): clarify schema-bundle overload validates only; add test
felixweinberger Apr 9, 2026
89db8c6
refactor(examples): split customMethod example into server/client pai…
felixweinberger Apr 9, 2026
3575410
fix(core): strip _meta before custom-handler schema validation; route…
felixweinberger Apr 9, 2026
b52bc34
fix(core): use SdkError(NotConnected) in request() to match notificat…
felixweinberger Apr 9, 2026
cdaca38
docs(core): note params normalization to {} in setCustom* handlers
felixweinberger Apr 13, 2026
e4cb1a9
feat(core): accept StandardSchemaV1 in setCustom*/sendCustom* (not ju…
felixweinberger Apr 13, 2026
236c072
feat(core): make Protocol concrete and exported with ProtocolSpec gen…
felixweinberger Apr 14, 2026
92a859d
feat(core): wire ProtocolSpec typed overloads + clean up casts
felixweinberger Apr 14, 2026
db49955
fix(core): typed-S overload types handler from passed schema; Client/…
felixweinberger Apr 14, 2026
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
7 changes: 7 additions & 0 deletions .changeset/protocol-concrete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/server': minor
---

Make `Protocol` concrete and exported. `setRequestHandler`/`setNotificationHandler`/`request`/`notification` gain a 3-arg `(method: string, paramsSchema, handler)` overload for non-standard methods alongside the spec-typed form. Optional `Protocol<S extends ProtocolSpec>` generic
for declaring a typed method vocabulary. The five `assert*Capability` abstracts are now no-op virtuals (`Client`/`Server` override to enforce).
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 — use the three-arg overloads of `setRequestHandler`/`setNotificationHandler`/`request`/`notification`, which accept any method string with a caller-supplied params/result schema. `Protocol` is now concrete and exported, so MCP-dialect protocols can subclass it directly:

| v1 | v2 |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| `setRequestHandler(CustomReqSchema, (req, extra) => ...)` | `setRequestHandler('vendor/method', ParamsSchema, (params, ctx) => ...)` |
| `setNotificationHandler(CustomNotifSchema, n => ...)` | `setNotificationHandler('vendor/method', ParamsSchema, params => ...)` |
| `this.request({ method: 'vendor/x', params }, ResultSchema)` | `this.request('vendor/x', params, ResultSchema)` |
| `this.notification({ method: 'vendor/x', params })` | `this.notification('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
46 changes: 46 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,52 @@ Common method string replacements:
| `ResourceListChangedNotificationSchema` | `'notifications/resources/list_changed'` |
| `PromptListChangedNotificationSchema` | `'notifications/prompts/list_changed'` |

### Custom (non-standard) protocol methods

In v1, vendor-specific methods were registered the same way as spec methods (`setRequestHandler(zodSchemaWithMethodLiteral, handler)`), and `Protocol<SendRequestT, SendNotificationT, SendResultT>` widened the send-side type unions.

In v2, `Protocol` is concrete and exported. Its `setRequestHandler`, `setNotificationHandler`, `request` and `notification` each have a string-method overload that accepts any method name with a caller-supplied params/result schema:

**Before (v1):**

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

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 { Protocol, type ProtocolSpec } from '@modelcontextprotocol/client';

type AppSpec = {
requests: { 'acme/search': { params: { query: string }; result: { hits: string[] } } };
} satisfies ProtocolSpec;

class App extends Protocol<AppSpec> {
constructor() {
super();
this.setRequestHandler('acme/search', SearchParams, params => ({ hits: [params.query] }));
}
search(query: string) {
return this.request('acme/search', { query }, SearchResult);
}
}
```

The `ProtocolSpec` type argument is optional — omit it for ad-hoc method strings without autocomplete. For a single vendor method on a stock `Client` or `Server`, call the three-arg overload directly: `server.setRequestHandler('acme/search', SearchParams, handler)`.

The five `assert*Capability` abstract methods are now no-op virtuals on `Protocol`, so subclasses no longer need to stub them.

### `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
47 changes: 47 additions & 0 deletions examples/client/src/customMethodExample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env node
/**
* Demonstrates calling a vendor method via the three-arg `client.request(method, params, resultSchema)`
* overload and registering a vendor notification handler with the three-arg `setNotificationHandler`
* overload.
*
* Pair with: examples/server/src/customMethodExample.ts (which contains the in-memory pair).
*/

import { Client, InMemoryTransport, Protocol } from '@modelcontextprotocol/client';
import { z } from 'zod';

const SearchParams = z.object({ query: z.string() });
const SearchResult = z.object({ hits: z.array(z.string()) });
const ProgressParams = z.object({ stage: z.string(), pct: z.number() });

async function main() {
const peer = new Protocol();
peer.setRequestHandler('acme/search', SearchParams, async p => {
await peer.notification('acme/searchProgress', { stage: 'started', pct: 0 });
await peer.notification('acme/searchProgress', { stage: 'done', pct: 100 });
return { hits: [p.query, p.query + '-2'] };
});
// The bare Protocol must respond to MCP initialize so Client.connect() completes.
peer.setRequestHandler('initialize', req => ({
protocolVersion: req.params.protocolVersion,
capabilities: {},
serverInfo: { name: 'peer', version: '1.0.0' }
}));

const client = new Client({ name: 'c', version: '1.0.0' }, { capabilities: {} });
client.setNotificationHandler('acme/searchProgress', ProgressParams, p => {
console.log(`[client] progress: ${p.stage} ${p.pct}%`);
});

const [t1, t2] = InMemoryTransport.createLinkedPair();
await peer.connect(t2);
await client.connect(t1);

const r = await client.request('acme/search', { query: 'widgets' }, SearchResult);
console.log('[client] hits=' + JSON.stringify(r.hits));

await client.close();
await peer.close();
}

await main();
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
47 changes: 47 additions & 0 deletions examples/server/src/customMethodExample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env node
/**
* Demonstrates registering vendor-specific JSON-RPC methods directly on a stock `Server`, and
* calling them from a bare `Protocol` peer (which is role-agnostic and so can act as the client
* side without depending on the client package).
*/

import { InMemoryTransport, Protocol, Server } from '@modelcontextprotocol/server';
import { z } from 'zod';

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

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

server.setRequestHandler('acme/search', SearchParams, params => {
console.log('[server] acme/search query=' + params.query);
return { hits: [params.query, params.query + '-result'] };
});

server.setNotificationHandler('acme/tick', TickParams, p => {
console.log('[server] acme/tick n=' + p.n);
});

const peer = new Protocol();

const [peerTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await peer.connect(peerTransport);
await peer.request({
method: 'initialize',
params: { protocolVersion: '2025-11-25', clientInfo: { name: 'peer', version: '1.0.0' }, capabilities: {} }
});

const r = await peer.request('acme/search', { query: 'widgets' }, SearchResult);
console.log('[peer] hits=' + JSON.stringify(r.hits));

await peer.notification('acme/tick', { n: 1 });
await peer.notification('acme/tick', { n: 2 });

await peer.close();
await server.close();
}

await main();
134 changes: 134 additions & 0 deletions examples/server/src/customMethodExtAppsExample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/usr/bin/env node
/**
* Demonstrates the ext-apps pattern on top of a concrete, role-agnostic `Protocol<S>`.
*
* `App` and `Host` each extend `Protocol<AppSpec>`. The vocabulary mixes spec-named methods
* (`tools/call`, `notifications/message`) and custom `ui/*` methods on a single object — no
* `ui.*`/`client.*` split, no wire renames, no direction enforcement.
*/

import { InMemoryTransport, Protocol } from '@modelcontextprotocol/server';
import { z } from 'zod';

type AppSpec = {
requests: {
'ui/initialize': {
params: { protocolVersion: string; appInfo: { name: string; version: string } };
result: { hostInfo: { name: string; version: string }; hostContext: { theme: 'light' | 'dark' } };
};
'ui/open-link': { params: { url: string }; result: { opened: boolean } };
'tools/call': { params: { name: string; arguments?: Record<string, unknown> }; result: { content: unknown[] } };
};
notifications: {
'ui/tool-result': { params: { toolName: string; text: string } };
'ui/size-changed': { params: { width: number; height: number } };
'notifications/message': { params: { level: string; data: unknown } };
};
};

const InitializeParams = z.object({
protocolVersion: z.string(),
appInfo: z.object({ name: z.string(), version: z.string() })
});
const InitializeResult = z.object({
hostInfo: z.object({ name: z.string(), version: z.string() }),
hostContext: z.object({ theme: z.enum(['light', 'dark']) })
});
const OpenLinkParams = z.object({ url: z.string() });
const OpenLinkResult = z.object({ opened: z.boolean() });
const CallToolParams = z.object({ name: z.string(), arguments: z.record(z.string(), z.unknown()).optional() });
const CallToolResult = z.object({ content: z.array(z.unknown()) });
const ToolResultParams = z.object({ toolName: z.string(), text: z.string() });
const SizeChangedParams = z.object({ width: z.number(), height: z.number() });
const LogParams = z.object({ level: z.string(), data: z.unknown() });

class App extends Protocol<AppSpec> {
private _hostContext?: { theme: 'light' | 'dark' };

constructor() {
super();
this.setNotificationHandler('ui/tool-result', ToolResultParams, p => {
console.log(`[app] tool-result ${p.toolName}: "${p.text}"`);
});
}

async initialize() {
const r = await this.request(
'ui/initialize',
{ protocolVersion: '2026-01-26', appInfo: { name: 'demo-ui', version: '1.0.0' } },
InitializeResult
);
this._hostContext = r.hostContext;
console.log('[app] hostContext=' + JSON.stringify(this._hostContext));
}

openLink(url: string) {
return this.request('ui/open-link', { url }, OpenLinkResult);
}

callServerTool(name: string, args?: Record<string, unknown>) {
return this.request('tools/call', { name, arguments: args }, CallToolResult);
}

sendLog(level: string, data: unknown) {
return this.notification('notifications/message', { level, data });
}

notifySize(width: number, height: number) {
return this.notification('ui/size-changed', { width, height });
}
}

class Host extends Protocol<AppSpec> {
constructor() {
super();
this.setRequestHandler('ui/initialize', InitializeParams, p => {
console.log(`[host] ui/initialize from ${p.appInfo.name}@${p.appInfo.version}`);
return { hostInfo: { name: 'demo-host', version: '1.0.0' }, hostContext: { theme: 'dark' as const } };
});
this.setRequestHandler('ui/open-link', OpenLinkParams, p => {
console.log('[host] open-link url=' + p.url);
return { opened: true };
});
this.setRequestHandler('tools/call', CallToolParams, p => {
console.log('[host] tools/call name=' + p.name);
return { content: [{ type: 'text', text: `result for ${p.name}` }] };
});
this.setNotificationHandler('ui/size-changed', SizeChangedParams, p => {
console.log(`[host] size-changed ${p.width}x${p.height}`);
});
this.setNotificationHandler('notifications/message', LogParams, p => {
console.log(`[host] log [${p.level}]`, p.data);
});
}

notifyToolResult(toolName: string, text: string) {
return this.notification('ui/tool-result', { toolName, text });
}
}

async function main() {
const [t1, t2] = InMemoryTransport.createLinkedPair();
const host = new Host();
const app = new App();
await host.connect(t2);
await app.connect(t1);

await app.initialize();

const { opened } = await app.openLink('https://example.com');
console.log('[app] openLink -> opened=' + opened);

const tool = await app.callServerTool('weather', { city: 'Tokyo' });
console.log('[app] tools/call ->', tool.content);

await app.sendLog('info', { msg: 'iframe ready' });
await app.notifySize(800, 600);
await host.notifyToolResult('weather', '18°C, rain');

await new Promise(r => setTimeout(r, 0));
await app.close();
await host.close();
}

await main();
Loading
Loading