Skip to content

Commit 3575410

Browse files
fix(core): strip _meta before custom-handler schema validation; route example notification to request stream; add changeset
- setCustomRequestHandler/setCustomNotificationHandler now strip _meta from params before validating against the user schema, so .strict() schemas do not reject SDK-injected fields like progressToken. _meta remains available via ctx.mcpReq._meta. Adds regression test. - examples/server/src/customMethodExample.ts: pass relatedRequestId so the acme/statusUpdate notification routes to the request response stream as the comment claims (was going to the standalone SSE stream). - Add .changeset/custom-method-handlers.md (minor bump for client+server).
1 parent 89db8c6 commit 3575410

4 files changed

Lines changed: 32 additions & 4 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@modelcontextprotocol/client': minor
3+
'@modelcontextprotocol/server': minor
4+
---
5+
6+
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.

examples/server/src/customMethodExample.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,13 @@ const getServer = () => {
3434
server.setCustomRequestHandler('acme/search', SearchParamsSchema, async (params, ctx) => {
3535
console.log(`[server] acme/search query="${params.query}" limit=${params.limit ?? 'unset'} (req ${ctx.mcpReq.id})`);
3636

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

4045
return {
4146
results: [

packages/core/src/shared/protocol.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1077,7 +1077,9 @@ export abstract class Protocol<ContextT extends BaseContext> {
10771077
throw new Error(`"${method}" is a standard MCP request method. Use setRequestHandler() instead.`);
10781078
}
10791079
this._requestHandlers.set(method, (request, ctx) => {
1080-
const parsed = parseSchema(paramsSchema, request.params);
1080+
const { _meta, ...userParams } = (request.params ?? {}) as Record<string, unknown>;
1081+
void _meta;
1082+
const parsed = parseSchema(paramsSchema, userParams);
10811083
if (!parsed.success) {
10821084
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`);
10831085
}
@@ -1112,7 +1114,9 @@ export abstract class Protocol<ContextT extends BaseContext> {
11121114
throw new Error(`"${method}" is a standard MCP notification method. Use setNotificationHandler() instead.`);
11131115
}
11141116
this._notificationHandlers.set(method, notification => {
1115-
const parsed = parseSchema(paramsSchema, notification.params);
1117+
const { _meta, ...userParams } = (notification.params ?? {}) as Record<string, unknown>;
1118+
void _meta;
1119+
const parsed = parseSchema(paramsSchema, userParams);
11161120
if (!parsed.success) {
11171121
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error.message}`);
11181122
}

packages/core/test/shared/customMethods.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,19 @@ describe('custom request handlers', () => {
6262
expect(received?.mcpReq.method).toBe('acme/ctx');
6363
});
6464

65+
test('strict schema: SDK-injected _meta is stripped before validation', async () => {
66+
let receivedQ: string | undefined;
67+
let receivedMeta: unknown;
68+
server.setCustomRequestHandler('acme/strict', z.object({ q: z.string() }).strict(), (params, ctx) => {
69+
receivedQ = params.q;
70+
receivedMeta = ctx.mcpReq._meta;
71+
return {};
72+
});
73+
await expect(client.sendCustomRequest('acme/strict', { q: 'hi' }, z.object({}), { onprogress: () => {} })).resolves.toEqual({});
74+
expect(receivedQ).toBe('hi');
75+
expect(receivedMeta).toMatchObject({ progressToken: expect.anything() });
76+
});
77+
6578
test('invalid params -> InvalidParams ProtocolError', async () => {
6679
server.setCustomRequestHandler('acme/search', SearchParams, () => ({ hits: [], total: 0 }));
6780

0 commit comments

Comments
 (0)