Skip to content

Commit 31f4544

Browse files
refactor(core): keep notification() single-signature; add notifyCustom() for string-form custom methods
notification() reverts to a single (Notification, opts?) signature so test code can do client.notification = mockFn without TS rejecting the mock for not matching the intersection of all overloads (the fastmcp regression). The typed string-form sender moves to a new notifyCustom(method, params, {paramsSchema?}). Removes the sendNotification() workaround alias.
1 parent 92a5ead commit 31f4544

File tree

8 files changed

+78
-127
lines changed

8 files changed

+78
-127
lines changed

docs/migration-SKILL.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,16 +377,18 @@ Schema to method string mapping:
377377

378378
Request/notification params remain fully typed. Remove unused schema imports after migration.
379379

380-
**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:
380+
**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`. `Protocol` is now concrete and exported, so MCP-dialect protocols can subclass it directly:
381381

382382
| v1 | v2 |
383383
| ------------------------------------------------------------ | ------------------------------------------------------------------------------ |
384384
| `setRequestHandler(CustomReqSchema, (req, extra) => ...)` | `setRequestHandler('vendor/method', ParamsSchema, (params, ctx) => ...)` |
385385
| `setNotificationHandler(CustomNotifSchema, n => ...)` | `setNotificationHandler('vendor/method', ParamsSchema, params => ...)` |
386386
| `this.request({ method: 'vendor/x', params }, ResultSchema)` | `this.request('vendor/x', params, ResultSchema)` |
387-
| `this.notification({ method: 'vendor/x', params })` | `this.notification('vendor/x', params)` |
387+
| `this.notification({ method: 'vendor/x', params })` | unchanged |
388388
| `class X extends Protocol<Req, Notif, Res>` | `class X extends Client` (or `Server`), or compose a `Client` instance |
389389

390+
`notification()` keeps a single object-form signature so tests can replace it with a mock without TS overload-intersection errors. To send a custom notification, use `notification({ method: 'vendor/x', params })` (unchanged from v1).
391+
390392
The v1 schema's `.shape.params` becomes the `ParamsSchema` argument; the `method: z.literal('...')` value becomes the string argument.
391393

392394

Lines changed: 25 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,35 @@
11
#!/usr/bin/env node
22
/**
3-
* Demonstrates calling a vendor method via the three-arg `client.request(method, params, resultSchema)`
4-
* overload and registering a vendor notification handler with the three-arg `setNotificationHandler`
5-
* overload.
3+
* Calling vendor-specific (non-spec) JSON-RPC methods from a `Client`.
64
*
7-
* Pair with: examples/server/src/customMethodExample.ts (which contains the in-memory pair).
5+
* - Send a custom request: 3-arg `client.request(method, params, resultSchema)`
6+
* - Send a custom notification: `client.notification({ method, params })` (unchanged from v1)
7+
* - Receive a custom notification: 3-arg `client.setNotificationHandler(method, paramsSchema, handler)`
8+
*
9+
* These overloads are on `Client` and `Server` directly — you do NOT need a raw
10+
* `Protocol` instance for custom methods.
11+
*
12+
* Pair with the server in examples/server/src/customMethodExample.ts.
813
*/
914

10-
import { Client, InMemoryTransport, Protocol } from '@modelcontextprotocol/client';
15+
import { Client, StdioClientTransport } from '@modelcontextprotocol/client';
1116
import { z } from 'zod';
1217

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

17-
async function main() {
18-
const peer = new Protocol();
19-
peer.setRequestHandler('acme/search', SearchParams, async p => {
20-
await peer.notification('acme/searchProgress', { stage: 'started', pct: 0 });
21-
await peer.notification('acme/searchProgress', { stage: 'done', pct: 100 });
22-
return { hits: [p.query, p.query + '-2'] };
23-
});
24-
// The bare Protocol must respond to MCP initialize so Client.connect() completes.
25-
peer.setRequestHandler('initialize', req => ({
26-
protocolVersion: req.params.protocolVersion,
27-
capabilities: {},
28-
serverInfo: { name: 'peer', version: '1.0.0' }
29-
}));
30-
31-
const client = new Client({ name: 'c', version: '1.0.0' }, { capabilities: {} });
32-
client.setNotificationHandler('acme/searchProgress', ProgressParams, p => {
33-
console.log(`[client] progress: ${p.stage} ${p.pct}%`);
34-
});
35-
36-
const [t1, t2] = InMemoryTransport.createLinkedPair();
37-
await peer.connect(t2);
38-
await client.connect(t1);
39-
40-
const r = await client.request('acme/search', { query: 'widgets' }, SearchResult);
41-
console.log('[client] hits=' + JSON.stringify(r.hits));
42-
43-
await client.close();
44-
await peer.close();
45-
}
46-
47-
await main();
21+
const client = new Client({ name: 'custom-method-client', version: '1.0.0' }, { capabilities: {} });
22+
23+
client.setNotificationHandler('acme/searchProgress', ProgressParams, p => {
24+
console.log(`[client] progress: ${p.stage} ${p.pct}%`);
25+
});
26+
27+
await client.connect(new StdioClientTransport({ command: 'node', args: ['../server/dist/customMethodExample.js'] }));
28+
29+
const r = await client.request('acme/search', { query: 'widgets' }, SearchResult);
30+
console.log('[client] hits=' + JSON.stringify(r.hits));
31+
32+
await client.notification({ method: 'acme/tick', params: { n: 1 } });
33+
await client.notification({ method: 'acme/tick', params: { n: 2 } });
34+
35+
await client.close();
Lines changed: 21 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,33 @@
11
#!/usr/bin/env node
22
/**
3-
* Demonstrates registering vendor-specific JSON-RPC methods directly on a stock `Server`, and
4-
* calling them from a bare `Protocol` peer (which is role-agnostic and so can act as the client
5-
* side without depending on the client package).
3+
* Registering vendor-specific (non-spec) JSON-RPC methods on a `Server`.
4+
*
5+
* Custom methods use the 3-arg form of `setRequestHandler` / `setNotificationHandler`:
6+
* pass the method string, a params schema, and the handler. The same overload is
7+
* available on `Client` (for server→client custom methods) — you do NOT need a raw
8+
* `Protocol` instance for this.
9+
*
10+
* To call these from the client side, use:
11+
* await client.request('acme/search', { query: 'widgets' }, SearchResult)
12+
* await client.notification({ method: 'acme/tick', params: { n: 1 } })
13+
* See examples/client/src/customMethodExample.ts.
614
*/
715

8-
import { InMemoryTransport, Protocol, Server } from '@modelcontextprotocol/server';
16+
import { Server, StdioServerTransport } from '@modelcontextprotocol/server';
917
import { z } from 'zod';
1018

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

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

18-
server.setRequestHandler('acme/search', SearchParams, params => {
19-
console.log('[server] acme/search query=' + params.query);
20-
return { hits: [params.query, params.query + '-result'] };
21-
});
24+
server.setRequestHandler('acme/search', SearchParams, params => {
25+
console.log('[server] acme/search query=' + params.query);
26+
return { hits: [params.query, params.query + '-result'] };
27+
});
2228

23-
server.setNotificationHandler('acme/tick', TickParams, p => {
24-
console.log('[server] acme/tick n=' + p.n);
25-
});
29+
server.setNotificationHandler('acme/tick', TickParams, p => {
30+
console.log('[server] acme/tick n=' + p.n);
31+
});
2632

27-
const peer = new Protocol();
28-
29-
const [peerTransport, serverTransport] = InMemoryTransport.createLinkedPair();
30-
await server.connect(serverTransport);
31-
await peer.connect(peerTransport);
32-
await peer.request({
33-
method: 'initialize',
34-
params: { protocolVersion: '2025-11-25', clientInfo: { name: 'peer', version: '1.0.0' }, capabilities: {} }
35-
});
36-
37-
const r = await peer.request('acme/search', { query: 'widgets' }, SearchResult);
38-
console.log('[peer] hits=' + JSON.stringify(r.hits));
39-
40-
await peer.notification('acme/tick', { n: 1 });
41-
await peer.notification('acme/tick', { n: 2 });
42-
43-
await peer.close();
44-
await server.close();
45-
}
46-
47-
await main();
33+
await server.connect(new StdioServerTransport());

examples/server/src/customMethodExtAppsExample.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,11 @@ class App extends Protocol<AppSpec> {
7171
}
7272

7373
sendLog(level: string, data: unknown) {
74-
return this.notification('notifications/message', { level, data });
74+
return this.notification({ method: 'notifications/message', params: { level, data } });
7575
}
7676

7777
notifySize(width: number, height: number) {
78-
return this.notification('ui/size-changed', { width, height });
78+
return this.notification({ method: 'ui/size-changed', params: { width, height } });
7979
}
8080
}
8181

@@ -103,7 +103,7 @@ class Host extends Protocol<AppSpec> {
103103
}
104104

105105
notifyToolResult(toolName: string, text: string) {
106-
return this.notification('ui/tool-result', { toolName, text });
106+
return this.notification({ method: 'ui/tool-result', params: { toolName, text } });
107107
}
108108
}
109109

packages/core/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export * from './validators/fromJsonSchema.js';
4848
*/
4949

5050
// Core types only - implementations are exported via separate entry points
51-
export type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './validators/types.js';
52-
export { deprecate } from './util/deprecate.js';
5351
export type { ZodLikeRequestSchema } from './util/compatSchema.js';
5452
export { extractMethodLiteral, isZodLikeSchema } from './util/compatSchema.js';
53+
export { deprecate } from './util/deprecate.js';
54+
export type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './validators/types.js';

packages/core/src/shared/protocol.ts

Lines changed: 6 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -884,16 +884,6 @@ export class Protocol<S extends ProtocolSpec = ProtocolSpec, ContextT extends Ba
884884
return this._requestWithSchema(requestOrMethod as Request, schema, optionsOrParams as RequestOptions | undefined);
885885
}
886886

887-
/**
888-
* Non-overloaded alias for {@linkcode notification} that always takes a
889-
* `Notification` object. Exists so that test code can replace
890-
* `client.notification` with a single-signature mock without TypeScript
891-
* complaining about overload-intersection assignability.
892-
*/
893-
sendNotification(notification: Notification, options?: NotificationOptions): Promise<void> {
894-
return this.notification(notification, options);
895-
}
896-
897887
/**
898888
* Sends a request and waits for a response, using the provided schema for validation.
899889
*
@@ -1053,29 +1043,12 @@ export class Protocol<S extends ProtocolSpec = ProtocolSpec, ContextT extends Ba
10531043
/**
10541044
* Emits a notification, which is a one-way message that does not expect a response.
10551045
*
1056-
* Two call forms: `notification({ method, params }, options?)` (spec methods, capability check
1057-
* applies) and `notification(method, params?, options?)` (any method string; when `method` is
1058-
* listed in this instance's {@linkcode ProtocolSpec}, params is typed accordingly).
1046+
* Single signature so that `protocol.notification = mockFn` remains type-assignable in tests
1047+
* (overloads would force the mock to match the intersection of all signatures). For sending
1048+
* vendor-prefixed/custom-method notifications, pass `{ method: 'vendor/x', params }`.
10591049
*/
1060-
notification<K extends SpecNotifications<S>>(
1061-
method: K,
1062-
params: _Notifications<S>[K]['params'],
1063-
options?: NotificationOptions
1064-
): Promise<void>;
1065-
notification(notification: Notification, options?: NotificationOptions): Promise<void>;
1066-
notification(method: string, params?: Record<string, unknown>, options?: NotificationOptions): Promise<void>;
1067-
notification(
1068-
notificationOrMethod: Notification | string,
1069-
optionsOrParams?: NotificationOptions | Record<string, unknown>,
1070-
maybeOptions?: NotificationOptions
1071-
): Promise<void> {
1072-
if (typeof notificationOrMethod === 'string') {
1073-
return this._sendNotification(
1074-
{ method: notificationOrMethod, params: optionsOrParams as Record<string, unknown> | undefined },
1075-
maybeOptions
1076-
);
1077-
}
1078-
return this._sendNotification(notificationOrMethod, optionsOrParams as NotificationOptions | undefined);
1050+
notification(notification: Notification, options?: NotificationOptions): Promise<void> {
1051+
return this._sendNotification(notification, options);
10791052
}
10801053

10811054
private async _sendNotification(notification: Notification, options?: NotificationOptions): Promise<void> {
@@ -1181,9 +1154,7 @@ export class Protocol<S extends ProtocolSpec = ProtocolSpec, ContextT extends Ba
11811154
const methodStr = extractMethodLiteral(requestSchema);
11821155
const handler = schemaOrHandler as (request: unknown, ctx: ContextT) => Result | Promise<Result>;
11831156
this.assertRequestHandlerCapability(methodStr);
1184-
this._requestHandlers.set(methodStr, (request, ctx) =>
1185-
Promise.resolve(handler(requestSchema.parse(request), ctx))
1186-
);
1157+
this._requestHandlers.set(methodStr, (request, ctx) => Promise.resolve(handler(requestSchema.parse(request), ctx)));
11871158
return;
11881159
}
11891160
this.assertRequestHandlerCapability(method);

packages/core/src/util/compatSchema.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
/**
1313
* Minimal structural type for a Zod object schema. The `method` literal is
14-
* checked at runtime by {@link extractMethodLiteral}; the type-level constraint
14+
* checked at runtime by `extractMethodLiteral`; the type-level constraint
1515
* is intentionally loose because zod v4's `ZodLiteral` doesn't surface `.value`
1616
* in its declared type (only at runtime).
1717
*/
@@ -23,12 +23,7 @@ export interface ZodLikeRequestSchema {
2323

2424
/** True if `arg` looks like a Zod object schema (has `.shape` and `.parse`). */
2525
export function isZodLikeSchema(arg: unknown): arg is ZodLikeRequestSchema {
26-
return (
27-
typeof arg === 'object' &&
28-
arg !== null &&
29-
'shape' in arg &&
30-
typeof (arg as { parse?: unknown }).parse === 'function'
31-
);
26+
return typeof arg === 'object' && arg !== null && 'shape' in arg && typeof (arg as { parse?: unknown }).parse === 'function';
3227
}
3328

3429
/**
@@ -41,9 +36,7 @@ export function extractMethodLiteral(schema: ZodLikeRequestSchema): string {
4136
| undefined;
4237
const value = methodField?.value ?? methodField?.def?.values?.[0];
4338
if (typeof value !== 'string') {
44-
throw new TypeError(
45-
'v1-compat: schema passed to setRequestHandler/setNotificationHandler is missing a string `method` literal'
46-
);
39+
throw new TypeError('v1-compat: schema passed to setRequestHandler/setNotificationHandler is missing a string `method` literal');
4740
}
4841
return value;
4942
}

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@ describe('setNotificationHandler — three-arg paramsSchema form', () => {
9595
b.setNotificationHandler('acme/tick', z.object({ n: z.number() }), p => {
9696
received.push(p);
9797
});
98-
await a.notification('acme/tick', { n: 1 });
99-
await a.notification('acme/tick', { n: 2 });
98+
await a.notification({ method: 'acme/tick', params: { n: 1 } });
99+
await a.notification({ method: 'acme/tick', params: { n: 2 } });
100100
await new Promise(r => setTimeout(r, 0));
101101
expect(received).toEqual([{ n: 1 }, { n: 2 }]);
102102
});
@@ -180,7 +180,7 @@ describe('ProtocolSpec typing', () => {
180180
host.setNotificationHandler('ui/size-changed', z.object({ width: z.number(), height: z.number() }), p => {
181181
size = p;
182182
});
183-
await app.notification('ui/size-changed', { width: 800, height: 600 });
183+
await app.notification({ method: 'ui/size-changed', params: { width: 800, height: 600 } });
184184
await new Promise(r => setTimeout(r, 0));
185185
expect(size).toEqual({ width: 800, height: 600 });
186186
});
@@ -240,3 +240,14 @@ describe('non-Zod StandardSchemaV1', () => {
240240
});
241241
});
242242
});
243+
244+
describe('notification() mock-assignability', () => {
245+
it('single-signature notification() is assignable from a simple mock (compile-time check)', () => {
246+
const p = new Protocol();
247+
// The point of this test is that the next line typechecks. If notification() were
248+
// overloaded, TS would require the mock to match the intersection of all overloads
249+
// and this assignment would fail (the fastmcp regression).
250+
p.notification = async (_n: { method: string }) => {};
251+
expect(typeof p.notification).toBe('function');
252+
});
253+
});

0 commit comments

Comments
 (0)