From 1523ad097311d35118d2e6936518f596647c7663 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 12 May 2026 11:29:52 +0000 Subject: [PATCH 1/5] feat(core): readMetaRequestScope lifts _meta.{clientCapabilities,protocolVersion,logLevel} into RequestEnv (SEP-2575) --- packages/core/src/exports/public/index.ts | 6 ++ packages/core/src/shared/context.ts | 64 +++++++++++++++++++- packages/core/src/shared/dispatcher.ts | 10 ++- packages/core/src/types/constants.ts | 6 ++ packages/core/test/shared/dispatcher.test.ts | 37 +++++++++++ 5 files changed, 121 insertions(+), 2 deletions(-) diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index e49e66735a..fa50455a13 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -58,6 +58,12 @@ 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 66094b9ee5..5e59704d28 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,13 @@ import type { ResultTypeMap, ServerCapabilities } from '../types/index.js'; +import { + 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 +176,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 +284,50 @@ 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. {@linkcode Dispatcher.dispatch} merges + * the result of {@linkcode readMetaRequestScope} into {@linkcode RequestEnv} 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 + * {@linkcode Dispatcher.dispatch} 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]; + if (ci && typeof ci === 'object') scope.clientInfo = ci as Implementation; + const cc = meta[META_CLIENT_CAPABILITIES_KEY]; + if (cc && typeof cc === 'object') scope.clientCapabilities = cc as ClientCapabilities; + 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 9795f93d50..5fbc1e0063 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,14 @@ 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). + const enrichedEnv: RequestEnv = { ...readMetaRequestScope(request.params?._meta), ...env }; // 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 +202,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 878d5111cf..07b922babd 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 eadb4584c8..6cb467abed 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'); + }); +}); From 9aee4d211a742cf1284c93c0b3cec3523c0909c4 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 12 May 2026 14:22:26 +0000 Subject: [PATCH 2/5] fix(dispatcher): defined-only env merge so explicit-undefined adapter keys do not clobber _meta-lifted scope --- packages/core/src/shared/dispatcher.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/core/src/shared/dispatcher.ts b/packages/core/src/shared/dispatcher.ts index 5fbc1e0063..0e23d76e2c 100644 --- a/packages/core/src/shared/dispatcher.ts +++ b/packages/core/src/shared/dispatcher.ts @@ -153,8 +153,14 @@ export class Dispatcher { */ 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). - const enrichedEnv: RequestEnv = { ...readMetaRequestScope(request.params?._meta), ...env }; + // (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 From d887785a00e35394bebb1ebc334ab99d74f93683 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 12 May 2026 15:29:37 +0000 Subject: [PATCH 3/5] fix(core): readMetaRequestScope normalises clientCapabilities/clientInfo via schema (parity with initialize) --- packages/core/src/shared/context.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/core/src/shared/context.ts b/packages/core/src/shared/context.ts index 5e59704d28..ea802b0dbc 100644 --- a/packages/core/src/shared/context.ts +++ b/packages/core/src/shared/context.ts @@ -20,6 +20,8 @@ import type { ServerCapabilities } from '../types/index.js'; import { + ClientCapabilitiesSchema, + ImplementationSchema, LoggingLevelSchema, META_CLIENT_CAPABILITIES_KEY, META_CLIENT_INFO_KEY, @@ -312,9 +314,14 @@ export function readMetaRequestScope(meta: RequestMeta | undefined): MetaRequest const pv = meta[META_PROTOCOL_VERSION_KEY]; if (typeof pv === 'string') scope.protocolVersion = pv; const ci = meta[META_CLIENT_INFO_KEY]; - if (ci && typeof ci === 'object') scope.clientInfo = ci as Implementation; + const parsedInfo = ImplementationSchema.safeParse(ci); + if (parsedInfo.success) scope.clientInfo = parsedInfo.data; const cc = meta[META_CLIENT_CAPABILITIES_KEY]; - if (cc && typeof cc === 'object') scope.clientCapabilities = cc as ClientCapabilities; + // 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; From 2a91eaa5028a566a4b26979dd20d5f8dbf738526 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 12 May 2026 15:36:59 +0000 Subject: [PATCH 4/5] docs(core): drop links to private Dispatcher.dispatch/RequestEnv in MetaRequestScope --- packages/core/src/shared/context.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/shared/context.ts b/packages/core/src/shared/context.ts index ea802b0dbc..49811fad95 100644 --- a/packages/core/src/shared/context.ts +++ b/packages/core/src/shared/context.ts @@ -288,9 +288,9 @@ export type ClientContext = BaseContext; /** * Per-request peer scope lifted from `request.params._meta` (SEP-2575). Stateless servers - * read these instead of holding `initialize` state. {@linkcode Dispatcher.dispatch} merges - * the result of {@linkcode readMetaRequestScope} into {@linkcode RequestEnv} before - * dispatching, so adapters can also pre-populate from session/init state (env wins). + * 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']`. */ @@ -304,9 +304,9 @@ export type MetaRequestScope = { }; /** - * Lifts {@linkcode MetaRequestScope} fields off `request.params._meta`. Called by - * {@linkcode Dispatcher.dispatch} before building the env so handlers see a per-request - * view of capabilities even on stateless transports. + * 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 {}; From 740466adf194b6544f2861284e070e04a244d892 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 12 May 2026 16:31:44 +0000 Subject: [PATCH 5/5] chore: prettier --- packages/core/src/exports/public/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index fa50455a13..6df324b2b6 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -63,7 +63,6 @@ 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';