Skip to content

Commit 399f678

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

File tree

4 files changed

+169
-2
lines changed

4 files changed

+169
-2
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-field getters (`signal`, `requestId`, `_meta`, `authInfo`, `sendNotification`, `sendRequest`, `taskStore`, `taskId`, `taskRequestedTtl`) on the handler context that forward to the nested `ctx.mcpReq` / `ctx.http` / `ctx.task` fields, plus the `RequestHandlerExtra` type alias. Allows v1 handler signatures to compile and run unchanged.

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: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,27 @@ export type NotificationOptions = {
162162
relatedTask?: RelatedTaskMetadata;
163163
};
164164

165+
/**
166+
* v1-compat flat aliases — populated at runtime by {@linkcode attachLegacyContextFields}.
167+
* The v2 nested forms (`ctx.mcpReq.*`, `ctx.http?.*`, `ctx.task?.*`) are preferred.
168+
* Do not add new fields here.
169+
*/
170+
interface LegacyContextFields {
171+
signal: AbortSignal;
172+
requestId: RequestId;
173+
_meta?: RequestMeta;
174+
authInfo?: AuthInfo;
175+
sendNotification: (notification: Notification) => Promise<void>;
176+
sendRequest: <T extends AnySchema>(request: Request, resultSchema: T, options?: RequestOptions) => Promise<SchemaOutput<T>>;
177+
taskStore?: TaskContext['store'];
178+
taskId?: TaskContext['id'];
179+
taskRequestedTtl?: TaskContext['requestedTtl'];
180+
}
181+
165182
/**
166183
* Base context provided to all request handlers.
167184
*/
168-
export type BaseContext = {
185+
export type BaseContext = LegacyContextFields & {
169186
/**
170187
* The session ID from the transport, if available.
171188
*/
@@ -279,6 +296,43 @@ export type ServerContext = BaseContext & {
279296
*/
280297
export type ClientContext = BaseContext;
281298

299+
/**
300+
* v1 name for the handler context. v2 also exposes the same data under
301+
* {@linkcode BaseContext.mcpReq | ctx.mcpReq} / {@linkcode BaseContext.http | ctx.http};
302+
* the flat fields remain available so existing handlers compile and run unchanged.
303+
*/
304+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- phantom params kept for v1 source compatibility
305+
export type RequestHandlerExtra<_Req = unknown, _Notif = unknown> = ServerContext;
306+
307+
// --- v1-compat: flat ctx.* getters (internal) ---
308+
309+
function legacyFieldGetter(value: () => unknown): PropertyDescriptor {
310+
return { enumerable: false, configurable: true, get: value };
311+
}
312+
313+
/**
314+
* Attaches v1's flat `extra.*` fields to the context object as forwarding getters.
315+
* v2 also exposes these under `ctx.mcpReq` / `ctx.http` / `ctx.task`.
316+
*
317+
* @internal
318+
*/
319+
function attachLegacyContextFields(
320+
ctx: BaseContext,
321+
sendRequest: <T extends AnySchema>(r: Request, s: T, o?: RequestOptions) => Promise<SchemaOutput<T>>
322+
): void {
323+
Object.defineProperties(ctx, {
324+
signal: legacyFieldGetter(() => ctx.mcpReq.signal),
325+
requestId: legacyFieldGetter(() => ctx.mcpReq.id),
326+
_meta: legacyFieldGetter(() => ctx.mcpReq._meta),
327+
authInfo: legacyFieldGetter(() => ctx.http?.authInfo),
328+
sendNotification: legacyFieldGetter(() => ctx.mcpReq.notify),
329+
sendRequest: legacyFieldGetter(() => sendRequest),
330+
taskStore: legacyFieldGetter(() => ctx.task?.store),
331+
taskId: legacyFieldGetter(() => ctx.task?.id),
332+
taskRequestedTtl: legacyFieldGetter(() => ctx.task?.requestedTtl)
333+
});
334+
}
335+
282336
/**
283337
* Information about a request's timeout state
284338
*/
@@ -604,8 +658,12 @@ export abstract class Protocol<ContextT extends BaseContext> {
604658
},
605659
http: extra?.authInfo ? { authInfo: extra.authInfo } : undefined,
606660
task: taskContext
607-
};
661+
// v1-compat flat fields (signal, requestId, sendNotification, sendRequest, task*) are
662+
// attached as non-enumerable getters by attachLegacyContextFields() below; cast because
663+
// the literal above doesn't include them.
664+
} as BaseContext;
608665
const ctx = this.buildContext(baseCtx, extra);
666+
attachLegacyContextFields(ctx, sendRequest);
609667

610668
// Starting with Promise.resolve() puts any synchronous errors into the monad as well.
611669
Promise.resolve()
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
2+
3+
import type { BaseContext, 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<BaseContext> {
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+
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.* getters', () => {
32+
let protocol: Protocol<BaseContext>;
33+
let transport: MockTransport;
34+
let warnSpy: ReturnType<typeof vi.spyOn>;
35+
36+
beforeEach(() => {
37+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
38+
protocol = new TestProtocolImpl();
39+
transport = new MockTransport();
40+
});
41+
42+
afterEach(() => {
43+
warnSpy.mockRestore();
44+
});
45+
46+
test('flat getters forward to nested fields without warning', async () => {
47+
await protocol.connect(transport);
48+
49+
let captured: BaseContext | undefined;
50+
const done = new Promise<void>(resolve => {
51+
protocol.setRequestHandler('ping', (_request, ctx) => {
52+
captured = ctx;
53+
resolve();
54+
return {};
55+
});
56+
});
57+
58+
transport.onmessage?.({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} });
59+
await done;
60+
61+
expect(captured).toBeDefined();
62+
const ctx = captured as BaseContext;
63+
64+
expect(ctx.signal).toBe(ctx.mcpReq.signal);
65+
expect(ctx.requestId).toBe(ctx.mcpReq.id);
66+
expect(ctx._meta).toBe(ctx.mcpReq._meta);
67+
expect(ctx.authInfo).toBe(ctx.http?.authInfo);
68+
expect(ctx.sendNotification).toBe(ctx.mcpReq.notify);
69+
expect(ctx.sendRequest).toBeTypeOf('function');
70+
expect(ctx.taskStore).toBe(ctx.task?.store);
71+
expect(ctx.taskId).toBe(ctx.task?.id);
72+
expect(ctx.taskRequestedTtl).toBe(ctx.task?.requestedTtl);
73+
74+
expect(warnSpy).not.toHaveBeenCalled();
75+
});
76+
77+
test('flat getters are non-enumerable (do not pollute spreads)', async () => {
78+
await protocol.connect(transport);
79+
80+
let captured: BaseContext | undefined;
81+
const done = new Promise<void>(resolve => {
82+
protocol.setRequestHandler('ping', (_request, ctx) => {
83+
captured = ctx;
84+
resolve();
85+
return {};
86+
});
87+
});
88+
89+
transport.onmessage?.({ jsonrpc: '2.0', id: 2, method: 'ping', params: {} });
90+
await done;
91+
92+
const keys = Object.keys(captured as BaseContext);
93+
expect(keys).not.toContain('signal');
94+
expect(keys).not.toContain('requestId');
95+
void { ...(captured as BaseContext) };
96+
expect(warnSpy).not.toHaveBeenCalled();
97+
});
98+
99+
test('RequestHandlerExtra<R, N> is a ServerContext alias (type-level)', () => {
100+
const check = (ctx: ServerContext): RequestHandlerExtra<unknown, unknown> => ctx;
101+
void check;
102+
});
103+
});

0 commit comments

Comments
 (0)