Skip to content

Commit 62d993f

Browse files
refactor(core): Protocol composes Dispatcher and StreamDriver; dispatch() public
Protocol now holds a private Dispatcher (handler registry, middleware) and constructs a StreamDriver per connect(). Public methods delegate; protocol.ts shrinks from ~1011 to ~393 LOC. dispatch(req, env): AsyncIterable<JSONRPCMessage> is now a public method: any per-request driver (HTTP, FaaS) can iterate it directly without a transport. Behavior unchanged: protocol.test.ts is unmodified, conformance 40/40.
1 parent 1916e69 commit 62d993f

5 files changed

Lines changed: 506 additions & 935 deletions

File tree

packages/core/src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ export * from './auth/errors.js';
22
export * from './errors/sdkErrors.js';
33
export * from './shared/auth.js';
44
export * from './shared/authUtils.js';
5-
export * from './shared/context.js';
65
export * from './shared/dispatcher.js';
76
export * from './shared/metadataUtils.js';
87
export * from './shared/protocol.js';

packages/core/src/shared/context.ts

Lines changed: 283 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,238 @@
1-
import type { AuthInfo, JSONRPCMessage, Notification, Request, RequestId, Result } from '../types/index.js';
1+
import type {
2+
AuthInfo,
3+
ClientCapabilities,
4+
CreateMessageRequest,
5+
CreateMessageResult,
6+
CreateMessageResultWithTools,
7+
ElicitRequestFormParams,
8+
ElicitRequestURLParams,
9+
ElicitResult,
10+
LoggingLevel,
11+
Notification,
12+
Progress,
13+
Request,
14+
RequestId,
15+
RequestMeta,
16+
RequestMethod,
17+
Result,
18+
ResultTypeMap,
19+
ServerCapabilities
20+
} from '../types/index.js';
221
import type { StandardSchemaV1 } from '../util/standardSchema.js';
3-
import type { NotificationOptions, RequestOptions } from './protocol.js';
22+
import type { TransportSendOptions } from './transport.js';
23+
24+
/**
25+
* Callback for progress notifications.
26+
*/
27+
export type ProgressCallback = (progress: Progress) => void;
28+
29+
/**
30+
* Additional initialization options.
31+
*/
32+
export type ProtocolOptions = {
33+
/**
34+
* Protocol versions supported. First version is preferred (sent by client,
35+
* used as fallback by server). Passed to transport during {@linkcode Protocol.connect | connect()}.
36+
*
37+
* @default {@linkcode SUPPORTED_PROTOCOL_VERSIONS}
38+
*/
39+
supportedProtocolVersions?: string[];
40+
41+
/**
42+
* Whether to restrict emitted requests to only those that the remote side has indicated that they can handle, through their advertised capabilities.
43+
*
44+
* Note that this DOES NOT affect checking of _local_ side capabilities, as it is considered a logic error to mis-specify those.
45+
*
46+
* Currently this defaults to `false`, for backwards compatibility with SDK versions that did not advertise capabilities correctly. In future, this will default to `true`.
47+
*/
48+
enforceStrictCapabilities?: boolean;
49+
/**
50+
* An array of notification method names that should be automatically debounced.
51+
* Any notifications with a method in this list will be coalesced if they
52+
* occur in the same tick of the event loop.
53+
* e.g., `['notifications/tools/list_changed']`
54+
*/
55+
debouncedNotificationMethods?: string[];
56+
};
57+
58+
/**
59+
* The default request timeout, in milliseconds.
60+
*/
61+
export const DEFAULT_REQUEST_TIMEOUT_MSEC = 60_000;
62+
63+
/**
64+
* Options that can be given per request.
65+
*/
66+
export type RequestOptions = {
67+
/**
68+
* If set, requests progress notifications from the remote end (if supported). When progress notifications are received, this callback will be invoked.
69+
*/
70+
onprogress?: ProgressCallback;
71+
72+
/**
73+
* Can be used to cancel an in-flight request. This will cause an `AbortError` to be raised from {@linkcode Protocol.request | request()}.
74+
*/
75+
signal?: AbortSignal;
76+
77+
/**
78+
* A timeout (in milliseconds) for this request. If exceeded, an {@linkcode SdkError} with code {@linkcode SdkErrorCode.RequestTimeout} will be raised from {@linkcode Protocol.request | request()}.
79+
*
80+
* If not specified, {@linkcode DEFAULT_REQUEST_TIMEOUT_MSEC} will be used as the timeout.
81+
*/
82+
timeout?: number;
83+
84+
/**
85+
* If `true`, receiving a progress notification will reset the request timeout.
86+
* This is useful for long-running operations that send periodic progress updates.
87+
* Default: `false`
88+
*/
89+
resetTimeoutOnProgress?: boolean;
90+
91+
/**
92+
* Maximum total time (in milliseconds) to wait for a response.
93+
* If exceeded, an {@linkcode SdkError} with code {@linkcode SdkErrorCode.RequestTimeout} will be raised, regardless of progress notifications.
94+
* If not specified, there is no maximum total timeout.
95+
*/
96+
maxTotalTimeout?: number;
97+
} & TransportSendOptions;
98+
99+
/**
100+
* Options that can be given per notification.
101+
*/
102+
export type NotificationOptions = {
103+
/**
104+
* May be used to indicate to the transport which incoming request to associate this outgoing notification with.
105+
*/
106+
relatedRequestId?: RequestId;
107+
};
108+
109+
/**
110+
* Base context provided to all request handlers.
111+
*/
112+
export type BaseContext = {
113+
/**
114+
* The session ID from the transport, if available.
115+
*/
116+
sessionId?: string;
117+
118+
/**
119+
* Information about the MCP request being handled.
120+
*/
121+
mcpReq: {
122+
/**
123+
* The JSON-RPC ID of the request being handled.
124+
*/
125+
id: RequestId;
126+
127+
/**
128+
* The method name of the request (e.g., 'tools/call', 'ping').
129+
*/
130+
method: string;
131+
132+
/**
133+
* Metadata from the original request.
134+
*/
135+
_meta?: RequestMeta;
136+
137+
/**
138+
* An abort signal used to communicate if the request was cancelled from the sender's side.
139+
*/
140+
signal: AbortSignal;
141+
142+
/**
143+
* Sends a request that relates to the current request being handled.
144+
*
145+
* This is used by certain transports to correctly associate related messages.
146+
*
147+
* For spec methods the result type is inferred from the method name.
148+
* For custom (non-spec) methods, pass a result schema as the second argument.
149+
*/
150+
send: {
151+
<M extends RequestMethod>(
152+
request: { method: M; params?: Record<string, unknown> },
153+
options?: RequestOptions
154+
): Promise<ResultTypeMap[M]>;
155+
<T extends StandardSchemaV1>(
156+
request: Request,
157+
resultSchema: T,
158+
options?: RequestOptions
159+
): Promise<StandardSchemaV1.InferOutput<T>>;
160+
};
161+
162+
/**
163+
* Sends a notification that relates to the current request being handled.
164+
*
165+
* This is used by certain transports to correctly associate related messages.
166+
*/
167+
notify: (notification: Notification) => Promise<void>;
168+
};
169+
170+
/**
171+
* HTTP transport information, only available when using an HTTP-based transport.
172+
*/
173+
http?: {
174+
/**
175+
* Information about a validated access token, provided to request handlers.
176+
*/
177+
authInfo?: AuthInfo;
178+
};
179+
180+
/**
181+
* Extension slot. Adapters and middleware populate keys here; handlers cast to the
182+
* extension's declared type to read them. Core never reads or writes this field.
183+
*/
184+
ext?: Record<string, unknown>;
185+
};
186+
187+
/**
188+
* Context provided to server-side request handlers, extending {@linkcode BaseContext} with server-specific fields.
189+
*/
190+
export type ServerContext = BaseContext & {
191+
mcpReq: {
192+
/**
193+
* Send a log message notification to the client.
194+
* Respects the client's log level filter set via logging/setLevel.
195+
*/
196+
log: (level: LoggingLevel, data: unknown, logger?: string) => Promise<void>;
197+
198+
/**
199+
* Send an elicitation request to the client, requesting user input.
200+
*/
201+
elicitInput: (params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions) => Promise<ElicitResult>;
202+
203+
/**
204+
* Request LLM sampling from the client.
205+
*/
206+
requestSampling: (
207+
params: CreateMessageRequest['params'],
208+
options?: RequestOptions
209+
) => Promise<CreateMessageResult | CreateMessageResultWithTools>;
210+
};
211+
212+
http?: {
213+
/**
214+
* The original HTTP request.
215+
*/
216+
req?: globalThis.Request;
217+
218+
/**
219+
* Closes the SSE stream for this request, triggering client reconnection.
220+
* Only available when using a StreamableHTTPServerTransport with eventStore configured.
221+
*/
222+
closeSSE?: () => void;
223+
224+
/**
225+
* Closes the standalone GET SSE stream, triggering client reconnection.
226+
* Only available when using a StreamableHTTPServerTransport with eventStore configured.
227+
*/
228+
closeStandaloneSSE?: () => void;
229+
};
230+
};
231+
232+
/**
233+
* Context provided to client-side request handlers.
234+
*/
235+
export type ClientContext = BaseContext;
4236

5237
/**
6238
* Per-request environment a transport adapter passes to {@linkcode Dispatcher.dispatch}.
@@ -17,6 +249,13 @@ export type RequestEnv = {
17249
*/
18250
send?: (request: Request, options?: RequestOptions) => Promise<Result>;
19251

252+
/**
253+
* Sends a notification back to the peer, related to the request being dispatched.
254+
* When supplied, `ctx.mcpReq.notify` calls this; when undefined, the dispatcher
255+
* yields the notification inline.
256+
*/
257+
notify?: (notification: Notification) => Promise<void>;
258+
20259
/** Validated auth token info for HTTP transports. */
21260
authInfo?: AuthInfo;
22261

@@ -29,6 +268,12 @@ export type RequestEnv = {
29268
/** Transport session identifier (legacy `Mcp-Session-Id`). */
30269
sessionId?: string;
31270

271+
/**
272+
* The originating request id, when the dispatch is on behalf of an inbound request.
273+
* Adapters propagate this so wrapped `send`/`notify` carry `relatedRequestId`.
274+
*/
275+
relatedRequestId?: RequestId;
276+
32277
/** Extension slot. Adapters and middleware populate keys here; copied onto `BaseContext.ext`. */
33278
ext?: Record<string, unknown>;
34279
};
@@ -48,10 +293,42 @@ export interface Outbound {
48293
notification(notification: Notification, options?: NotificationOptions): Promise<void>;
49294
/** Close the underlying connection. */
50295
close(): Promise<void>;
51-
/** Clear a registered progress callback by its message id. Optional; pipe-channels expose this. */
52-
removeProgressHandler?(messageId: number): void;
53296
/** Inform the channel which protocol version was negotiated (for header echoing etc.). Optional. */
54297
setProtocolVersion?(version: string): void;
55-
/** Write a raw JSON-RPC message on the same stream as a prior request. Optional; pipe-only. */
56-
sendRaw?(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise<void>;
298+
}
299+
300+
/**
301+
* Schema bundle accepted by {@linkcode Protocol.setRequestHandler | setRequestHandler}'s 3-arg form.
302+
*
303+
* `params` is required and validates the inbound `request.params`. `result` is optional;
304+
* when supplied it types the handler's return value (no runtime validation is performed
305+
* on the result).
306+
*/
307+
export interface RequestHandlerSchemas<
308+
P extends StandardSchemaV1 = StandardSchemaV1,
309+
R extends StandardSchemaV1 | undefined = StandardSchemaV1 | undefined
310+
> {
311+
params: P;
312+
result?: R;
313+
}
314+
315+
function isPlainObject(value: unknown): value is Record<string, unknown> {
316+
return value !== null && typeof value === 'object' && !Array.isArray(value);
317+
}
318+
319+
export function mergeCapabilities(base: ServerCapabilities, additional: Partial<ServerCapabilities>): ServerCapabilities;
320+
export function mergeCapabilities(base: ClientCapabilities, additional: Partial<ClientCapabilities>): ClientCapabilities;
321+
export function mergeCapabilities<T extends ServerCapabilities | ClientCapabilities>(base: T, additional: Partial<T>): T {
322+
const result: T = { ...base };
323+
for (const key in additional) {
324+
const k = key as keyof T;
325+
const addValue = additional[k];
326+
if (addValue === undefined) continue;
327+
const baseValue = result[k];
328+
result[k] =
329+
isPlainObject(baseValue) && isPlainObject(addValue)
330+
? ({ ...(baseValue as Record<string, unknown>), ...(addValue as Record<string, unknown>) } as T[typeof k])
331+
: (addValue as T[typeof k]);
332+
}
333+
return result;
57334
}

packages/core/src/shared/dispatcher.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@ import type {
1717
import { getNotificationSchema, getRequestSchema, getResultSchema, ProtocolError, ProtocolErrorCode } from '../types/index.js';
1818
import type { StandardSchemaV1 } from '../util/standardSchema.js';
1919
import { validateStandardSchema } from '../util/standardSchema.js';
20-
import type { RequestEnv } from './context.js';
21-
import type { BaseContext, RequestOptions } from './protocol.js';
20+
import type { BaseContext, RequestEnv, RequestOptions } from './context.js';
2221

2322
/**
2423
* One yielded item from {@linkcode Dispatcher.dispatch}. A dispatch yields zero or more
@@ -184,11 +183,13 @@ export class Dispatcher<ContextT extends BaseContext = BaseContext> {
184183
}
185184
return parsed.data;
186185
}) as BaseContext['mcpReq']['send'],
187-
notify: async (n: Notification) => {
188-
if (done) return;
189-
queue.push({ jsonrpc: '2.0', method: n.method, params: n.params } as JSONRPCNotification);
190-
wake?.();
191-
}
186+
notify:
187+
env.notify ??
188+
(async (n: Notification) => {
189+
if (done) return;
190+
queue.push({ jsonrpc: '2.0', method: n.method, params: n.params } as JSONRPCNotification);
191+
wake?.();
192+
})
192193
},
193194
http: env.authInfo || env.httpReq ? { authInfo: env.authInfo } : undefined,
194195
ext: env.ext

0 commit comments

Comments
 (0)