Skip to content

Commit 683fe35

Browse files
feat(core): harden custom method handlers for production
- Guard setCustom*/removeCustom* against standard MCP method names (throws directing users to setRequestHandler/setNotificationHandler) - Add isRequestMethod/isNotificationMethod runtime predicates - Add comprehensive unit tests (15 cases) for all 6 custom-method APIs - Add ext-apps style example demonstrating mcp-ui/* methods and DOM-style event listeners built on setCustomNotificationHandler - Add @modelcontextprotocol/client path mapping to examples/server tsconfig so the example resolves source instead of dist
1 parent 284d8e6 commit 683fe35

File tree

5 files changed

+419
-0
lines changed

5 files changed

+419
-0
lines changed
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Demonstrates that the ext-apps (mcp-ui) pattern is fully implementable on top of the v2
4+
* SDK's custom-method-handler API, without extending Protocol or relying on the v1 generic
5+
* type parameters.
6+
*
7+
* In v1, ext-apps defined `class ProtocolWithEvents<...> extends Protocol<SendRequestT, ...>` to
8+
* widen the request/notification type unions. In v2, the same is achieved by composing
9+
* setCustomRequestHandler / setCustomNotificationHandler / sendCustomRequest / sendCustomNotification
10+
* on top of the standard Client and Server classes.
11+
*/
12+
13+
import { Client } from '@modelcontextprotocol/client';
14+
import { InMemoryTransport, Server } from '@modelcontextprotocol/server';
15+
import { z } from 'zod';
16+
17+
// ───────────────────────────────────────────────────────────────────────────────
18+
// Custom method schemas (mirror the ext-apps spec.types.ts pattern)
19+
// ───────────────────────────────────────────────────────────────────────────────
20+
21+
const InitializeParams = z.object({
22+
protocolVersion: z.string(),
23+
appInfo: z.object({ name: z.string(), version: z.string() })
24+
});
25+
const InitializeResult = z.object({
26+
protocolVersion: z.string(),
27+
hostInfo: z.object({ name: z.string(), version: z.string() }),
28+
hostContext: z.object({ theme: z.enum(['light', 'dark']), locale: z.string() })
29+
});
30+
31+
const OpenLinkParams = z.object({ url: z.url() });
32+
const OpenLinkResult = z.object({ opened: z.boolean() });
33+
34+
const TeardownParams = z.object({ reason: z.string().optional() });
35+
36+
const SizeChangedParams = z.object({ width: z.number(), height: z.number() });
37+
const ToolResultParams = z.object({ toolName: z.string(), content: z.array(z.object({ type: z.string(), text: z.string() })) });
38+
const HostContextChangedParams = z.object({ theme: z.enum(['light', 'dark']).optional(), locale: z.string().optional() });
39+
40+
type AppEventMap = {
41+
toolresult: z.infer<typeof ToolResultParams>;
42+
hostcontextchanged: z.infer<typeof HostContextChangedParams>;
43+
};
44+
45+
// ───────────────────────────────────────────────────────────────────────────────
46+
// App: wraps Client, exposes typed mcp-ui/* methods + DOM-style events
47+
// (replaces v1's `class App extends ProtocolWithEvents<AppRequest, AppNotification, AppResult, AppEventMap>`)
48+
// ───────────────────────────────────────────────────────────────────────────────
49+
50+
class App {
51+
readonly client: Client;
52+
private _listeners: { [K in keyof AppEventMap]: ((p: AppEventMap[K]) => void)[] } = {
53+
toolresult: [],
54+
hostcontextchanged: []
55+
};
56+
private _hostContext?: z.infer<typeof InitializeResult>['hostContext'];
57+
58+
onTeardown?: (params: z.infer<typeof TeardownParams>) => void | Promise<void>;
59+
60+
constructor(appInfo: { name: string; version: string }) {
61+
this.client = new Client(appInfo, { capabilities: {} });
62+
63+
// Incoming custom request from host
64+
this.client.setCustomRequestHandler('mcp-ui/resourceTeardown', TeardownParams, async params => {
65+
await this.onTeardown?.(params);
66+
return {};
67+
});
68+
69+
// Incoming custom notifications from host -> DOM-style event slots
70+
this.client.setCustomNotificationHandler('mcp-ui/toolResult', ToolResultParams, p => this._dispatch('toolresult', p));
71+
this.client.setCustomNotificationHandler('mcp-ui/hostContextChanged', HostContextChangedParams, p => {
72+
this._hostContext = { ...this._hostContext!, ...p };
73+
this._dispatch('hostcontextchanged', p);
74+
});
75+
}
76+
77+
addEventListener<K extends keyof AppEventMap>(event: K, listener: (p: AppEventMap[K]) => void): void {
78+
this._listeners[event].push(listener);
79+
}
80+
81+
removeEventListener<K extends keyof AppEventMap>(event: K, listener: (p: AppEventMap[K]) => void): void {
82+
const arr = this._listeners[event];
83+
const i = arr.indexOf(listener);
84+
if (i !== -1) arr.splice(i, 1);
85+
}
86+
87+
private _dispatch<K extends keyof AppEventMap>(event: K, params: AppEventMap[K]): void {
88+
for (const l of this._listeners[event]) l(params);
89+
}
90+
91+
async connect(transport: Parameters<Client['connect']>[0]): Promise<void> {
92+
await this.client.connect(transport);
93+
const result = await this.client.sendCustomRequest(
94+
'mcp-ui/initialize',
95+
{ protocolVersion: '2026-01-26', appInfo: { name: 'demo-app', version: '1.0.0' } },
96+
InitializeResult
97+
);
98+
this._hostContext = result.hostContext;
99+
await this.client.sendCustomNotification('mcp-ui/initialized', {});
100+
}
101+
102+
getHostContext() {
103+
return this._hostContext;
104+
}
105+
106+
openLink(url: string) {
107+
return this.client.sendCustomRequest('mcp-ui/openLink', { url }, OpenLinkResult);
108+
}
109+
110+
notifySizeChanged(width: number, height: number) {
111+
return this.client.sendCustomNotification('mcp-ui/sizeChanged', { width, height });
112+
}
113+
}
114+
115+
// ───────────────────────────────────────────────────────────────────────────────
116+
// Host: wraps Server, handles mcp-ui/* requests and emits mcp-ui/* notifications
117+
// ───────────────────────────────────────────────────────────────────────────────
118+
119+
class Host {
120+
readonly server: Server;
121+
onSizeChanged?: (p: z.infer<typeof SizeChangedParams>) => void;
122+
123+
constructor() {
124+
this.server = new Server({ name: 'demo-host', version: '1.0.0' }, { capabilities: {} });
125+
126+
this.server.setCustomRequestHandler('mcp-ui/initialize', InitializeParams, params => {
127+
console.log(`[host] mcp-ui/initialize from ${params.appInfo.name}@${params.appInfo.version}`);
128+
return {
129+
protocolVersion: params.protocolVersion,
130+
hostInfo: { name: 'demo-host', version: '1.0.0' },
131+
hostContext: { theme: 'dark', locale: 'en-US' }
132+
};
133+
});
134+
135+
this.server.setCustomRequestHandler('mcp-ui/openLink', OpenLinkParams, params => {
136+
console.log(`[host] mcp-ui/openLink url=${params.url}`);
137+
return { opened: true };
138+
});
139+
140+
this.server.setCustomNotificationHandler('mcp-ui/initialized', z.object({}).optional(), () => {
141+
console.log('[host] mcp-ui/initialized');
142+
});
143+
144+
this.server.setCustomNotificationHandler('mcp-ui/sizeChanged', SizeChangedParams, p => {
145+
console.log(`[host] mcp-ui/sizeChanged ${p.width}x${p.height}`);
146+
this.onSizeChanged?.(p);
147+
});
148+
}
149+
150+
notifyToolResult(toolName: string, text: string) {
151+
return this.server.sendCustomNotification('mcp-ui/toolResult', {
152+
toolName,
153+
content: [{ type: 'text', text }]
154+
});
155+
}
156+
157+
notifyHostContextChanged(patch: z.infer<typeof HostContextChangedParams>) {
158+
return this.server.sendCustomNotification('mcp-ui/hostContextChanged', patch);
159+
}
160+
161+
requestTeardown(reason: string) {
162+
return this.server.sendCustomRequest('mcp-ui/resourceTeardown', { reason }, z.object({}));
163+
}
164+
}
165+
166+
// ───────────────────────────────────────────────────────────────────────────────
167+
// Demo
168+
// ───────────────────────────────────────────────────────────────────────────────
169+
170+
async function main() {
171+
const host = new Host();
172+
const app = new App({ name: 'demo-app', version: '1.0.0' });
173+
174+
app.addEventListener('toolresult', p => console.log(`[app] toolresult: ${p.toolName} -> "${p.content[0]?.text}"`));
175+
app.addEventListener('hostcontextchanged', p => console.log(`[app] hostcontextchanged: ${JSON.stringify(p)}`));
176+
app.onTeardown = p => console.log(`[app] teardown: ${p.reason}`);
177+
host.onSizeChanged = p => console.log(`[host] app resized to ${p.width}x${p.height}`);
178+
179+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
180+
await host.server.connect(serverTransport);
181+
await app.connect(clientTransport);
182+
183+
console.log(`[app] hostContext after init: ${JSON.stringify(app.getHostContext())}`);
184+
185+
// App -> Host: custom request
186+
const { opened } = await app.openLink('https://example.com');
187+
console.log(`[app] openLink -> opened=${opened}`);
188+
189+
// App -> Host: custom notification
190+
await app.notifySizeChanged(800, 600);
191+
192+
// Host -> App: custom notifications (DOM-style event listeners fire)
193+
await host.notifyToolResult('search', 'found 3 widgets');
194+
await host.notifyHostContextChanged({ theme: 'light' });
195+
console.log(`[app] hostContext after change: ${JSON.stringify(app.getHostContext())}`);
196+
197+
// Host -> App: custom request
198+
await host.requestTeardown('navigation');
199+
200+
await app.client.close();
201+
await host.server.close();
202+
}
203+
204+
await main();

examples/server/tsconfig.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
"*": ["./*"],
88
"@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"],
99
"@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"],
10+
"@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"],
11+
"@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"],
1012
"@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"],
1113
"@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"],
1214
"@modelcontextprotocol/hono": ["./node_modules/@modelcontextprotocol/hono/src/index.ts"],

packages/core/src/shared/protocol.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ import {
4040
isJSONRPCNotification,
4141
isJSONRPCRequest,
4242
isJSONRPCResultResponse,
43+
isNotificationMethod,
44+
isRequestMethod,
4345
ProtocolError,
4446
ProtocolErrorCode,
4547
SUPPORTED_PROTOCOL_VERSIONS
@@ -1071,6 +1073,9 @@ export abstract class Protocol<ContextT extends BaseContext> {
10711073
paramsSchema: P,
10721074
handler: (params: SchemaOutput<P>, ctx: ContextT) => Result | Promise<Result>
10731075
): void {
1076+
if (isRequestMethod(method)) {
1077+
throw new Error(`"${method}" is a standard MCP request method. Use setRequestHandler() instead.`);
1078+
}
10741079
this._requestHandlers.set(method, (request, ctx) => {
10751080
const parsed = parseSchema(paramsSchema, request.params);
10761081
if (!parsed.success) {
@@ -1085,6 +1090,9 @@ export abstract class Protocol<ContextT extends BaseContext> {
10851090
* {@linkcode Protocol.setCustomRequestHandler | setCustomRequestHandler}.
10861091
*/
10871092
removeCustomRequestHandler(method: string): void {
1093+
if (isRequestMethod(method)) {
1094+
throw new Error(`"${method}" is a standard MCP request method. Use removeRequestHandler() instead.`);
1095+
}
10881096
this._requestHandlers.delete(method);
10891097
}
10901098

@@ -1100,6 +1108,9 @@ export abstract class Protocol<ContextT extends BaseContext> {
11001108
paramsSchema: P,
11011109
handler: (params: SchemaOutput<P>) => void | Promise<void>
11021110
): void {
1111+
if (isNotificationMethod(method)) {
1112+
throw new Error(`"${method}" is a standard MCP notification method. Use setNotificationHandler() instead.`);
1113+
}
11031114
this._notificationHandlers.set(method, notification => {
11041115
const parsed = parseSchema(paramsSchema, notification.params);
11051116
if (!parsed.success) {
@@ -1114,6 +1125,9 @@ export abstract class Protocol<ContextT extends BaseContext> {
11141125
* {@linkcode Protocol.setCustomNotificationHandler | setCustomNotificationHandler}.
11151126
*/
11161127
removeCustomNotificationHandler(method: string): void {
1128+
if (isNotificationMethod(method)) {
1129+
throw new Error(`"${method}" is a standard MCP notification method. Use removeNotificationHandler() instead.`);
1130+
}
11171131
this._notificationHandlers.delete(method);
11181132
}
11191133

packages/core/src/types/schemas.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2209,6 +2209,20 @@ const notificationSchemas = buildSchemaMap([...ClientNotificationSchema.options,
22092209
NotificationSchemaType
22102210
>;
22112211

2212+
/**
2213+
* Type predicate: returns true if `method` is a standard MCP request method.
2214+
*/
2215+
export function isRequestMethod(method: string): method is RequestMethod {
2216+
return method in requestSchemas;
2217+
}
2218+
2219+
/**
2220+
* Type predicate: returns true if `method` is a standard MCP notification method.
2221+
*/
2222+
export function isNotificationMethod(method: string): method is NotificationMethod {
2223+
return method in notificationSchemas;
2224+
}
2225+
22122226
/**
22132227
* Gets the Zod schema for a given request method.
22142228
* The return type is a ZodType that parses to RequestTypeMap[M], allowing callers

0 commit comments

Comments
 (0)