Skip to content

Commit 628f0e1

Browse files
refactor(core): extract Dispatcher; Protocol composes it (zero behavior change)
Introduce `Dispatcher<ContextT>` (`packages/core/src/shared/dispatcher.ts`) holding the request-handler registry, middleware chain, and `dispatch()` entry point. `Protocol` now composes a `protected readonly dispatcher` and delegates: - `setRequestHandler` / `removeRequestHandler` / `assertCanSetRequestHandler` - `fallbackRequestHandler` (now `dispatcher.fallbackHandler`) - `_onrequest` invokes `dispatcher.dispatch(request, ctx)` for lookup, middleware onion, and JSON-RPC response wrapping The previous `_wrapHandler` override hook is replaced by `dispatcher.use()`: - `Server` registers `_callToolResultMiddleware` (was `_wrapHandler` for tools/call) - `Client` registers `_validationMiddleware` (was `_wrapHandler` for elicitation/create + sampling/createMessage) Same validation runs on the same paths; the observation point moves from per-registration wrapping to per-dispatch middleware. `dispatch()`'s catch block preserves `_onrequest`'s error behavior verbatim: thrown errors surface their `code` (if a safe integer), `message`, and `data`. No sanitization is introduced. Tests: - `dispatcher.test.ts` added (11 tests for setHandler/dispatch/middleware/error) - `wrapHandler.test.ts` deleted (covered by rewritten customMethods test) - `customMethods.test.ts` adapts one test from spying on `_wrapHandler` to spying via middleware; same assertion (both 2-arg and 3-arg routes through) No existing test assertions changed.
1 parent 29c4991 commit 628f0e1

12 files changed

Lines changed: 510 additions & 273 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
'@modelcontextprotocol/core': major
3+
---
4+
Extract Dispatcher from Protocol. Protocol composes `protected readonly dispatcher`; setRequestHandler/_onrequest delegate. The protected `_wrapHandler` override hook is replaced by `dispatcher.use(middleware)`.

.changeset/wraphandler-hook.md

Lines changed: 0 additions & 7 deletions
This file was deleted.

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,11 @@ When a request arrives from the remote side:
161161
1. **Transport** receives message, calls `transport.onmessage()`
162162
2. **`Protocol.connect()`** routes to `_onrequest()`, `_onresponse()`, or `_onnotification()`
163163
3. **`Protocol._onrequest()`**:
164-
- Looks up handler in `_requestHandlers` map (keyed by method name)
164+
- Checks `dispatcher.canHandle(method)`; sends a `MethodNotFound` error and returns early if no handler (or fallback) is registered
165165
- Creates `BaseContext` with `signal`, `sessionId`, `sendNotification`, `sendRequest`, etc.
166166
- Calls `buildContext()` to let subclasses enrich the context (e.g., Server adds HTTP request info)
167-
- Invokes handler, sends JSON-RPC response back via transport
167+
- Calls `dispatcher.dispatch()` which looks up the handler (keyed by method name), runs the middleware chain, invokes the handler, and wraps the result as a JSON-RPC response
168+
- Sends the response back via transport
168169
4. **Handler** was registered via `setRequestHandler('method', handler)`
169170

170171
### Handler Registration

packages/client/src/client/client.ts

Lines changed: 66 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import type {
77
CompleteRequest,
88
GetPromptRequest,
99
Implementation,
10-
JSONRPCRequest,
1110
JsonSchemaType,
1211
JsonSchemaValidator,
1312
jsonSchemaValidator,
@@ -19,12 +18,12 @@ import type {
1918
ListToolsRequest,
2019
LoggingLevel,
2120
MessageExtraInfo,
21+
Middleware,
2222
NotificationMethod,
2323
ProtocolOptions,
2424
ReadResourceRequest,
2525
RequestMethod,
2626
RequestOptions,
27-
Result,
2827
ServerCapabilities,
2928
SubscribeRequest,
3029
Tool,
@@ -229,6 +228,8 @@ export class Client extends Protocol<ClientContext> {
229228
this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator();
230229
this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false;
231230

231+
this.dispatcher.use(this._validationMiddleware);
232+
232233
// Store list changed config for setup after connection (when we know server capabilities)
233234
if (options?.listChanged) {
234235
this._pendingListChangedConfig = options.listChanged;
@@ -283,93 +284,86 @@ export class Client extends Protocol<ClientContext> {
283284

284285
/**
285286
* Enforces client-side validation for `elicitation/create` and `sampling/createMessage`
286-
* regardless of how the handler was registered.
287+
* regardless of how the handler was registered. Installed as a {@linkcode Dispatcher}
288+
* middleware so it applies to both the legacy `_onrequest` path and the 2026-06
289+
* dispatch path.
287290
*/
288-
protected override _wrapHandler(
289-
method: string,
290-
handler: (request: JSONRPCRequest, ctx: ClientContext) => Promise<Result>
291-
): (request: JSONRPCRequest, ctx: ClientContext) => Promise<Result> {
292-
if (method === 'elicitation/create') {
293-
return async (request, ctx) => {
294-
const validatedRequest = parseSchema(ElicitRequestSchema, request);
295-
if (!validatedRequest.success) {
296-
// Type guard: if success is false, error is guaranteed to exist
297-
const errorMessage =
298-
validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error);
299-
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid elicitation request: ${errorMessage}`);
300-
}
291+
private readonly _validationMiddleware: Middleware<ClientContext> = async (request, _ctx, next) => {
292+
if (request.method === 'elicitation/create') {
293+
const validatedRequest = parseSchema(ElicitRequestSchema, request);
294+
if (!validatedRequest.success) {
295+
const errorMessage =
296+
validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error);
297+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid elicitation request: ${errorMessage}`);
298+
}
301299

302-
const { params } = validatedRequest.data;
303-
params.mode = params.mode ?? 'form';
304-
const { supportsFormMode, supportsUrlMode } = getSupportedElicitationModes(this._capabilities.elicitation);
300+
const { params } = validatedRequest.data;
301+
params.mode = params.mode ?? 'form';
302+
const { supportsFormMode, supportsUrlMode } = getSupportedElicitationModes(this._capabilities.elicitation);
305303

306-
if (params.mode === 'form' && !supportsFormMode) {
307-
throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Client does not support form-mode elicitation requests');
308-
}
304+
if (params.mode === 'form' && !supportsFormMode) {
305+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Client does not support form-mode elicitation requests');
306+
}
309307

310-
if (params.mode === 'url' && !supportsUrlMode) {
311-
throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Client does not support URL-mode elicitation requests');
312-
}
308+
if (params.mode === 'url' && !supportsUrlMode) {
309+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Client does not support URL-mode elicitation requests');
310+
}
313311

314-
const result = await handler(request, ctx);
312+
const result = await next();
315313

316-
const validationResult = parseSchema(ElicitResultSchema, result);
317-
if (!validationResult.success) {
318-
// Type guard: if success is false, error is guaranteed to exist
319-
const errorMessage =
320-
validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error);
321-
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid elicitation result: ${errorMessage}`);
322-
}
314+
const validationResult = parseSchema(ElicitResultSchema, result);
315+
if (!validationResult.success) {
316+
const errorMessage =
317+
validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error);
318+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid elicitation result: ${errorMessage}`);
319+
}
323320

324-
const validatedResult = validationResult.data;
325-
const requestedSchema = params.mode === 'form' ? (params.requestedSchema as JsonSchemaType) : undefined;
326-
327-
if (
328-
params.mode === 'form' &&
329-
validatedResult.action === 'accept' &&
330-
validatedResult.content &&
331-
requestedSchema &&
332-
this._capabilities.elicitation?.form?.applyDefaults
333-
) {
334-
try {
335-
applyElicitationDefaults(requestedSchema, validatedResult.content);
336-
} catch {
337-
// gracefully ignore errors in default application
338-
}
321+
const validatedResult = validationResult.data;
322+
const requestedSchema = params.mode === 'form' ? (params.requestedSchema as JsonSchemaType) : undefined;
323+
324+
if (
325+
params.mode === 'form' &&
326+
validatedResult.action === 'accept' &&
327+
validatedResult.content &&
328+
requestedSchema &&
329+
this._capabilities.elicitation?.form?.applyDefaults
330+
) {
331+
try {
332+
applyElicitationDefaults(requestedSchema, validatedResult.content);
333+
} catch {
334+
// gracefully ignore errors in default application
339335
}
336+
}
340337

341-
return validatedResult;
342-
};
338+
return validatedResult;
343339
}
344340

345-
if (method === 'sampling/createMessage') {
346-
return async (request, ctx) => {
347-
const validatedRequest = parseSchema(CreateMessageRequestSchema, request);
348-
if (!validatedRequest.success) {
349-
const errorMessage =
350-
validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error);
351-
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling request: ${errorMessage}`);
352-
}
341+
if (request.method === 'sampling/createMessage') {
342+
const validatedRequest = parseSchema(CreateMessageRequestSchema, request);
343+
if (!validatedRequest.success) {
344+
const errorMessage =
345+
validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error);
346+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling request: ${errorMessage}`);
347+
}
353348

354-
const { params } = validatedRequest.data;
349+
const { params } = validatedRequest.data;
355350

356-
const result = await handler(request, ctx);
351+
const result = await next();
357352

358-
const hasTools = params.tools || params.toolChoice;
359-
const resultSchema = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema;
360-
const validationResult = parseSchema(resultSchema, result);
361-
if (!validationResult.success) {
362-
const errorMessage =
363-
validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error);
364-
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling result: ${errorMessage}`);
365-
}
353+
const hasTools = params.tools || params.toolChoice;
354+
const resultSchema = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema;
355+
const validationResult = parseSchema(resultSchema, result);
356+
if (!validationResult.success) {
357+
const errorMessage =
358+
validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error);
359+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling result: ${errorMessage}`);
360+
}
366361

367-
return validationResult.data;
368-
};
362+
return validationResult.data;
369363
}
370364

371-
return handler;
372-
}
365+
return next();
366+
};
373367

374368
protected assertCapability(capability: keyof ServerCapabilities, method: string): void {
375369
if (!this._serverCapabilities?.[capability]) {

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

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

41+
// Dispatcher types (handler registry; consumed by Protocol)
42+
export type { RequestHandlerSchemas } from '../../shared/dispatcher.js';
43+
4144
// Protocol types (NOT the Protocol class itself or mergeCapabilities)
4245
export type {
4346
BaseContext,
4447
ClientContext,
4548
NotificationOptions,
4649
ProgressCallback,
4750
ProtocolOptions,
48-
RequestHandlerSchemas,
4951
RequestOptions,
5052
ServerContext
5153
} from '../../shared/protocol.js';

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ 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/dispatcher.js';
56
export * from './shared/metadataUtils.js';
67
export * from './shared/protocol.js';
78
export * from './shared/stdio.js';

0 commit comments

Comments
 (0)