Skip to content

Commit f1075b5

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 d3f9f5a commit f1075b5

File tree

4 files changed

+140
-6
lines changed

4 files changed

+140
-6
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: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -305,11 +305,51 @@ type TimeoutInfo = {
305305
onTimeout: () => void;
306306
};
307307

308+
/**
309+
* Declares the request and notification vocabulary a `Protocol` subclass speaks.
310+
*
311+
* Supplying a concrete `ProtocolSpec` as `Protocol`'s second type argument gives method-name
312+
* autocomplete and params/result correlation on the typed overloads of `setRequestHandler`
313+
* and `setNotificationHandler`. The default leaves them string-keyed and untyped.
314+
*/
315+
export type ProtocolSpec = {
316+
requests?: Record<string, { params?: unknown; result: unknown }>;
317+
notifications?: Record<string, { params?: unknown }>;
318+
};
319+
320+
/**
321+
* The {@linkcode ProtocolSpec} that describes the standard MCP method vocabulary, derived from
322+
* {@linkcode RequestTypeMap}, {@linkcode ResultTypeMap} and {@linkcode NotificationTypeMap}.
323+
*/
324+
export type McpSpec = {
325+
requests: { [M in RequestMethod]: { params: RequestTypeMap[M]['params']; result: ResultTypeMap[M] } };
326+
notifications: { [M in NotificationMethod]: { params: NotificationTypeMap[M]['params'] } };
327+
};
328+
329+
type _Requests<SpecT extends ProtocolSpec> = NonNullable<SpecT['requests']>;
330+
type _Notifications<SpecT extends ProtocolSpec> = NonNullable<SpecT['notifications']>;
331+
332+
/**
333+
* Method-name keys from a {@linkcode ProtocolSpec}'s `requests` map, or `never` for the
334+
* unconstrained default `ProtocolSpec`. Making the keys `never` for the default disables the
335+
* spec-typed overloads on `setRequestHandler` until the caller supplies a concrete `SpecT`.
336+
*/
337+
export type SpecRequests<SpecT extends ProtocolSpec> = string extends keyof _Requests<SpecT> ? never : keyof _Requests<SpecT> & string;
338+
339+
/** See {@linkcode SpecRequests}. */
340+
export type SpecNotifications<SpecT extends ProtocolSpec> = string extends keyof _Notifications<SpecT>
341+
? never
342+
: keyof _Notifications<SpecT> & string;
343+
308344
/**
309345
* Implements MCP protocol framing on top of a pluggable transport, including
310346
* features like request/response linking, notifications, and progress.
347+
*
348+
* `Protocol` is abstract; `Client` and `Server` are the concrete role-specific implementations.
349+
* Subclasses (such as MCP-dialect protocols like MCP Apps) can supply a {@linkcode ProtocolSpec}
350+
* as the second type argument to get method-name autocomplete on their own vocabulary.
311351
*/
312-
export abstract class Protocol<ContextT extends BaseContext> {
352+
export abstract class Protocol<ContextT extends BaseContext = BaseContext, SpecT extends ProtocolSpec = McpSpec> {
313353
private _transport?: Transport;
314354
private _requestMessageId = 0;
315355
private _requestHandlers: Map<string, (request: JSONRPCRequest, ctx: ContextT) => Promise<Result>> = new Map();
@@ -1042,10 +1082,19 @@ export abstract class Protocol<ContextT extends BaseContext> {
10421082
* Any method string; the supplied schema validates incoming `params`. Absent or undefined
10431083
* `params` are normalized to `{}` (after stripping `_meta`) before validation, so for
10441084
* no-params methods use `z.object({})`. `paramsSchema` may be any Standard Schema (Zod,
1045-
* Valibot, ArkType, etc.).
1085+
* Valibot, ArkType, etc.). When `method` is listed in this instance's
1086+
* {@linkcode ProtocolSpec}, params and result types are inferred from `SpecT`.
10461087
* - **Zod schema** — `setRequestHandler(RequestZodSchema, (request, ctx) => …)`. The method
10471088
* name is read from the schema's `method` literal; the handler receives the parsed request.
10481089
*/
1090+
setRequestHandler<K extends SpecRequests<SpecT>, P extends StandardSchemaV1<_Requests<SpecT>[K]['params']>>(
1091+
method: K,
1092+
paramsSchema: P,
1093+
handler: (
1094+
params: StandardSchemaV1.InferOutput<P>,
1095+
ctx: ContextT
1096+
) => _Requests<SpecT>[K]['result'] | Promise<_Requests<SpecT>[K]['result']>
1097+
): void;
10491098
setRequestHandler<M extends RequestMethod>(
10501099
method: M,
10511100
handler: (request: RequestTypeMap[M], ctx: ContextT) => Result | Promise<Result>
@@ -1143,8 +1192,15 @@ export abstract class Protocol<ContextT extends BaseContext> {
11431192
*
11441193
* Mirrors {@linkcode setRequestHandler}: a two-arg spec-method form (handler receives the full
11451194
* notification object), a three-arg form with a `paramsSchema` (handler receives validated
1146-
* `params`), and a Zod-schema form (method read from the schema's `method` literal).
1195+
* `params`), and a Zod-schema form (method read from the schema's `method` literal). When the
1196+
* three-arg form's `method` is listed in this instance's {@linkcode ProtocolSpec}, the params
1197+
* type is inferred from `SpecT`.
11471198
*/
1199+
setNotificationHandler<K extends SpecNotifications<SpecT>, P extends StandardSchemaV1<_Notifications<SpecT>[K]['params']>>(
1200+
method: K,
1201+
paramsSchema: P,
1202+
handler: (params: StandardSchemaV1.InferOutput<P>) => void | Promise<void>
1203+
): void;
11481204
setNotificationHandler<M extends NotificationMethod>(
11491205
method: M,
11501206
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)