Skip to content

Commit 72bae92

Browse files
feat(compat): add deprecated flat ctx.* getters + RequestHandlerExtra alias
v1 handler signatures read context fields flat (extra.signal, extra.requestId, extra._meta, extra.authInfo, extra.sendNotification, extra.sendRequest). v2 nests these under ctx.mcpReq / ctx.http. This adds non-enumerable forwarding getters on the context object so v1 handlers compile and run unchanged, with a one-time stderr deprecation warning per field. Also adds the RequestHandlerExtra<R, N> type alias (= ServerContext) so v1 type imports keep resolving. All marked @deprecated; removed in v3.
1 parent a33c305 commit 72bae92

4 files changed

Lines changed: 192 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
@@ -45,6 +45,8 @@ export type {
4545
NotificationOptions,
4646
ProgressCallback,
4747
ProtocolOptions,
48+
// eslint-disable-next-line @typescript-eslint/no-deprecated
49+
RequestHandlerExtra,
4850
RequestOptions,
4951
ServerContext
5052
} from '../../shared/protocol.js';

packages/core/src/shared/protocol.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
ProtocolErrorCode,
4545
SUPPORTED_PROTOCOL_VERSIONS
4646
} from '../types/index.js';
47+
import { deprecate } from '../util/deprecate.js';
4748
import type { AnySchema, SchemaOutput } from '../util/schema.js';
4849
import { parseSchema } from '../util/schema.js';
4950
import type { TaskContext, TaskManagerHost, TaskManagerOptions, TaskRequestOptions } from './taskManager.js';
@@ -227,6 +228,25 @@ export type BaseContext = {
227228
* Task context, available when task storage is configured.
228229
*/
229230
task?: TaskContext;
231+
232+
/** @deprecated Use `ctx.mcpReq.signal`. Removed in v3. */
233+
signal: AbortSignal;
234+
/** @deprecated Use `ctx.mcpReq.id`. Removed in v3. */
235+
requestId: RequestId;
236+
/** @deprecated Use `ctx.mcpReq._meta`. Removed in v3. */
237+
_meta?: RequestMeta;
238+
/** @deprecated Use `ctx.http?.authInfo`. Removed in v3. */
239+
authInfo?: AuthInfo;
240+
/** @deprecated Use `ctx.mcpReq.notify`. Removed in v3. */
241+
sendNotification: (notification: Notification) => Promise<void>;
242+
/** @deprecated Use `ctx.mcpReq.send`. Removed in v3. */
243+
sendRequest: <T extends AnySchema>(request: Request, resultSchema: T, options?: RequestOptions) => Promise<SchemaOutput<T>>;
244+
/** @deprecated Use `ctx.task?.store`. Removed in v3. */
245+
taskStore?: TaskContext['store'];
246+
/** @deprecated Use `ctx.task?.id`. Removed in v3. */
247+
taskId?: TaskContext['id'];
248+
/** @deprecated Use `ctx.task?.requestedTtl`. Removed in v3. */
249+
taskRequestedTtl?: TaskContext['requestedTtl'];
230250
};
231251

232252
/**
@@ -279,6 +299,54 @@ export type ServerContext = BaseContext & {
279299
*/
280300
export type ClientContext = BaseContext;
281301

302+
/**
303+
* Flat-field shape of the v1 handler context. v2 nests these under
304+
* {@linkcode BaseContext.mcpReq | ctx.mcpReq} / {@linkcode BaseContext.http | ctx.http}.
305+
* Kept as deprecated forwarding properties on the context object so v1 handlers
306+
* compile and run unchanged.
307+
*
308+
* @deprecated Use the nested fields on `ctx.mcpReq` / `ctx.http` instead. Will be removed in v3.
309+
*/
310+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- phantom params kept for v1 source compatibility
311+
export type RequestHandlerExtra<_Req = unknown, _Notif = unknown> = ServerContext;
312+
313+
// --- v1-compat: flat ctx.* getters (internal) ---
314+
315+
function legacyFieldGetter(key: string, target: string, value: () => unknown): PropertyDescriptor {
316+
return {
317+
enumerable: false,
318+
configurable: true,
319+
get() {
320+
deprecate(`ctx.${key}`, `ctx.${key} is deprecated. Use ${target} instead. Removed in v3.`);
321+
return value();
322+
}
323+
};
324+
}
325+
326+
/**
327+
* Attaches v1's flat `extra.*` fields to the context object as deprecated
328+
* forwarding getters. v2 nests these under `ctx.mcpReq` / `ctx.http`.
329+
*
330+
* @internal
331+
*/
332+
function attachLegacyContextFields(
333+
ctx: BaseContext,
334+
sendRequest: <T extends AnySchema>(r: Request, s: T, o?: RequestOptions) => Promise<SchemaOutput<T>>,
335+
sendNotification: (n: Notification) => Promise<void>
336+
): void {
337+
Object.defineProperties(ctx, {
338+
signal: legacyFieldGetter('signal', 'ctx.mcpReq.signal', () => ctx.mcpReq.signal),
339+
requestId: legacyFieldGetter('requestId', 'ctx.mcpReq.id', () => ctx.mcpReq.id),
340+
_meta: legacyFieldGetter('_meta', 'ctx.mcpReq._meta', () => ctx.mcpReq._meta),
341+
authInfo: legacyFieldGetter('authInfo', 'ctx.http?.authInfo', () => ctx.http?.authInfo),
342+
sendNotification: legacyFieldGetter('sendNotification', 'ctx.mcpReq.notify', () => sendNotification),
343+
sendRequest: legacyFieldGetter('sendRequest', 'ctx.mcpReq.send', () => sendRequest),
344+
taskStore: legacyFieldGetter('taskStore', 'ctx.task?.store', () => ctx.task?.store),
345+
taskId: legacyFieldGetter('taskId', 'ctx.task?.id', () => ctx.task?.id),
346+
taskRequestedTtl: legacyFieldGetter('taskRequestedTtl', 'ctx.task?.requestedTtl', () => ctx.task?.requestedTtl)
347+
});
348+
}
349+
282350
/**
283351
* Information about a request's timeout state
284352
*/
@@ -604,8 +672,11 @@ export abstract class Protocol<ContextT extends BaseContext> {
604672
},
605673
http: extra?.authInfo ? { authInfo: extra.authInfo } : undefined,
606674
task: taskContext
607-
};
675+
// Deprecated flat fields (signal, requestId, sendNotification, sendRequest, task*) are
676+
// attached as getters by attachLegacyContextFields() below.
677+
} as BaseContext;
608678
const ctx = this.buildContext(baseCtx, extra);
679+
attachLegacyContextFields(ctx, sendRequest, sendNotification);
609680

610681
// Starting with Promise.resolve() puts any synchronous errors into the monad as well.
611682
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)