From 3bab1dd886dc415adbd3bf8c4cdc465ed67d9299 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 15 Apr 2026 11:59:21 +0000 Subject: [PATCH] feat(compat): flat ctx.* getters + RequestHandlerExtra type alias --- .changeset/ctx-flat-compat-getters.md | 5 ++ packages/client/src/client/client.ts | 3 +- packages/core/src/exports/public/index.ts | 1 + packages/core/src/shared/protocol.ts | 67 +++++++++++++++++-- .../core/test/shared/protocol.compat.test.ts | 67 +++++++++++++++++++ packages/server/src/server/server.ts | 3 +- 6 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 .changeset/ctx-flat-compat-getters.md create mode 100644 packages/core/test/shared/protocol.compat.test.ts diff --git a/.changeset/ctx-flat-compat-getters.md b/.changeset/ctx-flat-compat-getters.md new file mode 100644 index 000000000..de41c5879 --- /dev/null +++ b/.changeset/ctx-flat-compat-getters.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +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. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 21a43bd15..0a5722d2f 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -13,6 +13,7 @@ import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, + LegacyContextFields, ListChangedHandlers, ListChangedOptions, ListPromptsRequest, @@ -245,7 +246,7 @@ export class Client extends Protocol { } } - protected override buildContext(ctx: BaseContext, _transportInfo?: MessageExtraInfo): ClientContext { + protected override buildContext(ctx: BaseContext & LegacyContextFields, _transportInfo?: MessageExtraInfo): ClientContext { return ctx; } 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..2a958bb72 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -162,6 +162,32 @@ export type NotificationOptions = { relatedTask?: RelatedTaskMetadata; }; +/** + * v1-compat flat aliases — added by `withLegacyContextFields`. + * The v2 nested forms (`ctx.mcpReq.*`, `ctx.http?.*`, `ctx.task?.*`) are preferred. + * Do not add new fields here. + */ +export interface LegacyContextFields { + /** @deprecated Use `ctx.mcpReq.signal` */ + signal: AbortSignal; + /** @deprecated Use `ctx.mcpReq.id` */ + requestId: RequestId; + /** @deprecated Use `ctx.mcpReq._meta` */ + _meta?: RequestMeta; + /** @deprecated Use `ctx.http?.authInfo` */ + authInfo?: AuthInfo; + /** @deprecated Use `ctx.mcpReq.notify` */ + sendNotification: (notification: Notification) => Promise; + /** @deprecated Use `ctx.mcpReq.send` */ + sendRequest: (request: Request, resultSchema: T, options?: RequestOptions) => Promise>; + /** @deprecated Use `ctx.task?.store` */ + taskStore?: TaskContext['store']; + /** @deprecated Use `ctx.task?.id` */ + taskId?: TaskContext['id']; + /** @deprecated Use `ctx.task?.requestedTtl` */ + taskRequestedTtl?: TaskContext['requestedTtl']; +} + /** * Base context provided to all request handlers. */ @@ -272,12 +298,45 @@ export type ServerContext = BaseContext & { */ closeStandaloneSSE?: () => void; }; -}; +} & LegacyContextFields; /** * Context provided to client-side request handlers. */ -export type ClientContext = BaseContext; +export type ClientContext = BaseContext & LegacyContextFields; + +/** + * 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; + +/** + * Returns a copy of `ctx` with v1's flat `extra.*` aliases populated as plain properties + * mirroring the nested v2 fields. Intersected onto `ClientContext`/`ServerContext` so + * existing handlers that read `extra.signal` etc. compile and run unchanged. + * + * @internal + */ +function withLegacyContextFields( + ctx: T, + sendRequest: (r: Request, s: S, o?: RequestOptions) => Promise> +): T & LegacyContextFields { + return { + ...ctx, + signal: ctx.mcpReq.signal, + requestId: ctx.mcpReq.id, + _meta: ctx.mcpReq._meta, + authInfo: ctx.http?.authInfo, + sendNotification: ctx.mcpReq.notify, + sendRequest, + taskStore: ctx.task?.store, + taskId: ctx.task?.id, + taskRequestedTtl: ctx.task?.requestedTtl + }; +} /** * Information about a request's timeout state @@ -393,7 +452,7 @@ export abstract class Protocol { * Builds the context object for request handlers. Subclasses must override * to return the appropriate context type (e.g., ServerContext adds HTTP request info). */ - protected abstract buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ContextT; + protected abstract buildContext(ctx: BaseContext & LegacyContextFields, transportInfo?: MessageExtraInfo): ContextT; private async _oncancel(notification: CancelledNotification): Promise { if (!notification.params.requestId) { @@ -605,7 +664,7 @@ export abstract class Protocol { http: extra?.authInfo ? { authInfo: extra.authInfo } : undefined, task: taskContext }; - const ctx = this.buildContext(baseCtx, extra); + const ctx = this.buildContext(withLegacyContextFields(baseCtx, sendRequest), extra); // 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..a4123ee2e --- /dev/null +++ b/packages/core/test/shared/protocol.compat.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'vitest'; + +import type { BaseContext, ClientContext, LegacyContextFields, 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 & LegacyContextFields): ClientContext { + 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.* fields', () => { + test('flat fields mirror nested v2 fields', async () => { + const protocol = new TestProtocolImpl(); + const transport = new MockTransport(); + await protocol.connect(transport); + + let captured: ClientContext | 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!; + + 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); + }); + + test('RequestHandlerExtra is a ServerContext alias (type-level)', () => { + const check = (ctx: ServerContext): RequestHandlerExtra => ctx; + void check; + }); +}); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 4361f3e1e..b69d25911 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -14,6 +14,7 @@ import type { InitializeResult, JsonSchemaType, jsonSchemaValidator, + LegacyContextFields, ListRootsRequest, LoggingLevel, LoggingMessageNotification, @@ -153,7 +154,7 @@ export class Server extends Protocol { }); } - protected override buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ServerContext { + protected override buildContext(ctx: BaseContext & LegacyContextFields, transportInfo?: MessageExtraInfo): ServerContext { // Only create http when there's actual HTTP transport info or auth info const hasHttpInfo = ctx.http || transportInfo?.request || transportInfo?.closeSSEStream || transportInfo?.closeStandaloneSSEStream; return {