Skip to content

Commit 3bab1dd

Browse files
feat(compat): flat ctx.* getters + RequestHandlerExtra type alias
1 parent 9ed62fe commit 3bab1dd

File tree

6 files changed

+140
-6
lines changed

6 files changed

+140
-6
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/core': patch
3+
---
4+
5+
v1-compat: add flat fields (`signal`, `requestId`, `_meta`, `authInfo`, `sendNotification`, `sendRequest`, `taskStore`, `taskId`, `taskRequestedTtl`) on the handler context (`ClientContext`/`ServerContext`) mirroring the nested `ctx.mcpReq` / `ctx.http` / `ctx.task` fields, plus the `RequestHandlerExtra` type alias. Allows v1 handler signatures to compile and run unchanged.

packages/client/src/client/client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
JsonSchemaType,
1414
JsonSchemaValidator,
1515
jsonSchemaValidator,
16+
LegacyContextFields,
1617
ListChangedHandlers,
1718
ListChangedOptions,
1819
ListPromptsRequest,
@@ -245,7 +246,7 @@ export class Client extends Protocol<ClientContext> {
245246
}
246247
}
247248

248-
protected override buildContext(ctx: BaseContext, _transportInfo?: MessageExtraInfo): ClientContext {
249+
protected override buildContext(ctx: BaseContext & LegacyContextFields, _transportInfo?: MessageExtraInfo): ClientContext {
249250
return ctx;
250251
}
251252

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export type {
4545
NotificationOptions,
4646
ProgressCallback,
4747
ProtocolOptions,
48+
RequestHandlerExtra,
4849
RequestOptions,
4950
ServerContext
5051
} from '../../shared/protocol.js';

packages/core/src/shared/protocol.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,32 @@ export type NotificationOptions = {
162162
relatedTask?: RelatedTaskMetadata;
163163
};
164164

165+
/**
166+
* v1-compat flat aliases — added by `withLegacyContextFields`.
167+
* The v2 nested forms (`ctx.mcpReq.*`, `ctx.http?.*`, `ctx.task?.*`) are preferred.
168+
* Do not add new fields here.
169+
*/
170+
export interface LegacyContextFields {
171+
/** @deprecated Use `ctx.mcpReq.signal` */
172+
signal: AbortSignal;
173+
/** @deprecated Use `ctx.mcpReq.id` */
174+
requestId: RequestId;
175+
/** @deprecated Use `ctx.mcpReq._meta` */
176+
_meta?: RequestMeta;
177+
/** @deprecated Use `ctx.http?.authInfo` */
178+
authInfo?: AuthInfo;
179+
/** @deprecated Use `ctx.mcpReq.notify` */
180+
sendNotification: (notification: Notification) => Promise<void>;
181+
/** @deprecated Use `ctx.mcpReq.send` */
182+
sendRequest: <T extends AnySchema>(request: Request, resultSchema: T, options?: RequestOptions) => Promise<SchemaOutput<T>>;
183+
/** @deprecated Use `ctx.task?.store` */
184+
taskStore?: TaskContext['store'];
185+
/** @deprecated Use `ctx.task?.id` */
186+
taskId?: TaskContext['id'];
187+
/** @deprecated Use `ctx.task?.requestedTtl` */
188+
taskRequestedTtl?: TaskContext['requestedTtl'];
189+
}
190+
165191
/**
166192
* Base context provided to all request handlers.
167193
*/
@@ -272,12 +298,45 @@ export type ServerContext = BaseContext & {
272298
*/
273299
closeStandaloneSSE?: () => void;
274300
};
275-
};
301+
} & LegacyContextFields;
276302

277303
/**
278304
* Context provided to client-side request handlers.
279305
*/
280-
export type ClientContext = BaseContext;
306+
export type ClientContext = BaseContext & LegacyContextFields;
307+
308+
/**
309+
* v1 name for the handler context. v2 also exposes the same data under
310+
* `ctx.mcpReq` / `ctx.http`; the flat fields remain available so existing
311+
* handlers compile and run unchanged. See {@linkcode BaseContext}.
312+
*/
313+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- phantom params kept for v1 source compatibility
314+
export type RequestHandlerExtra<_Req = unknown, _Notif = unknown> = ServerContext;
315+
316+
/**
317+
* Returns a copy of `ctx` with v1's flat `extra.*` aliases populated as plain properties
318+
* mirroring the nested v2 fields. Intersected onto `ClientContext`/`ServerContext` so
319+
* existing handlers that read `extra.signal` etc. compile and run unchanged.
320+
*
321+
* @internal
322+
*/
323+
function withLegacyContextFields<T extends BaseContext>(
324+
ctx: T,
325+
sendRequest: <S extends AnySchema>(r: Request, s: S, o?: RequestOptions) => Promise<SchemaOutput<S>>
326+
): T & LegacyContextFields {
327+
return {
328+
...ctx,
329+
signal: ctx.mcpReq.signal,
330+
requestId: ctx.mcpReq.id,
331+
_meta: ctx.mcpReq._meta,
332+
authInfo: ctx.http?.authInfo,
333+
sendNotification: ctx.mcpReq.notify,
334+
sendRequest,
335+
taskStore: ctx.task?.store,
336+
taskId: ctx.task?.id,
337+
taskRequestedTtl: ctx.task?.requestedTtl
338+
};
339+
}
281340

282341
/**
283342
* Information about a request's timeout state
@@ -393,7 +452,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
393452
* Builds the context object for request handlers. Subclasses must override
394453
* to return the appropriate context type (e.g., ServerContext adds HTTP request info).
395454
*/
396-
protected abstract buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ContextT;
455+
protected abstract buildContext(ctx: BaseContext & LegacyContextFields, transportInfo?: MessageExtraInfo): ContextT;
397456

398457
private async _oncancel(notification: CancelledNotification): Promise<void> {
399458
if (!notification.params.requestId) {
@@ -605,7 +664,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
605664
http: extra?.authInfo ? { authInfo: extra.authInfo } : undefined,
606665
task: taskContext
607666
};
608-
const ctx = this.buildContext(baseCtx, extra);
667+
const ctx = this.buildContext(withLegacyContextFields(baseCtx, sendRequest), extra);
609668

610669
// Starting with Promise.resolve() puts any synchronous errors into the monad as well.
611670
Promise.resolve()
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, expect, test } from 'vitest';
2+
3+
import type { BaseContext, ClientContext, LegacyContextFields, RequestHandlerExtra, ServerContext } from '../../src/shared/protocol.js';
4+
import { Protocol } from '../../src/shared/protocol.js';
5+
import type { Transport } from '../../src/shared/transport.js';
6+
import type { JSONRPCMessage } from '../../src/types/index.js';
7+
8+
class TestProtocolImpl extends Protocol<ClientContext> {
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 & LegacyContextFields): ClientContext {
15+
return ctx;
16+
}
17+
}
18+
19+
class MockTransport implements Transport {
20+
onclose?: () => void;
21+
onerror?: (error: Error) => void;
22+
onmessage?: (message: unknown) => void;
23+
24+
async start(): Promise<void> {}
25+
async close(): Promise<void> {
26+
this.onclose?.();
27+
}
28+
async send(_message: JSONRPCMessage): Promise<void> {}
29+
}
30+
31+
describe('v1-compat: flat ctx.* fields', () => {
32+
test('flat fields mirror nested v2 fields', async () => {
33+
const protocol = new TestProtocolImpl();
34+
const transport = new MockTransport();
35+
await protocol.connect(transport);
36+
37+
let captured: ClientContext | undefined;
38+
const done = new Promise<void>(resolve => {
39+
protocol.setRequestHandler('ping', (_request, ctx) => {
40+
captured = ctx;
41+
resolve();
42+
return {};
43+
});
44+
});
45+
46+
transport.onmessage?.({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} });
47+
await done;
48+
49+
expect(captured).toBeDefined();
50+
const ctx = captured!;
51+
52+
expect(ctx.signal).toBe(ctx.mcpReq.signal);
53+
expect(ctx.requestId).toBe(ctx.mcpReq.id);
54+
expect(ctx._meta).toBe(ctx.mcpReq._meta);
55+
expect(ctx.authInfo).toBe(ctx.http?.authInfo);
56+
expect(ctx.sendNotification).toBe(ctx.mcpReq.notify);
57+
expect(ctx.sendRequest).toBeTypeOf('function');
58+
expect(ctx.taskStore).toBe(ctx.task?.store);
59+
expect(ctx.taskId).toBe(ctx.task?.id);
60+
expect(ctx.taskRequestedTtl).toBe(ctx.task?.requestedTtl);
61+
});
62+
63+
test('RequestHandlerExtra<R, N> is a ServerContext alias (type-level)', () => {
64+
const check = (ctx: ServerContext): RequestHandlerExtra<unknown, unknown> => ctx;
65+
void check;
66+
});
67+
});

packages/server/src/server/server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
InitializeResult,
1515
JsonSchemaType,
1616
jsonSchemaValidator,
17+
LegacyContextFields,
1718
ListRootsRequest,
1819
LoggingLevel,
1920
LoggingMessageNotification,
@@ -153,7 +154,7 @@ export class Server extends Protocol<ServerContext> {
153154
});
154155
}
155156

156-
protected override buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ServerContext {
157+
protected override buildContext(ctx: BaseContext & LegacyContextFields, transportInfo?: MessageExtraInfo): ServerContext {
157158
// Only create http when there's actual HTTP transport info or auth info
158159
const hasHttpInfo = ctx.http || transportInfo?.request || transportInfo?.closeSSEStream || transportInfo?.closeStandaloneSSEStream;
159160
return {

0 commit comments

Comments
 (0)