diff --git a/.changeset/ctx-flat-compat-getters.md b/.changeset/ctx-flat-compat-getters.md new file mode 100644 index 000000000..75f68fba6 --- /dev/null +++ b/.changeset/ctx-flat-compat-getters.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +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. diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 2dc1e13a8..7d5b7501e 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -45,6 +45,7 @@ export type { NotificationOptions, ProgressCallback, ProtocolOptions, + RequestHandlerExtra, RequestOptions, ServerContext } from '../../shared/protocol.js'; diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 57eab6932..0b46d296e 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -162,10 +162,27 @@ export type NotificationOptions = { relatedTask?: RelatedTaskMetadata; }; +/** + * v1-compat flat aliases — populated at runtime by {@linkcode attachLegacyContextFields}. + * The v2 nested forms (`ctx.mcpReq.*`, `ctx.http?.*`, `ctx.task?.*`) are preferred. + * Do not add new fields here. + */ +interface LegacyContextFields { + signal: AbortSignal; + requestId: RequestId; + _meta?: RequestMeta; + authInfo?: AuthInfo; + sendNotification: (notification: Notification) => Promise; + sendRequest: (request: Request, resultSchema: T, options?: RequestOptions) => Promise>; + taskStore?: TaskContext['store']; + taskId?: TaskContext['id']; + taskRequestedTtl?: TaskContext['requestedTtl']; +} + /** * Base context provided to all request handlers. */ -export type BaseContext = { +export type BaseContext = LegacyContextFields & { /** * The session ID from the transport, if available. */ @@ -279,6 +296,43 @@ export type ServerContext = BaseContext & { */ export type ClientContext = BaseContext; +/** + * v1 name for the handler context. v2 also exposes the same data under + * `ctx.mcpReq` / `ctx.http`; the flat fields remain available so existing + * handlers compile and run unchanged. See {@linkcode BaseContext}. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- phantom params kept for v1 source compatibility +export type RequestHandlerExtra<_Req = unknown, _Notif = unknown> = ServerContext; + +// --- v1-compat: flat ctx.* getters (internal) --- + +function legacyFieldGetter(value: () => unknown): PropertyDescriptor { + return { enumerable: false, configurable: true, get: value }; +} + +/** + * Attaches v1's flat `extra.*` fields to the context object as forwarding getters. + * v2 also exposes these under `ctx.mcpReq` / `ctx.http` / `ctx.task`. + * + * @internal + */ +function attachLegacyContextFields( + ctx: BaseContext, + sendRequest: (r: Request, s: T, o?: RequestOptions) => Promise> +): void { + Object.defineProperties(ctx, { + signal: legacyFieldGetter(() => ctx.mcpReq.signal), + requestId: legacyFieldGetter(() => ctx.mcpReq.id), + _meta: legacyFieldGetter(() => ctx.mcpReq._meta), + authInfo: legacyFieldGetter(() => ctx.http?.authInfo), + sendNotification: legacyFieldGetter(() => ctx.mcpReq.notify), + sendRequest: legacyFieldGetter(() => sendRequest), + taskStore: legacyFieldGetter(() => ctx.task?.store), + taskId: legacyFieldGetter(() => ctx.task?.id), + taskRequestedTtl: legacyFieldGetter(() => ctx.task?.requestedTtl) + }); +} + /** * Information about a request's timeout state */ @@ -604,8 +658,12 @@ export abstract class Protocol { }, http: extra?.authInfo ? { authInfo: extra.authInfo } : undefined, task: taskContext - }; + // v1-compat flat fields (signal, requestId, sendNotification, sendRequest, task*) are + // attached as non-enumerable getters by attachLegacyContextFields() below; cast because + // the literal above doesn't include them. + } as BaseContext; const ctx = this.buildContext(baseCtx, extra); + attachLegacyContextFields(ctx, sendRequest); // Starting with Promise.resolve() puts any synchronous errors into the monad as well. Promise.resolve() diff --git a/packages/core/test/shared/protocol.compat.test.ts b/packages/core/test/shared/protocol.compat.test.ts new file mode 100644 index 000000000..71c2049fa --- /dev/null +++ b/packages/core/test/shared/protocol.compat.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { BaseContext, RequestHandlerExtra, ServerContext } from '../../src/shared/protocol.js'; +import { Protocol } from '../../src/shared/protocol.js'; +import type { Transport } from '../../src/shared/transport.js'; +import type { JSONRPCMessage } from '../../src/types/index.js'; + +class TestProtocolImpl extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +class MockTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: unknown) => void; + + async start(): Promise {} + async close(): Promise { + this.onclose?.(); + } + async send(_message: JSONRPCMessage): Promise {} +} + +describe('v1-compat: flat ctx.* getters', () => { + let protocol: Protocol; + let transport: MockTransport; + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + protocol = new TestProtocolImpl(); + transport = new MockTransport(); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + test('flat getters forward to nested fields without warning', async () => { + await protocol.connect(transport); + + let captured: BaseContext | undefined; + const done = new Promise(resolve => { + protocol.setRequestHandler('ping', (_request, ctx) => { + captured = ctx; + resolve(); + return {}; + }); + }); + + transport.onmessage?.({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} }); + await done; + + expect(captured).toBeDefined(); + const ctx = captured as BaseContext; + + expect(ctx.signal).toBe(ctx.mcpReq.signal); + expect(ctx.requestId).toBe(ctx.mcpReq.id); + expect(ctx._meta).toBe(ctx.mcpReq._meta); + expect(ctx.authInfo).toBe(ctx.http?.authInfo); + expect(ctx.sendNotification).toBe(ctx.mcpReq.notify); + expect(ctx.sendRequest).toBeTypeOf('function'); + expect(ctx.taskStore).toBe(ctx.task?.store); + expect(ctx.taskId).toBe(ctx.task?.id); + expect(ctx.taskRequestedTtl).toBe(ctx.task?.requestedTtl); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('flat getters are non-enumerable (do not pollute spreads)', async () => { + await protocol.connect(transport); + + let captured: BaseContext | undefined; + const done = new Promise(resolve => { + protocol.setRequestHandler('ping', (_request, ctx) => { + captured = ctx; + resolve(); + return {}; + }); + }); + + transport.onmessage?.({ jsonrpc: '2.0', id: 2, method: 'ping', params: {} }); + await done; + + const keys = Object.keys(captured as BaseContext); + expect(keys).not.toContain('signal'); + expect(keys).not.toContain('requestId'); + void { ...(captured as BaseContext) }; + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('RequestHandlerExtra is a ServerContext alias (type-level)', () => { + const check = (ctx: ServerContext): RequestHandlerExtra => ctx; + void check; + }); +});