This commit is contained in:
parent
f60dff0674
commit
f4137630c0
8 changed files with 933 additions and 207 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue