Skip to content

Commit a7fcd76

Browse files
fix(server): narrow honored listen filter against the server's declared capabilities
`honoredSubset()` now accepts an optional `ServerCapabilities` and drops any requested notification type the server does not advertise: `toolsListChanged` is honored only when `capabilities.tools.listChanged` is true (likewise prompts/resources), and `resourceSubscriptions` only when `capabilities.resources.subscribe` is true. The honored filter the ack carries thereby reflects what the server can actually deliver. The seam is threaded through `ListenRouterOptions.serverCapabilities` and `StdioListenRouter`'s constructor. When omitted (the current `createMcpHandler`/`serveStdio` wiring, where capabilities live behind a per-request factory), the requested set is honored as-is — unchanged behavior. Wiring the entries to supply capabilities (via a factory probe instance) is a follow-up.
1 parent adc2068 commit a7fcd76

3 files changed

Lines changed: 56 additions & 16 deletions

File tree

packages/server/src/server/listenRouter.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
* - The server MUST NOT deliver a notification type the client did not request.
1919
* - Termination is stream close (HTTP); no JSON-RPC result is ever emitted.
2020
*/
21-
import type { JSONRPCRequest, RequestId, SubscriptionFilter } from '@modelcontextprotocol/core';
21+
import type { JSONRPCRequest, RequestId, ServerCapabilities, SubscriptionFilter } from '@modelcontextprotocol/core';
2222
import { SUBSCRIPTION_ID_META_KEY, SubscriptionFilterSchema } from '@modelcontextprotocol/core';
2323

2424
import type { ServerEventBus } from './serverEventBus.js';
@@ -38,6 +38,13 @@ export interface ListenRouterOptions {
3838
maxSubscriptions?: number;
3939
/** SSE comment-frame keepalive interval; `0` disables keepalive (default 15000). */
4040
keepAliveMs?: number;
41+
/**
42+
* The server's declared capabilities. When supplied, the honored filter
43+
* the ack carries is narrowed against these (a requested type the server
44+
* does not advertise is dropped from the ack); when omitted, the requested
45+
* set is honored as-is.
46+
*/
47+
serverCapabilities?: ServerCapabilities;
4148
/** Out-of-band error reporting (never alters the response). */
4249
onerror?: (error: Error) => void;
4350
}
@@ -103,7 +110,7 @@ export interface ListenRouter {
103110
}
104111

105112
export function createListenRouter(options: ListenRouterOptions): ListenRouter {
106-
const { bus, onerror } = options;
113+
const { bus, onerror, serverCapabilities } = options;
107114
const maxSubscriptions = options.maxSubscriptions ?? DEFAULT_MAX_SUBSCRIPTIONS;
108115
const keepAliveMs = options.keepAliveMs ?? DEFAULT_LISTEN_KEEPALIVE_MS;
109116

@@ -119,7 +126,7 @@ export function createListenRouter(options: ListenRouterOptions): ListenRouter {
119126
if (filter === undefined) {
120127
return jsonRpcError(message.id, -32_602, "Invalid params: 'notifications' is required and must be a valid SubscriptionFilter");
121128
}
122-
const honored = honoredSubset(filter);
129+
const honored = honoredSubset(filter, serverCapabilities);
123130
// The spec carries the listen request's JSON-RPC id verbatim as the
124131
// subscription id; demux is per-connection (each HTTP listen has its
125132
// own SSE stream) so client-chosen ids cannot route across requests.
@@ -246,7 +253,10 @@ export class StdioListenRouter {
246253
/** Active subscriptions, keyed by the listen request's JSON-RPC id verbatim. */
247254
private readonly _subs = new Map<RequestId, SubscriptionFilter>();
248255

249-
constructor(private readonly _maxSubscriptions: number = DEFAULT_MAX_SUBSCRIPTIONS) {}
256+
constructor(
257+
private readonly _maxSubscriptions: number = DEFAULT_MAX_SUBSCRIPTIONS,
258+
private readonly _serverCapabilities?: ServerCapabilities
259+
) {}
250260

251261
/** Whether `id` is an active listen subscription on this connection. */
252262
has(id: RequestId): boolean {
@@ -270,7 +280,7 @@ export class StdioListenRouter {
270280
error: { code: -32_602, message: "Invalid params: 'notifications' is required and must be a valid SubscriptionFilter" }
271281
};
272282
}
273-
const honored = honoredSubset(filter);
283+
const honored = honoredSubset(filter, this._serverCapabilities);
274284
this._subs.set(message.id, honored);
275285
return stampSubscriptionId({ method: 'notifications/subscriptions/acknowledged', params: { notifications: honored } }, message.id);
276286
}

packages/server/src/server/serverEventBus.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { SubscriptionFilter } from '@modelcontextprotocol/core';
1+
import type { ServerCapabilities, SubscriptionFilter } from '@modelcontextprotocol/core';
22

33
/**
44
* A change event a server publishes for delivery on open `subscriptions/listen`
@@ -143,19 +143,31 @@ export function listenFilterAccepts(filter: SubscriptionFilter, event: ServerEve
143143

144144
/**
145145
* The honored subset of a requested filter: keeps only the fields the client
146-
* explicitly opted in to (drops `false` and absent fields). The serving entry
147-
* sends this back in `notifications/subscriptions/acknowledged`.
146+
* explicitly opted in to (drops `false` and absent fields), narrowed against
147+
* the server's declared capabilities when supplied. The serving entry sends
148+
* this back in `notifications/subscriptions/acknowledged` so the ack reflects
149+
* what the server can actually deliver.
148150
*
149-
* The entry-handled listen router does not narrow the requested set further —
150-
* the bus only emits events the consumer publishes, so honoring the full
151-
* request is harmless when the server never publishes a given type.
151+
* - `toolsListChanged` is honored only when `capabilities.tools.listChanged`
152+
* is advertised; likewise `promptsListChanged` / `resourcesListChanged`.
153+
* - `resourceSubscriptions` is honored only when
154+
* `capabilities.resources.subscribe` is advertised.
155+
*
156+
* When `capabilities` is omitted the requested set is honored as-is (the
157+
* pre-existing behavior, for callers that cannot supply capabilities at
158+
* router creation time).
152159
*/
153-
export function honoredSubset(requested: SubscriptionFilter): SubscriptionFilter {
160+
export function honoredSubset(requested: SubscriptionFilter, capabilities?: ServerCapabilities): SubscriptionFilter {
154161
const honored: SubscriptionFilter = {};
155-
if (requested.toolsListChanged === true) honored.toolsListChanged = true;
156-
if (requested.promptsListChanged === true) honored.promptsListChanged = true;
157-
if (requested.resourcesListChanged === true) honored.resourcesListChanged = true;
158-
if (requested.resourceSubscriptions !== undefined && requested.resourceSubscriptions.length > 0) {
162+
const allow = (bit: unknown): boolean => capabilities === undefined || bit === true;
163+
if (requested.toolsListChanged === true && allow(capabilities?.tools?.listChanged)) honored.toolsListChanged = true;
164+
if (requested.promptsListChanged === true && allow(capabilities?.prompts?.listChanged)) honored.promptsListChanged = true;
165+
if (requested.resourcesListChanged === true && allow(capabilities?.resources?.listChanged)) honored.resourcesListChanged = true;
166+
if (
167+
requested.resourceSubscriptions !== undefined &&
168+
requested.resourceSubscriptions.length > 0 &&
169+
allow(capabilities?.resources?.subscribe)
170+
) {
159171
honored.resourceSubscriptions = [...requested.resourceSubscriptions];
160172
}
161173
return honored;

packages/server/test/server/serverEventBus.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,24 @@ describe('honoredSubset', () => {
6060
requested.resourceSubscriptions.push('file:///b');
6161
expect(honored.resourceSubscriptions).toEqual(['file:///a']);
6262
});
63+
64+
it('narrows against the supplied server capabilities', () => {
65+
const requested = {
66+
toolsListChanged: true as const,
67+
promptsListChanged: true as const,
68+
resourcesListChanged: true as const,
69+
resourceSubscriptions: ['file:///a']
70+
};
71+
// Only tools.listChanged advertised → only toolsListChanged honored.
72+
expect(honoredSubset(requested, { tools: { listChanged: true } })).toEqual({ toolsListChanged: true });
73+
// resources.subscribe gates resourceSubscriptions; resources.listChanged gates resourcesListChanged.
74+
expect(honoredSubset(requested, { resources: { subscribe: true } })).toEqual({ resourceSubscriptions: ['file:///a'] });
75+
expect(honoredSubset(requested, { resources: { listChanged: true } })).toEqual({ resourcesListChanged: true });
76+
// No relevant capability advertised → empty.
77+
expect(honoredSubset(requested, {})).toEqual({});
78+
// Omitted capabilities → requested set honored as-is (back-compat).
79+
expect(honoredSubset(requested)).toEqual(requested);
80+
});
6381
});
6482

6583
describe('serverEventToNotification', () => {

0 commit comments

Comments
 (0)