Skip to content

Commit 284d8e6

Browse files
feat(core): add custom request/notification handler API to Protocol
Adds setCustomRequestHandler, setCustomNotificationHandler, sendCustomRequest, sendCustomNotification (plus remove* variants) to the Protocol class. These allow registering handlers for vendor-specific methods outside the standard RequestMethod/NotificationMethod unions, with user-provided Zod schemas for param/result validation. Custom handlers share the existing _requestHandlers map and dispatch path, so they receive full context (cancellation, task support, send/notify) for free. Capability checks are skipped for custom methods. Also exports InMemoryTransport from core/public so examples and tests can use createLinkedPair() without depending on the internal core barrel, and adds examples/server/src/customMethodExample.ts demonstrating the API.
1 parent 866c08d commit 284d8e6

File tree

5 files changed

+182
-47
lines changed

5 files changed

+182
-47
lines changed

examples/server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
},
3434
"dependencies": {
3535
"@hono/node-server": "catalog:runtimeServerOnly",
36+
"@modelcontextprotocol/client": "workspace:^",
3637
"@modelcontextprotocol/examples-shared": "workspace:^",
3738
"@modelcontextprotocol/express": "workspace:^",
3839
"@modelcontextprotocol/hono": "workspace:^",
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Demonstrates custom (non-standard) request and notification methods.
4+
*
5+
* The Protocol class exposes setCustomRequestHandler / setCustomNotificationHandler /
6+
* sendCustomRequest / sendCustomNotification for vendor-specific methods that are not
7+
* part of the MCP spec. Params and results are validated against user-provided Zod
8+
* schemas, and handlers receive the same context (cancellation, task support,
9+
* bidirectional send/notify) as standard handlers.
10+
*/
11+
12+
import { Client } from '@modelcontextprotocol/client';
13+
import { InMemoryTransport, Server } from '@modelcontextprotocol/server';
14+
import { z } from 'zod';
15+
16+
const SearchParamsSchema = z.object({
17+
query: z.string(),
18+
limit: z.number().int().positive().optional()
19+
});
20+
21+
const SearchResultSchema = z.object({
22+
results: z.array(z.object({ id: z.string(), title: z.string() })),
23+
total: z.number()
24+
});
25+
26+
const AnalyticsParamsSchema = z.object({
27+
event: z.string(),
28+
properties: z.record(z.string(), z.unknown()).optional()
29+
});
30+
31+
const AnalyticsResultSchema = z.object({ recorded: z.boolean() });
32+
33+
const StatusUpdateParamsSchema = z.object({
34+
status: z.enum(['idle', 'busy', 'error']),
35+
detail: z.string().optional()
36+
});
37+
38+
async function main() {
39+
const server = new Server({ name: 'custom-method-server', version: '1.0.0' }, { capabilities: {} });
40+
const client = new Client({ name: 'custom-method-client', version: '1.0.0' }, { capabilities: {} });
41+
42+
server.setCustomRequestHandler('acme/search', SearchParamsSchema, async (params, ctx) => {
43+
console.log(`[server] acme/search query="${params.query}" limit=${params.limit ?? 'unset'} (req ${ctx.mcpReq.id})`);
44+
return {
45+
results: [
46+
{ id: 'r1', title: `Result for "${params.query}"` },
47+
{ id: 'r2', title: 'Another result' }
48+
],
49+
total: 2
50+
};
51+
});
52+
53+
server.setCustomRequestHandler('acme/analytics', AnalyticsParamsSchema, async params => {
54+
console.log(`[server] acme/analytics event="${params.event}"`);
55+
return { recorded: true };
56+
});
57+
58+
client.setCustomNotificationHandler('acme/statusUpdate', StatusUpdateParamsSchema, params => {
59+
console.log(`[client] acme/statusUpdate status=${params.status} detail=${params.detail ?? '<none>'}`);
60+
});
61+
62+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
63+
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
64+
65+
const searchResult = await client.sendCustomRequest('acme/search', { query: 'widgets', limit: 5 }, SearchResultSchema);
66+
console.log(`[client] received ${searchResult.total} results, first: "${searchResult.results[0]?.title}"`);
67+
68+
const analyticsResult = await client.sendCustomRequest('acme/analytics', { event: 'page_view' }, AnalyticsResultSchema);
69+
console.log(`[client] analytics recorded=${analyticsResult.recorded}`);
70+
71+
await server.sendCustomNotification('acme/statusUpdate', { status: 'busy', detail: 'indexing' });
72+
73+
// Validation error: wrong param type (limit must be a number)
74+
try {
75+
await client.sendCustomRequest('acme/search', { query: 'widgets', limit: 'five' }, SearchResultSchema);
76+
console.error('[client] expected validation error but request succeeded');
77+
} catch (error) {
78+
console.log(`[client] validation error (expected): ${(error as Error).message}`);
79+
}
80+
81+
await client.close();
82+
await server.close();
83+
}
84+
85+
await main();

packages/core/src/exports/public/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ export type {
133133
export { isTerminal } from '../../experimental/tasks/interfaces.js';
134134
export { InMemoryTaskMessageQueue, InMemoryTaskStore } from '../../experimental/tasks/stores/inMemory.js';
135135

136+
// Transport utilities
137+
export { InMemoryTransport } from '../../util/inMemory.js';
138+
136139
// Validator types and classes
137140
export type { StandardSchemaWithJSON } from '../../util/standardSchema.js';
138141
export { AjvJsonSchemaValidator } from '../../validators/ajvProvider.js';

packages/core/src/shared/protocol.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1057,6 +1057,96 @@ export abstract class Protocol<ContextT extends BaseContext> {
10571057
removeNotificationHandler(method: NotificationMethod): void {
10581058
this._notificationHandlers.delete(method);
10591059
}
1060+
1061+
/**
1062+
* Registers a handler for a custom (non-standard) request method.
1063+
*
1064+
* Unlike {@linkcode Protocol.setRequestHandler | setRequestHandler}, this accepts any method
1065+
* string and validates incoming params against a user-provided schema instead of an SDK-defined
1066+
* one. Capability checks are skipped. The handler receives the same {@linkcode BaseContext | context}
1067+
* as standard handlers, including cancellation, task support, and bidirectional send/notify.
1068+
*/
1069+
setCustomRequestHandler<P extends AnySchema>(
1070+
method: string,
1071+
paramsSchema: P,
1072+
handler: (params: SchemaOutput<P>, ctx: ContextT) => Result | Promise<Result>
1073+
): void {
1074+
this._requestHandlers.set(method, (request, ctx) => {
1075+
const parsed = parseSchema(paramsSchema, request.params);
1076+
if (!parsed.success) {
1077+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`);
1078+
}
1079+
return Promise.resolve(handler(parsed.data, ctx));
1080+
});
1081+
}
1082+
1083+
/**
1084+
* Removes a custom request handler previously registered with
1085+
* {@linkcode Protocol.setCustomRequestHandler | setCustomRequestHandler}.
1086+
*/
1087+
removeCustomRequestHandler(method: string): void {
1088+
this._requestHandlers.delete(method);
1089+
}
1090+
1091+
/**
1092+
* Registers a handler for a custom (non-standard) notification method.
1093+
*
1094+
* Unlike {@linkcode Protocol.setNotificationHandler | setNotificationHandler}, this accepts any
1095+
* method string and validates incoming params against a user-provided schema instead of an
1096+
* SDK-defined one.
1097+
*/
1098+
setCustomNotificationHandler<P extends AnySchema>(
1099+
method: string,
1100+
paramsSchema: P,
1101+
handler: (params: SchemaOutput<P>) => void | Promise<void>
1102+
): void {
1103+
this._notificationHandlers.set(method, notification => {
1104+
const parsed = parseSchema(paramsSchema, notification.params);
1105+
if (!parsed.success) {
1106+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`);
1107+
}
1108+
return Promise.resolve(handler(parsed.data));
1109+
});
1110+
}
1111+
1112+
/**
1113+
* Removes a custom notification handler previously registered with
1114+
* {@linkcode Protocol.setCustomNotificationHandler | setCustomNotificationHandler}.
1115+
*/
1116+
removeCustomNotificationHandler(method: string): void {
1117+
this._notificationHandlers.delete(method);
1118+
}
1119+
1120+
/**
1121+
* Sends a custom (non-standard) request and waits for a response, validating the result against
1122+
* the provided schema.
1123+
*
1124+
* Unlike {@linkcode Protocol.request | request}, this accepts any method string. Capability
1125+
* checks are bypassed when {@linkcode ProtocolOptions.enforceStrictCapabilities} is disabled
1126+
* (the default).
1127+
*/
1128+
sendCustomRequest<T extends AnySchema>(
1129+
method: string,
1130+
params: Record<string, unknown> | undefined,
1131+
resultSchema: T,
1132+
options?: RequestOptions
1133+
): Promise<SchemaOutput<T>> {
1134+
return this._requestWithSchema({ method, params } as Request, resultSchema, options);
1135+
}
1136+
1137+
/**
1138+
* Sends a custom (non-standard) notification.
1139+
*
1140+
* Unlike {@linkcode Protocol.notification | notification}, this accepts any method string and
1141+
* bypasses capability checks by sending directly via the transport.
1142+
*/
1143+
async sendCustomNotification(method: string, params?: Record<string, unknown>, options?: NotificationOptions): Promise<void> {
1144+
if (!this._transport) {
1145+
throw new SdkError(SdkErrorCode.NotConnected, 'Not connected');
1146+
}
1147+
const jsonrpcNotification: JSONRPCNotification = { jsonrpc: '2.0', method, params };
1148+
await this._transport.send(jsonrpcNotification, options);
1149+
}
10601150
}
10611151

10621152
function isPlainObject(value: unknown): value is Record<string, unknown> {

0 commit comments

Comments
 (0)