Skip to content

Commit ae50e77

Browse files
feat(core): export Protocol class + ProtocolSpec generic for typed custom vocabularies
Exports the abstract Protocol class (was reachable in v1 via deep imports) and adds Protocol<ContextT, SpecT extends ProtocolSpec = McpSpec>. Subclasses supplying a concrete ProtocolSpec get method-name autocomplete and params/result correlation on the typed setRequestHandler/setNotificationHandler overloads.
1 parent daab2e2 commit ae50e77

File tree

4 files changed

+144
-7
lines changed

4 files changed

+144
-7
lines changed

.changeset/export-protocol-spec.md

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+
Export the abstract `Protocol` class (was reachable in v1 via deep imports) and add `Protocol<ContextT, SpecT extends ProtocolSpec = McpSpec>` for typed custom-method vocabularies. Subclasses supplying a concrete `ProtocolSpec` get method-name autocomplete and params/result correlation on the typed `setRequestHandler`/`setNotificationHandler` overloads.

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,21 @@ export { checkResourceAllowed, resourceUrlFromServerUrl } from '../../shared/aut
3838
// Metadata utilities
3939
export { getDisplayName } from '../../shared/metadataUtils.js';
4040

41-
// Protocol types (NOT the Protocol class itself or mergeCapabilities)
41+
// Protocol class (abstract — subclass for custom vocabularies) + types. NOT mergeCapabilities.
4242
export type {
4343
BaseContext,
4444
ClientContext,
45+
McpSpec,
4546
NotificationOptions,
4647
ProgressCallback,
4748
ProtocolOptions,
49+
ProtocolSpec,
4850
RequestOptions,
49-
ServerContext
51+
ServerContext,
52+
SpecNotifications,
53+
SpecRequests
5054
} from '../../shared/protocol.js';
51-
export { DEFAULT_REQUEST_TIMEOUT_MSEC } from '../../shared/protocol.js';
55+
export { DEFAULT_REQUEST_TIMEOUT_MSEC, Protocol } from '../../shared/protocol.js';
5256
export type { ZodLikeRequestSchema } from '../../util/compatSchema.js';
5357

5458
// Task manager types (NOT TaskManager class itself — internal)

packages/core/src/shared/protocol.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -295,11 +295,51 @@ type TimeoutInfo = {
295295
onTimeout: () => void;
296296
};
297297

298+
/**
299+
* Declares the request and notification vocabulary a `Protocol` subclass speaks.
300+
*
301+
* Supplying a concrete `ProtocolSpec` as `Protocol`'s second type argument gives method-name
302+
* autocomplete and params/result correlation on the typed overloads of `setRequestHandler`
303+
* and `setNotificationHandler`. The default leaves them string-keyed and untyped.
304+
*/
305+
export type ProtocolSpec = {
306+
requests?: Record<string, { params?: unknown; result: unknown }>;
307+
notifications?: Record<string, { params?: unknown }>;
308+
};
309+
310+
/**
311+
* The {@linkcode ProtocolSpec} that describes the standard MCP method vocabulary, derived from
312+
* {@linkcode RequestTypeMap}, {@linkcode ResultTypeMap} and {@linkcode NotificationTypeMap}.
313+
*/
314+
export type McpSpec = {
315+
requests: { [M in RequestMethod]: { params: RequestTypeMap[M]['params']; result: ResultTypeMap[M] } };
316+
notifications: { [M in NotificationMethod]: { params: NotificationTypeMap[M]['params'] } };
317+
};
318+
319+
type _Requests<SpecT extends ProtocolSpec> = NonNullable<SpecT['requests']>;
320+
type _Notifications<SpecT extends ProtocolSpec> = NonNullable<SpecT['notifications']>;
321+
322+
/**
323+
* Method-name keys from a {@linkcode ProtocolSpec}'s `requests` map, or `never` for the
324+
* unconstrained default `ProtocolSpec`. Making the keys `never` for the default disables the
325+
* spec-typed overloads on `setRequestHandler` until the caller supplies a concrete `SpecT`.
326+
*/
327+
export type SpecRequests<SpecT extends ProtocolSpec> = string extends keyof _Requests<SpecT> ? never : keyof _Requests<SpecT> & string;
328+
329+
/** See {@linkcode SpecRequests}. */
330+
export type SpecNotifications<SpecT extends ProtocolSpec> = string extends keyof _Notifications<SpecT>
331+
? never
332+
: keyof _Notifications<SpecT> & string;
333+
298334
/**
299335
* Implements MCP protocol framing on top of a pluggable transport, including
300336
* features like request/response linking, notifications, and progress.
337+
*
338+
* `Protocol` is abstract; `Client` and `Server` are the concrete role-specific implementations.
339+
* Subclasses (such as MCP-dialect protocols like MCP Apps) can supply a {@linkcode ProtocolSpec}
340+
* as the second type argument to get method-name autocomplete on their own vocabulary.
301341
*/
302-
export abstract class Protocol<ContextT extends BaseContext> {
342+
export abstract class Protocol<ContextT extends BaseContext = BaseContext, SpecT extends ProtocolSpec = McpSpec> {
303343
private _transport?: Transport;
304344
private _requestMessageId = 0;
305345
private _requestHandlers: Map<string, (request: JSONRPCRequest, ctx: ContextT) => Promise<Result>> = new Map();
@@ -1028,10 +1068,19 @@ export abstract class Protocol<ContextT extends BaseContext> {
10281068
* Any method string; the supplied schema validates incoming `params`. Absent or undefined
10291069
* `params` are normalized to `{}` (after stripping `_meta`) before validation, so for
10301070
* no-params methods use `z.object({})`. `paramsSchema` may be any Standard Schema (Zod,
1031-
* Valibot, ArkType, etc.).
1071+
* Valibot, ArkType, etc.). When `method` is listed in this instance's
1072+
* {@linkcode ProtocolSpec}, params and result types are inferred from `SpecT`.
10321073
* - **Zod schema** — `setRequestHandler(RequestZodSchema, (request, ctx) => …)`. The method
10331074
* name is read from the schema's `method` literal; the handler receives the parsed request.
10341075
*/
1076+
setRequestHandler<K extends SpecRequests<SpecT>, P extends StandardSchemaV1<_Requests<SpecT>[K]['params']>>(
1077+
method: K,
1078+
paramsSchema: P,
1079+
handler: (
1080+
params: StandardSchemaV1.InferOutput<P>,
1081+
ctx: ContextT
1082+
) => _Requests<SpecT>[K]['result'] | Promise<_Requests<SpecT>[K]['result']>
1083+
): void;
10351084
setRequestHandler<M extends RequestMethod>(
10361085
method: M,
10371086
handler: (request: RequestTypeMap[M], ctx: ContextT) => Result | Promise<Result>
@@ -1057,7 +1106,10 @@ export abstract class Protocol<ContextT extends BaseContext> {
10571106
);
10581107
}
10591108
if (maybeHandler === undefined) {
1060-
return this._setRequestHandlerByMethod(method, schemaOrHandler as (request: Request, ctx: ContextT) => Result | Promise<Result>);
1109+
return this._setRequestHandlerByMethod(
1110+
method,
1111+
schemaOrHandler as (request: Request, ctx: ContextT) => Result | Promise<Result>
1112+
);
10611113
}
10621114

10631115
this.assertRequestHandlerCapability(method);
@@ -1122,8 +1174,15 @@ export abstract class Protocol<ContextT extends BaseContext> {
11221174
*
11231175
* Mirrors {@linkcode setRequestHandler}: a two-arg spec-method form (handler receives the full
11241176
* notification object), a three-arg form with a `paramsSchema` (handler receives validated
1125-
* `params`), and a Zod-schema form (method read from the schema's `method` literal).
1177+
* `params`), and a Zod-schema form (method read from the schema's `method` literal). When the
1178+
* three-arg form's `method` is listed in this instance's {@linkcode ProtocolSpec}, the params
1179+
* type is inferred from `SpecT`.
11261180
*/
1181+
setNotificationHandler<K extends SpecNotifications<SpecT>, P extends StandardSchemaV1<_Notifications<SpecT>[K]['params']>>(
1182+
method: K,
1183+
paramsSchema: P,
1184+
handler: (params: StandardSchemaV1.InferOutput<P>) => void | Promise<void>
1185+
): void;
11271186
setNotificationHandler<M extends NotificationMethod>(
11281187
method: M,
11291188
handler: (notification: NotificationTypeMap[M]) => void | Promise<void>
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { z } from 'zod';
3+
4+
import type { BaseContext, ProtocolSpec, SpecRequests } from '../../src/shared/protocol.js';
5+
import { Protocol } from '../../src/shared/protocol.js';
6+
import { InMemoryTransport } from '../../src/util/inMemory.js';
7+
8+
class TestProtocol<SpecT extends ProtocolSpec = ProtocolSpec> extends Protocol<BaseContext, SpecT> {
9+
protected assertCapabilityForMethod(): void {}
10+
protected assertNotificationCapability(): void {}
11+
protected assertRequestHandlerCapability(): void {}
12+
protected assertTaskCapability(): void {}
13+
protected assertTaskHandlerCapability(): void {}
14+
protected buildContext(ctx: BaseContext): BaseContext {
15+
return ctx;
16+
}
17+
}
18+
19+
describe('ProtocolSpec typing', () => {
20+
type AppSpec = {
21+
requests: {
22+
'ui/open-link': { params: { url: string }; result: { opened: boolean } };
23+
};
24+
notifications: {
25+
'ui/size-changed': { params: { width: number; height: number } };
26+
};
27+
};
28+
29+
type _Assert<T extends true> = T;
30+
type _Eq<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
31+
type _t1 = _Assert<_Eq<SpecRequests<AppSpec>, 'ui/open-link'>>;
32+
type _t2 = _Assert<_Eq<SpecRequests<ProtocolSpec>, never>>;
33+
void (undefined as unknown as [_t1, _t2]);
34+
35+
it('typed-SpecT overload infers params/result; string fallback still works', async () => {
36+
const [t1, t2] = InMemoryTransport.createLinkedPair();
37+
const app = new TestProtocol<AppSpec>();
38+
const host = new TestProtocol<AppSpec>();
39+
await app.connect(t1);
40+
await host.connect(t2);
41+
42+
host.setRequestHandler('ui/open-link', z.object({ url: z.string() }), p => {
43+
const _typed: string = p.url;
44+
void _typed;
45+
return { opened: true };
46+
});
47+
const r = await app.request({ method: 'ui/open-link', params: { url: 'https://x' } }, z.object({ opened: z.boolean() }));
48+
expect(r.opened).toBe(true);
49+
50+
host.setRequestHandler('not/in-spec', z.object({ n: z.number() }), p => ({ doubled: p.n * 2 }));
51+
const r2 = await app.request({ method: 'not/in-spec', params: { n: 3 } }, z.object({ doubled: z.number() }));
52+
expect(r2.doubled).toBe(6);
53+
});
54+
55+
it('typed-SpecT overload types handler from passed schema, not SpecT (regression)', () => {
56+
type Spec = { requests: { 'x/y': { params: { a: string; b: string }; result: { ok: boolean } } } };
57+
const p = new TestProtocol<Spec>();
58+
const Narrow = z.object({ a: z.string() });
59+
p.setRequestHandler('x/y', Narrow, params => {
60+
const _a: string = params.a;
61+
// @ts-expect-error -- params is InferOutput<Narrow>, has no 'b' even though Spec does
62+
const _b: string = params.b;
63+
void _a;
64+
void _b;
65+
return { ok: true };
66+
});
67+
});
68+
});

0 commit comments

Comments
 (0)