Skip to content

Commit 5018344

Browse files
Merge origin/fweinberger/v2-bc-ctx-flat-getters into v2-bc-d1-base
2 parents 5185e12 + 72bae92 commit 5018344

4 files changed

Lines changed: 191 additions & 1 deletion

File tree

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 deprecated flat-field getters (`signal`, `requestId`, `_meta`, `authInfo`, `sendNotification`, `sendRequest`) on the handler context that forward to the nested `ctx.mcpReq` / `ctx.http` fields, plus the `RequestHandlerExtra` type alias. Allows v1 handler signatures to compile and run unchanged. Removed in v3.

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export type {
5050
NotificationOptions,
5151
ProgressCallback,
5252
ProtocolOptions,
53+
// eslint-disable-next-line @typescript-eslint/no-deprecated
54+
RequestHandlerExtra,
5355
RequestOptions,
5456
ServerContext
5557
} from '../../shared/protocol.js';

packages/core/src/shared/protocol.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,25 @@ export type BaseContext = {
231231
* Task context, available when task storage is configured.
232232
*/
233233
task?: TaskContext;
234+
235+
/** @deprecated Use `ctx.mcpReq.signal`. Removed in v3. */
236+
signal: AbortSignal;
237+
/** @deprecated Use `ctx.mcpReq.id`. Removed in v3. */
238+
requestId: RequestId;
239+
/** @deprecated Use `ctx.mcpReq._meta`. Removed in v3. */
240+
_meta?: RequestMeta;
241+
/** @deprecated Use `ctx.http?.authInfo`. Removed in v3. */
242+
authInfo?: AuthInfo;
243+
/** @deprecated Use `ctx.mcpReq.notify`. Removed in v3. */
244+
sendNotification: (notification: Notification) => Promise<void>;
245+
/** @deprecated Use `ctx.mcpReq.send`. Removed in v3. */
246+
sendRequest: <T extends AnySchema>(request: Request, resultSchema: T, options?: RequestOptions) => Promise<SchemaOutput<T>>;
247+
/** @deprecated Use `ctx.task?.store`. Removed in v3. */
248+
taskStore?: TaskContext['store'];
249+
/** @deprecated Use `ctx.task?.id`. Removed in v3. */
250+
taskId?: TaskContext['id'];
251+
/** @deprecated Use `ctx.task?.requestedTtl`. Removed in v3. */
252+
taskRequestedTtl?: TaskContext['requestedTtl'];
234253
};
235254

236255
/**
@@ -283,6 +302,54 @@ export type ServerContext = BaseContext & {
283302
*/
284303
export type ClientContext = BaseContext;
285304

305+
/**
306+
* Flat-field shape of the v1 handler context. v2 nests these under
307+
* {@linkcode BaseContext.mcpReq | ctx.mcpReq} / {@linkcode BaseContext.http | ctx.http}.
308+
* Kept as deprecated forwarding properties on the context object so v1 handlers
309+
* compile and run unchanged.
310+
*
311+
* @deprecated Use the nested fields on `ctx.mcpReq` / `ctx.http` instead. Will be removed in v3.
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+
// --- v1-compat: flat ctx.* getters (internal) ---
317+
318+
function legacyFieldGetter(key: string, target: string, value: () => unknown): PropertyDescriptor {
319+
return {
320+
enumerable: false,
321+
configurable: true,
322+
get() {
323+
deprecate(`ctx.${key}`, `ctx.${key} is deprecated. Use ${target} instead. Removed in v3.`);
324+
return value();
325+
}
326+
};
327+
}
328+
329+
/**
330+
* Attaches v1's flat `extra.*` fields to the context object as deprecated
331+
* forwarding getters. v2 nests these under `ctx.mcpReq` / `ctx.http`.
332+
*
333+
* @internal
334+
*/
335+
function attachLegacyContextFields(
336+
ctx: BaseContext,
337+
sendRequest: <T extends AnySchema>(r: Request, s: T, o?: RequestOptions) => Promise<SchemaOutput<T>>,
338+
sendNotification: (n: Notification) => Promise<void>
339+
): void {
340+
Object.defineProperties(ctx, {
341+
signal: legacyFieldGetter('signal', 'ctx.mcpReq.signal', () => ctx.mcpReq.signal),
342+
requestId: legacyFieldGetter('requestId', 'ctx.mcpReq.id', () => ctx.mcpReq.id),
343+
_meta: legacyFieldGetter('_meta', 'ctx.mcpReq._meta', () => ctx.mcpReq._meta),
344+
authInfo: legacyFieldGetter('authInfo', 'ctx.http?.authInfo', () => ctx.http?.authInfo),
345+
sendNotification: legacyFieldGetter('sendNotification', 'ctx.mcpReq.notify', () => sendNotification),
346+
sendRequest: legacyFieldGetter('sendRequest', 'ctx.mcpReq.send', () => sendRequest),
347+
taskStore: legacyFieldGetter('taskStore', 'ctx.task?.store', () => ctx.task?.store),
348+
taskId: legacyFieldGetter('taskId', 'ctx.task?.id', () => ctx.task?.id),
349+
taskRequestedTtl: legacyFieldGetter('taskRequestedTtl', 'ctx.task?.requestedTtl', () => ctx.task?.requestedTtl)
350+
});
351+
}
352+
286353
/**
287354
* Information about a request's timeout state
288355
*/
@@ -652,8 +719,11 @@ export abstract class Protocol<ContextT extends BaseContext = BaseContext, S ext
652719
},
653720
http: extra?.authInfo ? { authInfo: extra.authInfo } : undefined,
654721
task: taskContext
655-
};
722+
// Deprecated flat fields (signal, requestId, sendNotification, sendRequest, task*) are
723+
// attached as getters by attachLegacyContextFields() below.
724+
} as BaseContext;
656725
const ctx = this.buildContext(baseCtx, extra);
726+
attachLegacyContextFields(ctx, sendRequest, sendNotification);
657727

658728
// Starting with Promise.resolve() puts any synchronous errors into the monad as well.
659729
Promise.resolve()
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/* eslint-disable @typescript-eslint/no-deprecated */
2+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
3+
4+
import type { BaseContext, RequestHandlerExtra, ServerContext } from '../../src/shared/protocol.js';
5+
import { Protocol } from '../../src/shared/protocol.js';
6+
import type { Transport } from '../../src/shared/transport.js';
7+
import type { JSONRPCMessage } from '../../src/types/index.js';
8+
import { _resetDeprecationWarnings } from '../../src/util/deprecate.js';
9+
10+
class TestProtocolImpl extends Protocol<BaseContext> {
11+
protected assertCapabilityForMethod(): void {}
12+
protected assertNotificationCapability(): void {}
13+
protected assertRequestHandlerCapability(): void {}
14+
protected assertTaskCapability(): void {}
15+
protected assertTaskHandlerCapability(): void {}
16+
protected buildContext(ctx: BaseContext): BaseContext {
17+
return ctx;
18+
}
19+
}
20+
21+
class MockTransport implements Transport {
22+
onclose?: () => void;
23+
onerror?: (error: Error) => void;
24+
onmessage?: (message: unknown) => void;
25+
26+
async start(): Promise<void> {}
27+
async close(): Promise<void> {
28+
this.onclose?.();
29+
}
30+
async send(_message: JSONRPCMessage): Promise<void> {}
31+
}
32+
33+
describe('v1-compat: flat ctx.* getters', () => {
34+
let protocol: Protocol<BaseContext>;
35+
let transport: MockTransport;
36+
let warnSpy: ReturnType<typeof vi.spyOn>;
37+
38+
beforeEach(() => {
39+
_resetDeprecationWarnings();
40+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
41+
protocol = new TestProtocolImpl();
42+
transport = new MockTransport();
43+
});
44+
45+
afterEach(() => {
46+
warnSpy.mockRestore();
47+
});
48+
49+
test('ctx.signal forwards to ctx.mcpReq.signal and warns once', async () => {
50+
await protocol.connect(transport);
51+
52+
let captured: BaseContext | undefined;
53+
const done = new Promise<void>(resolve => {
54+
protocol.setRequestHandler('ping', (_request, ctx) => {
55+
captured = ctx;
56+
resolve();
57+
return {};
58+
});
59+
});
60+
61+
transport.onmessage?.({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} });
62+
await done;
63+
64+
expect(captured).toBeDefined();
65+
const ctx = captured as BaseContext;
66+
67+
// Flat getters forward to nested fields
68+
expect(ctx.signal).toBe(ctx.mcpReq.signal);
69+
expect(ctx.requestId).toBe(ctx.mcpReq.id);
70+
expect(ctx._meta).toBe(ctx.mcpReq._meta);
71+
expect(ctx.authInfo).toBe(ctx.http?.authInfo);
72+
expect(ctx.sendNotification).toBeTypeOf('function');
73+
expect(ctx.sendRequest).toBeTypeOf('function');
74+
75+
// Each key warns once; second access does not re-warn
76+
const before = warnSpy.mock.calls.length;
77+
expect(ctx.signal).toBe(ctx.mcpReq.signal);
78+
expect(warnSpy.mock.calls.length).toBe(before);
79+
80+
// Exactly one warning per distinct key (6 keys touched above)
81+
expect(warnSpy).toHaveBeenCalledTimes(6);
82+
expect(warnSpy.mock.calls[0]?.[0]).toContain('ctx.signal is deprecated');
83+
});
84+
85+
test('flat getters are non-enumerable (do not pollute spreads)', async () => {
86+
await protocol.connect(transport);
87+
88+
let captured: BaseContext | undefined;
89+
const done = new Promise<void>(resolve => {
90+
protocol.setRequestHandler('ping', (_request, ctx) => {
91+
captured = ctx;
92+
resolve();
93+
return {};
94+
});
95+
});
96+
97+
transport.onmessage?.({ jsonrpc: '2.0', id: 2, method: 'ping', params: {} });
98+
await done;
99+
100+
const keys = Object.keys(captured as BaseContext);
101+
expect(keys).not.toContain('signal');
102+
expect(keys).not.toContain('requestId');
103+
// Spreading should not trigger the deprecated getter
104+
void { ...(captured as BaseContext) };
105+
expect(warnSpy).not.toHaveBeenCalled();
106+
});
107+
108+
test('RequestHandlerExtra<R, N> is a ServerContext alias (type-level)', () => {
109+
// Compile-time assertion: RequestHandlerExtra is assignable to/from ServerContext.
110+
const check = (ctx: ServerContext): RequestHandlerExtra<unknown, unknown> => ctx;
111+
void check;
112+
});
113+
});

0 commit comments

Comments
 (0)