diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index e49e66735..6df324b2b 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -58,6 +58,11 @@ export { DEFAULT_REQUEST_TIMEOUT_MSEC } from '../../shared/protocol.js'; // are exported via the `export * from types/types.js` below) export { RequiresInput } from '../../shared/dispatcher.js'; +// SEP-2575 per-request _meta scope +export type { MetaRequestScope } from '../../shared/context.js'; +export { readMetaRequestScope } from '../../shared/context.js'; +export { META_CLIENT_CAPABILITIES_KEY, META_CLIENT_INFO_KEY, META_LOG_LEVEL_KEY, META_PROTOCOL_VERSION_KEY } from '../../types/index.js'; + // Task manager types (NOT TaskManager class itself — internal) export type { RequestTaskStore, TaskContext, TaskManagerOptions, TaskRequestOptions } from '../../shared/taskManager.js'; diff --git a/packages/core/src/shared/context.ts b/packages/core/src/shared/context.ts index 66094b9ee..49811fad9 100644 --- a/packages/core/src/shared/context.ts +++ b/packages/core/src/shared/context.ts @@ -7,6 +7,7 @@ import type { ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, + Implementation, LoggingLevel, Notification, Progress, @@ -18,6 +19,15 @@ import type { ResultTypeMap, ServerCapabilities } from '../types/index.js'; +import { + ClientCapabilitiesSchema, + ImplementationSchema, + LoggingLevelSchema, + META_CLIENT_CAPABILITIES_KEY, + META_CLIENT_INFO_KEY, + META_LOG_LEVEL_KEY, + META_PROTOCOL_VERSION_KEY +} from '../types/index.js'; import type { StandardSchemaV1 } from '../util/standardSchema.js'; import type { TransportSendOptions } from './transport.js'; @@ -168,6 +178,23 @@ export type BaseContext = { */ notify: (notification: Notification) => Promise; + /** + * SEP-2575: per-request client capabilities lifted from `_meta`, or supplied by the + * adapter from session/init state. Adapter-supplied env takes precedence over `_meta` + * (`_meta` is client-asserted; session state is server-validated). Prefer this over + * instance-level `_clientCapabilities` for per-request checks on stateless servers. + */ + clientCapabilities?: ClientCapabilities; + + /** SEP-2575: per-request protocol version lifted from `_meta`. */ + protocolVersion?: string; + + /** SEP-2575: per-request client info lifted from `_meta`. */ + clientInfo?: Implementation; + + /** SEP-2575: per-request log level lifted from `_meta`. */ + logLevel?: LoggingLevel; + /** * SEP-2322: client-supplied answers to a prior round's * {@linkcode IncompleteResult.inputRequests}, keyed by the same opaque ids. @@ -259,13 +286,55 @@ export type ServerContext = BaseContext & { */ export type ClientContext = BaseContext; +/** + * Per-request peer scope lifted from `request.params._meta` (SEP-2575). Stateless servers + * read these instead of holding `initialize` state. The dispatch path merges the result of + * {@linkcode readMetaRequestScope} into the per-request env before dispatching, so adapters + * can also pre-populate from session/init state (env wins). + */ +export type MetaRequestScope = { + /** From `_meta['io.modelcontextprotocol/protocolVersion']`. */ + protocolVersion?: string; + /** From `_meta['io.modelcontextprotocol/clientInfo']`. */ + clientInfo?: Implementation; + /** From `_meta['io.modelcontextprotocol/clientCapabilities']`. */ + clientCapabilities?: ClientCapabilities; + /** From `_meta['io.modelcontextprotocol/logLevel']`. */ + logLevel?: LoggingLevel; +}; + +/** + * Lifts {@linkcode MetaRequestScope} fields off `request.params._meta`. Called by the + * dispatch path before building the env so handlers see a per-request view of + * capabilities even on stateless transports. + */ +export function readMetaRequestScope(meta: RequestMeta | undefined): MetaRequestScope { + if (!meta) return {}; + const scope: MetaRequestScope = {}; + const pv = meta[META_PROTOCOL_VERSION_KEY]; + if (typeof pv === 'string') scope.protocolVersion = pv; + const ci = meta[META_CLIENT_INFO_KEY]; + const parsedInfo = ImplementationSchema.safeParse(ci); + if (parsedInfo.success) scope.clientInfo = parsedInfo.data; + const cc = meta[META_CLIENT_CAPABILITIES_KEY]; + // Run through the schema so the same z.preprocess normalisations applied to + // initialize.params.capabilities (e.g. ElicitationCapabilitySchema's `{}` -> `{form:{}}`) + // also apply to the per-request _meta-carried value. + const parsedCaps = ClientCapabilitiesSchema.safeParse(cc); + if (parsedCaps.success) scope.clientCapabilities = parsedCaps.data; + const ll = meta[META_LOG_LEVEL_KEY]; + const parsedLevel = typeof ll === 'string' ? LoggingLevelSchema.safeParse(ll) : undefined; + if (parsedLevel?.success) scope.logLevel = parsedLevel.data; + return scope; +} + /** * Per-request environment a transport adapter passes to {@linkcode Dispatcher.dispatch}. * Everything is optional; a bare `dispatch()` call works with no transport at all. * * @internal */ -export type RequestEnv = { +export type RequestEnv = MetaRequestScope & { /** * Sends a request back to the peer (server→client elicitation/sampling, or * client→server nested calls). Supplied by {@linkcode StreamDriver} when running diff --git a/packages/core/src/shared/dispatcher.ts b/packages/core/src/shared/dispatcher.ts index 9795f93d5..0e23d76e2 100644 --- a/packages/core/src/shared/dispatcher.ts +++ b/packages/core/src/shared/dispatcher.ts @@ -21,6 +21,7 @@ import { getNotificationSchema, getRequestSchema, getResultSchema, ProtocolError import type { StandardSchemaV1 } from '../util/standardSchema.js'; import { validateStandardSchema } from '../util/standardSchema.js'; import type { BaseContext, RequestEnv, RequestOptions } from './context.js'; +import { readMetaRequestScope } from './context.js'; /** * One yielded item from {@linkcode Dispatcher.dispatch}. A dispatch yields zero or more @@ -151,11 +152,20 @@ export class Dispatcher { * May throw if iteration itself is misused. */ dispatch(request: JSONRPCRequest, env: RequestEnv = {}): AsyncGenerator { + // SEP-2575: lift per-request peer scope from _meta. Adapter-supplied env wins + // (e.g. SessionCompat passing its stored init capabilities), but only when the + // adapter set a *defined* value. An adapter that builds env with explicit + // `clientCapabilities: undefined` (as shttpHandler does on a stateless POST) + // must not clobber the value lifted from _meta, so strip undefined own-keys + // from env before spreading. + const definedEnv: RequestEnv = {}; + for (const [k, v] of Object.entries(env)) if (v !== undefined) (definedEnv as Record)[k] = v; + const enrichedEnv: RequestEnv = { ...readMetaRequestScope(request.params?._meta), ...definedEnv }; // eslint-disable-next-line unicorn/consistent-function-scoping -- closes over `this` let chain: DispatchFn = (r, e) => this._dispatchCore(r, e); // eslint-disable-next-line unicorn/no-array-reverse -- toReversed() requires ES2023 lib; consumers may target ES2022 for (const mw of [...this._dispatchMw].reverse()) chain = mw(chain); - return chain(request, env); + return chain(request, enrichedEnv); } /** @@ -198,6 +208,10 @@ export class Dispatcher { method: request.method, _meta: request.params?._meta, signal: localAbort.signal, + clientCapabilities: env.clientCapabilities, + protocolVersion: env.protocolVersion, + clientInfo: env.clientInfo, + logLevel: env.logLevel, inputResponses: mrtrParams?.inputResponses, requestState: mrtrParams?.requestState, send: (async (r: Request, schemaOrOptions?: unknown, maybeOptions?: RequestOptions) => { diff --git a/packages/core/src/types/constants.ts b/packages/core/src/types/constants.ts index 878d5111c..07b922bab 100644 --- a/packages/core/src/types/constants.ts +++ b/packages/core/src/types/constants.ts @@ -4,6 +4,12 @@ export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, '2025-06-18 export const RELATED_TASK_META_KEY = 'io.modelcontextprotocol/related-task'; +/* SEP-2575 per-request _meta scope keys (stateless servers read these instead of initialize state) */ +export const META_PROTOCOL_VERSION_KEY = 'io.modelcontextprotocol/protocolVersion'; +export const META_CLIENT_INFO_KEY = 'io.modelcontextprotocol/clientInfo'; +export const META_CLIENT_CAPABILITIES_KEY = 'io.modelcontextprotocol/clientCapabilities'; +export const META_LOG_LEVEL_KEY = 'io.modelcontextprotocol/logLevel'; + /* JSON-RPC types */ export const JSONRPC_VERSION = '2.0'; diff --git a/packages/core/test/shared/dispatcher.test.ts b/packages/core/test/shared/dispatcher.test.ts index eadb4584c..6cb467abe 100644 --- a/packages/core/test/shared/dispatcher.test.ts +++ b/packages/core/test/shared/dispatcher.test.ts @@ -349,3 +349,40 @@ describe('RequiresInput → IncompleteResult (SEP-2322 Option E)', () => { expect(seen).toEqual({ inputResponses: { r0: { ok: true } }, requestState: 's1' }); }); }); + +describe('MetaRequestScope (SEP-2575)', () => { + test('clientCapabilities/protocolVersion lifted from _meta onto ctx.mcpReq', async () => { + const d = new Dispatcher(); + let seen: { caps?: unknown; pv?: unknown } = {}; + d.setRequestHandler('ping', async (_r, ctx) => { + seen = { caps: ctx.mcpReq.clientCapabilities, pv: ctx.mcpReq.protocolVersion }; + return {}; + }); + await collect( + d.dispatch( + req('ping', { + _meta: { + 'io.modelcontextprotocol/clientCapabilities': { sampling: {} }, + 'io.modelcontextprotocol/protocolVersion': '2026-06-30' + } + }) + ) + ); + expect(seen).toEqual({ caps: { sampling: {} }, pv: '2026-06-30' }); + }); + + test('adapter-supplied env wins over _meta', async () => { + const d = new Dispatcher(); + let seenPv: string | undefined; + d.setRequestHandler('ping', async (_r, ctx) => { + seenPv = ctx.mcpReq.protocolVersion; + return {}; + }); + await collect( + d.dispatch(req('ping', { _meta: { 'io.modelcontextprotocol/protocolVersion': 'from-meta' } }), { + protocolVersion: 'from-env' + }) + ); + expect(seenPv).toBe('from-env'); + }); +});