Align 2026-07-28 wire with spec PR #3002: serverInfo in result _meta, clientInfo optional (#2513)

This commit is contained in:
Felix Weinberger 2026-07-20 16:09:55 +01:00 • committed by GitHub
commit f4137630c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 933 additions and 207 deletions

View file

@ -63,7 +63,7 @@ import { createListenRouter, DEFAULT_LISTEN_KEEPALIVE_MS, DEFAULT_MAX_SUBSCRIPTI
import { McpServer } from './mcp';
import type { PerRequestResponseMode } from './perRequestTransport';
import type { Server } from './server';
import { installModernOnlyHandlers, seedClientIdentityFromEnvelope } from './server';
import { installModernOnlyHandlers, seedClientIdentityFromEnvelope, serverIdentityOf } from './server';
import type { ServerEventBus, ServerNotifier } from './serverEventBus';
import { createServerNotifier, InMemoryServerEventBus } from './serverEventBus';
import { WebStandardStreamableHTTPServerTransport } from './streamableHttp';
@ -721,8 +721,9 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
// middleware layer mounted in front of this entry.
if (route.messageKind === 'request' && route.message.method === 'subscriptions/listen') {
const capabilities = server.getCapabilities();
const serverInfo = serverIdentityOf(server);
void product.close().catch(reportError);
return listenRouter.serve(route.message, request.signal, capabilities);
return listenRouter.serve(route.message, request.signal, capabilities, serverInfo);
}
// SEP-2243 `Mcp-Param-*` server-side validation (pre-dispatch ladder

View file

@ -23,8 +23,14 @@
* transport close carries no response and the client treats it as a
* disconnect.
*/
import type { JSONRPCRequest, RequestId, ServerCapabilities, SubscriptionFilter } from '@modelcontextprotocol/core-internal';
import { codecForVersion, MODERN_WIRE_REVISION, SUBSCRIPTION_ID_META_KEY } from '@modelcontextprotocol/core-internal';
import type {
Implementation,
JSONRPCRequest,
RequestId,
ServerCapabilities,
SubscriptionFilter
} from '@modelcontextprotocol/core-internal';
import { codecForVersion, MODERN_WIRE_REVISION, SERVER_INFO_META_KEY, SUBSCRIPTION_ID_META_KEY } from '@modelcontextprotocol/core-internal';
import type { ServerEventBus } from './serverEventBus';
import { honoredSubset, listenFilterAccepts, serverEventToNotification } from './serverEventBus';
@ -100,8 +106,11 @@ export interface ListenRouter {
* `capabilities` is required: the acknowledged filter is always narrowed
* against what the serving instance advertises (honoring a filter without
* capabilities would fail open and deliver unadvertised types).
* `serverInfo` is the serving instance's identity, stamped onto the
* graceful-close result's `_meta` (the spec's `SubscriptionsListenResultMeta`
* extends `ResultMetaObject`, so the serverInfo SHOULD applies there too).
*/
serve(message: JSONRPCRequest, signal: AbortSignal | undefined, capabilities: ServerCapabilities): Response;
serve(message: JSONRPCRequest, signal: AbortSignal | undefined, capabilities: ServerCapabilities, serverInfo: Implementation): Response;
/**
* Gracefully close every open subscription stream: emits the empty
* `subscriptions/listen` JSON-RPC result (the spec's graceful-close
@ -119,7 +128,12 @@ export function createListenRouter(options: ListenRouterOptions): ListenRouter {
const open = new Set<(graceful: boolean) => void>();
function serve(message: JSONRPCRequest, signal: AbortSignal | undefined, capabilities: ServerCapabilities): Response {
function serve(
message: JSONRPCRequest,
signal: AbortSignal | undefined,
capabilities: ServerCapabilities,
serverInfo: Implementation
): Response {
// Capacity guard, pre-ack: in-band -32603 on HTTP 200.
if (open.size >= maxSubscriptions) {
onerror?.(new Error(`subscriptions/listen refused: subscription limit reached (${maxSubscriptions})`));
@ -166,7 +180,10 @@ export function createListenRouter(options: ListenRouterOptions): ListenRouter {
`event: message\ndata: ${JSON.stringify({
jsonrpc: '2.0',
id: subscriptionId,
result: { resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: subscriptionId } }
result: {
resultType: 'complete',
_meta: { [SUBSCRIPTION_ID_META_KEY]: subscriptionId, [SERVER_INFO_META_KEY]: serverInfo }
}
})}\n\n`
);
}
@ -256,6 +273,16 @@ export function createListenRouter(options: ListenRouterOptions): ListenRouter {
* Stdio listen router
* ------------------------------------------------------------------------ */
/** A graceful-close `subscriptions/listen` result frame emitted by {@linkcode StdioListenRouter.teardownAll}. */
export interface ListenCloseFrame {
jsonrpc: '2.0';
id: RequestId;
result: {
resultType: 'complete';
_meta: { [SUBSCRIPTION_ID_META_KEY]: RequestId; [SERVER_INFO_META_KEY]?: Implementation };
};
}
const CHANGE_NOTIFICATION_METHODS: ReadonlySet<string> = new Set([
'notifications/tools/list_changed',
'notifications/prompts/list_changed',
@ -281,21 +308,31 @@ export class StdioListenRouter {
* what the server can actually deliver.
*/
private _serverCapabilities: ServerCapabilities | undefined;
/**
* The serving instance's identity, stamped onto the graceful-close
* results' `_meta` (the spec's `SubscriptionsListenResultMeta` extends
* `ResultMetaObject`). Handed over together with the capabilities.
*/
private _serverInfo: Implementation | undefined;
constructor(
private readonly _maxSubscriptions: number = DEFAULT_MAX_SUBSCRIPTIONS,
serverCapabilities?: ServerCapabilities
serverCapabilities?: ServerCapabilities,
serverInfo?: Implementation
) {
this._serverCapabilities = serverCapabilities;
this._serverInfo = serverInfo;
}
/**
* Record the serving instance's declared capabilities once it has been
* constructed. Called by `serveStdio`'s connect path; subsequent
* `serve()` calls narrow the honored filter against these.
* Record the serving instance's declared capabilities and identity once
* it has been constructed. Called by `serveStdio`'s connect path;
* subsequent `serve()` calls narrow the honored filter against the
* capabilities, and `teardownAll()` stamps the identity.
*/
setServerCapabilities(capabilities: ServerCapabilities): void {
setServerCapabilities(capabilities: ServerCapabilities, serverInfo?: Implementation): void {
this._serverCapabilities = capabilities;
if (serverInfo !== undefined) this._serverInfo = serverInfo;
}
/** Whether `id` is an active listen subscription on this connection. */
@ -373,21 +410,24 @@ export class StdioListenRouter {
/**
* Server-side graceful teardown of every active subscription: returns the
* empty `subscriptions/listen` JSON-RPC result for each subscription id —
* the spec's graceful-close signal — for the entry to emit before closing
* the wire. Clears the set so nothing further is delivered.
* the spec's graceful-close signal, `_meta` carrying the subscription id
* and the serving instance's identity — for the entry to emit before
* closing the wire. Clears the set so nothing further is delivered.
*/
teardownAll(): {
jsonrpc: '2.0';
id: RequestId;
result: { resultType: 'complete'; _meta: { [SUBSCRIPTION_ID_META_KEY]: RequestId } };
}[] {
const out: {
jsonrpc: '2.0';
id: RequestId;
result: { resultType: 'complete'; _meta: { [SUBSCRIPTION_ID_META_KEY]: RequestId } };
}[] = [];
teardownAll(): ListenCloseFrame[] {
const out: ListenCloseFrame[] = [];
for (const id of this._subs.keys()) {
out.push({ jsonrpc: '2.0', id, result: { resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: id } } });
out.push({
jsonrpc: '2.0',
id,
result: {
resultType: 'complete',
_meta: {
[SUBSCRIPTION_ID_META_KEY]: id,
...(this._serverInfo !== undefined && { [SERVER_INFO_META_KEY]: this._serverInfo })
}
}
});
}
this._subs.clear();
return out;

View file

@ -78,7 +78,7 @@ import type { McpServerFactory } from './createMcpHandler';
import { DEFAULT_MAX_SUBSCRIPTIONS, StdioListenRouter } from './listenRouter';
import { McpServer } from './mcp';
import type { Server } from './server';
import { installModernOnlyHandlers } from './server';
import { installModernOnlyHandlers, serverIdentityOf } from './server';
import { StdioServerTransport } from './stdio';
/** Options for {@linkcode serveStdio}. */
@ -518,9 +518,10 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions
setNegotiatedProtocolVersion(server, revision);
installModernOnlyHandlers(server, SUPPORTED_MODERN_PROTOCOL_VERSIONS);
// The listen router was created before this instance existed; now
// that capabilities are known, hand them over so the acknowledged
// filter is narrowed against what the server actually advertises.
listenRouter.setServerCapabilities(server.getCapabilities());
// that capabilities are known, hand them over (with the identity
// stamped onto graceful-close results) so the acknowledged filter
// is narrowed against what the server actually advertises.
listenRouter.setServerCapabilities(server.getCapabilities(), serverIdentityOf(server));
}
const channel: StdioConnectionChannel = new StdioConnectionChannel(
wire,

View file

@ -206,6 +206,7 @@ export type ServerOptions = ProtocolOptions & {
*/
let writeClientIdentity: (server: Server, identity: PerRequestClientIdentity) => void;
let installDiscoverHandler: (server: Server, servedModernVersions: readonly string[]) => void;
let readServerIdentity: (server: Server) => Implementation;
/** Connection-scoped client-identity fields backfilled per request from a validated `_meta` envelope. */
export interface PerRequestClientIdentity {
@ -239,6 +240,17 @@ export function installModernOnlyHandlers(server: Server, servedModernVersions:
installDiscoverHandler(server, servedModernVersions);
}
/**
* Package-internal: the instance's implementation identity, for the serving
* entries to stamp onto entry-built results (the `subscriptions/listen`
* graceful-close result — built outside the encode seam, but the spec's
* `SubscriptionsListenResultMeta` extends `ResultMetaObject`, so it carries
* the serverInfo SHOULD like every other result). Not public API.
*/
export function serverIdentityOf(server: Server): Implementation {
return readServerIdentity(server);
}
/**
* An MCP server on top of a pluggable transport.
*
@ -268,6 +280,7 @@ export class Server extends Protocol<ServerContext> {
}
server.setRequestHandler('server/discover', () => server._ondiscover());
};
readServerIdentity = server => server._serverInfo;
}
private _capabilities: ServerCapabilities;
private _instructions?: string;
@ -913,18 +926,28 @@ export class Server extends Protocol<ServerContext> {
/**
* Answers `server/discover` (protocol revision 2026-07-28). `supportedVersions`
* lists only modern revisions (2025-era versions are negotiated via `initialize`);
* the advertised capabilities exclude the listChanged/subscribe-class capabilities
* the capabilities are advertised as-is, listChanged/subscribe bits included
* (see {@linkcode discoverAdvertisedCapabilities}).
*/
private _ondiscover(): DiscoverResult {
// Server identity is not a body field: the encode seam stamps it into
// the result `_meta` via `_outboundServerInfo` (spec PR #3002).
return {
supportedVersions: modernProtocolVersions(this._supportedProtocolVersions),
capabilities: discoverAdvertisedCapabilities(this.getCapabilities()),
serverInfo: this._serverInfo,
...(this._instructions && { instructions: this._instructions })
};
}
/**
* The identity the 2026-era encode seam stamps into every outbound
* result's `_meta` under `io.modelcontextprotocol/serverInfo` (spec PR
* #3002: servers SHOULD identify themselves on every response).
*/
protected override _outboundServerInfo(): Implementation | undefined {
return this._serverInfo;
}
/**
* After initialization has completed, this will be populated with the client's reported capabilities.
*

View file

@ -128,9 +128,12 @@ describe('createMcpHandler — modern path', () => {
postRequest({ jsonrpc: '2.0', id: 5, method: 'server/discover', params: { _meta: ENVELOPE } })
);
expect(response.status).toBe(200);
const body = (await response.json()) as { result: { supportedVersions: string[]; serverInfo: { name: string } } };
const body = (await response.json()) as {
result: { supportedVersions: string[]; _meta: Record<string, { name: string }> };
};
expect(body.result.supportedVersions).toEqual([MODERN_REVISION]);
expect(body.result.serverInfo.name).toBe('entry-test-server');
// #3002: identity in the result `_meta`, never the body.
expect(body.result._meta['io.modelcontextprotocol/serverInfo']!.name).toBe('entry-test-server');
});
it('backfills the deprecated accessors and the negotiated revision from the validated envelope (per-request instance state)', async () => {
@ -309,7 +312,10 @@ describe('createMcpHandler — modern path', () => {
expect(response.status).toBe(400);
const body = (await response.json()) as JSONRPCErrorBody;
expect(body.error.code).toBe(-32_602);
expect(JSON.stringify(body.error.data)).toContain('clientInfo');
// clientCapabilities is the missing REQUIRED key; clientInfo is a
// SHOULD since spec PR #3002 and its absence is never an error.
expect(JSON.stringify(body.error.data)).toContain('clientCapabilities');
expect(JSON.stringify(body.error.data)).not.toContain('clientInfo');
expect(body.id).toBe(1);
expect(state.contexts).toHaveLength(0);
});

View file

@ -208,7 +208,16 @@ describe('createMcpHandler — subscriptions/listen', () => {
expect(resultFrame).toEqual({
jsonrpc: '2.0',
id: 1,
result: { resultType: 'complete', _meta: { 'io.modelcontextprotocol/subscriptionId': 1 } }
result: {
resultType: 'complete',
_meta: {
'io.modelcontextprotocol/subscriptionId': 1,
// #3002: the close result carries the serving instance's
// identity like every other result (SubscriptionsListenResultMeta
// extends ResultMetaObject).
'io.modelcontextprotocol/serverInfo': { name: 'listen-test-server', version: '1.0.0' }
}
}
});
});

View file

@ -30,6 +30,7 @@ import {
isJSONRPCResultResponse,
LATEST_PROTOCOL_VERSION,
PROTOCOL_VERSION_META_KEY,
SERVER_INFO_META_KEY,
setNegotiatedProtocolVersion,
SUPPORTED_PROTOCOL_VERSIONS
} from '@modelcontextprotocol/core-internal';
@ -99,12 +100,39 @@ describe('server/discover handler gating', () => {
if (isJSONRPCResultResponse(response)) {
const result = DiscoverResultSchema.parse(response.result);
expect(result.supportedVersions).toEqual([MODERN]);
expect(result.serverInfo).toEqual({ name: 'modern-server', version: '2.0.0' });
// #3002: identity travels in the result `_meta`, never the body.
expect(result._meta?.[SERVER_INFO_META_KEY]).toEqual({ name: 'modern-server', version: '2.0.0' });
expect('serverInfo' in (response.result as Record<string, unknown>)).toBe(false);
expect(result.instructions).toBe('hello');
}
await server.close();
});
it('serves discover for a client that omits clientInfo (SHOULD since spec PR #3002)', async () => {
const server = new Server(
{ name: 'modern-server', version: '2.0.0' },
{ capabilities: { tools: {} }, supportedProtocolVersions: DUAL_ERA_VERSIONS }
);
const withoutClientInfo: JSONRPCRequest = {
jsonrpc: '2.0',
id: 1,
method: 'server/discover',
params: {
_meta: {
[PROTOCOL_VERSION_META_KEY]: MODERN,
[CLIENT_CAPABILITIES_META_KEY]: {}
}
}
};
const response = await sendRaw(server, withoutClientInfo, { markModern: true });
expect(isJSONRPCResultResponse(response)).toBe(true);
if (isJSONRPCResultResponse(response)) {
const result = DiscoverResultSchema.parse(response.result);
expect(result.supportedVersions).toEqual([MODERN]);
}
await server.close();
});
it('a modern-era instance whose supported list carries no modern revision still answers -32601 (handler not installed)', async () => {
const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} });
const response = await sendRaw(server, discoverRequest, { markModern: true });

View file

@ -121,9 +121,12 @@ describe('serveStdio — subscriptions/listen', () => {
// the wire closes. No notifications/cancelled (the pre-#2953 path).
const results = inbound.filter(m => 'result' in m) as { id: unknown; result: unknown }[];
expect(results.map(m => m.id)).toEqual(['s1', 's2']);
// #3002: each close result carries the serving instance's identity
// (SubscriptionsListenResultMeta extends ResultMetaObject).
const serverInfo = { name: 's', version: '1' };
expect(results.map(m => m.result)).toEqual([
{ resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: 's1' } },
{ resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: 's2' } }
{ resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: 's1', 'io.modelcontextprotocol/serverInfo': serverInfo } },
{ resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: 's2', 'io.modelcontextprotocol/serverInfo': serverInfo } }
]);
expect(inbound.some(m => (m as JSONRPCNotification).method === 'notifications/cancelled')).toBe(false);
});