Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ctx-flat-compat-getters.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/core/src/exports/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export type {
NotificationOptions,
ProgressCallback,
ProtocolOptions,
RequestHandlerExtra,
RequestOptions,
ServerContext
} from '../../shared/protocol.js';
Expand Down
62 changes: 61 additions & 1 deletion packages/core/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,25 @@ export type BaseContext = {
* Task context, available when task storage is configured.
*/
task?: TaskContext;

/** v1-compat alias for `ctx.mcpReq.signal`. */
signal: AbortSignal;
/** v1-compat alias for `ctx.mcpReq.id`. */
requestId: RequestId;
/** v1-compat alias for `ctx.mcpReq._meta`. */
_meta?: RequestMeta;
/** v1-compat alias for `ctx.http?.authInfo`. */
authInfo?: AuthInfo;
/** v1-compat alias for `ctx.mcpReq.notify`. */
sendNotification: (notification: Notification) => Promise<void>;
/** v1 form of `ctx.mcpReq.send` (takes a result schema; `mcpReq.send` is method-keyed instead). */
sendRequest: <T extends AnySchema>(request: Request, resultSchema: T, options?: RequestOptions) => Promise<SchemaOutput<T>>;
/** v1-compat alias for `ctx.task?.store`. */
taskStore?: TaskContext['store'];
/** v1-compat alias for `ctx.task?.id`. */
taskId?: TaskContext['id'];
/** v1-compat alias for `ctx.task?.requestedTtl`. */
taskRequestedTtl?: TaskContext['requestedTtl'];
};

/**
Expand Down Expand Up @@ -279,6 +298,43 @@ export type ServerContext = BaseContext & {
*/
export type ClientContext = BaseContext;

/**
* v1 name for the handler context. v2 also exposes the same data under
* {@linkcode BaseContext.mcpReq | ctx.mcpReq} / {@linkcode BaseContext.http | ctx.http};
* the flat fields remain available so existing handlers compile and run unchanged.
*/
// 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: <T extends AnySchema>(r: Request, s: T, o?: RequestOptions) => Promise<SchemaOutput<T>>
): 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
*/
Expand Down Expand Up @@ -604,8 +660,12 @@ export abstract class Protocol<ContextT extends BaseContext> {
},
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()
Expand Down
103 changes: 103 additions & 0 deletions packages/core/test/shared/protocol.compat.test.ts
Original file line number Diff line number Diff line change
@@ -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<BaseContext> {
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<void> {}
async close(): Promise<void> {
this.onclose?.();
}
async send(_message: JSONRPCMessage): Promise<void> {}
}

describe('v1-compat: flat ctx.* getters', () => {
let protocol: Protocol<BaseContext>;
let transport: MockTransport;
let warnSpy: ReturnType<typeof vi.spyOn>;

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<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 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<void>(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<R, N> is a ServerContext alias (type-level)', () => {
const check = (ctx: ServerContext): RequestHandlerExtra<unknown, unknown> => ctx;
void check;
});
});
Loading