Skip to content
6 changes: 6 additions & 0 deletions .changeset/ctx-flat-compat-getters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/server': patch
---

v1-compat: add `@deprecated` 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. Covers the common v1 `extra.*` accesses; HTTP-transport-specific fields (`requestInfo`, `closeSSEStream`, `closeStandaloneSSEStream`) are not shimmed and require migration to `ctx.http?.req` / `ctx.http?.closeSSE` / `ctx.http?.closeStandaloneSSE`.
3 changes: 2 additions & 1 deletion packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
JsonSchemaType,
JsonSchemaValidator,
jsonSchemaValidator,
LegacyContextFields,
ListChangedHandlers,
ListChangedOptions,
ListPromptsRequest,
Expand Down Expand Up @@ -266,7 +267,7 @@ export class Client extends Protocol<ClientContext> {
}
}

protected override buildContext(ctx: BaseContext, _transportInfo?: MessageExtraInfo): ClientContext {
protected override buildContext(ctx: BaseContext & LegacyContextFields, _transportInfo?: MessageExtraInfo): ClientContext {
return ctx;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/exports/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,11 @@ export { getDisplayName } from '../../shared/metadataUtils.js';
export type {
BaseContext,
ClientContext,
LegacyContextFields,
NotificationOptions,
ProgressCallback,
ProtocolOptions,
RequestHandlerExtra,
Comment thread
felixweinberger marked this conversation as resolved.
RequestHandlerSchemas,
RequestOptions,
ServerContext
Expand Down
72 changes: 68 additions & 4 deletions packages/core/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,36 @@ 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<void>;
/** @deprecated Use `ctx.mcpReq.send` */
sendRequest: <T extends StandardSchemaV1>(
request: Request,
resultSchema: T,
options?: RequestOptions
) => Promise<StandardSchemaV1.InferOutput<T>>;
/** @deprecated Use `ctx.task?.store` */
taskStore?: TaskContext['store'];
/** @deprecated Use `ctx.task?.id` */
taskId?: TaskContext['id'];
/** @deprecated Use `ctx.task?.requestedTtl` */
taskRequestedTtl?: TaskContext['requestedTtl'];
}
Comment thread
claude[bot] marked this conversation as resolved.

/**
* Base context provided to all request handlers.
*/
Expand Down Expand Up @@ -282,12 +312,46 @@ export type ServerContext = BaseContext & {
*/
closeStandaloneSSE?: () => void;
};
};
} & LegacyContextFields;

/**
* Context provided to client-side request handlers.
*/
export type ClientContext = BaseContext;
export type ClientContext = BaseContext & LegacyContextFields;

/**
* @deprecated Use {@linkcode ServerContext} (server side) or {@linkcode ClientContext} (client side).
*
* 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 using them compile and run unchanged. HTTP-transport-specific fields
* (`requestInfo`, `closeSSEStream`, `closeStandaloneSSEStream`) are not shimmed
* and require migration to `ctx.http?.req` / `ctx.http?.closeSSE` / `ctx.http?.closeStandaloneSSE`. 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;
Comment thread
claude[bot] marked this conversation as resolved.

/**
* 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<T extends BaseContext>(ctx: T, sendRequest: LegacyContextFields['sendRequest']): 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
Expand Down Expand Up @@ -406,7 +470,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
* 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<void> {
if (!notification.params.requestId) {
Expand Down Expand Up @@ -630,7 +694,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
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()
Expand Down
67 changes: 67 additions & 0 deletions packages/core/test/shared/protocol.compat.test.ts
Original file line number Diff line number Diff line change
@@ -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<ClientContext> {
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<void> {}
async close(): Promise<void> {
this.onclose?.();
}
async send(_message: JSONRPCMessage): Promise<void> {}
}

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<void>(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<R, N> is a ServerContext alias (type-level)', () => {
const check = (ctx: ServerContext): RequestHandlerExtra<unknown, unknown> => ctx;
void check;
});
});
3 changes: 2 additions & 1 deletion packages/server/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
JSONRPCRequest,
JsonSchemaType,
jsonSchemaValidator,
LegacyContextFields,
ListRootsRequest,
LoggingLevel,
LoggingMessageNotification,
Expand Down Expand Up @@ -152,7 +153,7 @@ export class Server extends Protocol<ServerContext> {
});
}

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 {
Expand Down
Loading