Skip to content

Commit 0ff1bd0

Browse files
feat(server): subscriptions/listen — entry-handled router and ServerEventBus (#2321)
1 parent 7f425ee commit 0ff1bd0

28 files changed

Lines changed: 1772 additions & 137 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@modelcontextprotocol/core': minor
3+
'@modelcontextprotocol/server': minor
4+
---
5+
6+
`subscriptions/listen` (SEP-1865) is served by both serving entries on protocol revision 2026-07-28. The entry owns ack-first, per-stream filtering, subscription-id stamping, keepalive (HTTP), the pre-ack `-32603` capacity guard, and teardown (HTTP stream close; one
7+
`notifications/cancelled` per subscription on stdio). `server/discover` now advertises `listChanged`/`subscribe` capability bits — the rider that suppressed them until listen was served is discharged.
8+
9+
Under `createMcpHandler` the consumer's factory **is** constructed for `subscriptions/listen` (a capabilities-only probe so the acknowledged filter reflects what the server advertises; the instance is never connected and is closed immediately). Per-request authorization performed inside the factory therefore sees listen requests; token verification still belongs at the middleware layer mounted in front of the entry.
10+
11+
New public surface:
12+
13+
- `@modelcontextprotocol/server`: `ServerEventBus`, `ServerEvent`, `ServerNotifier` (types); `InMemoryServerEventBus` (class).
14+
- `McpHttpHandler` gains `.notify` (`ServerNotifier`: `toolsChanged()`, `promptsChanged()`, `resourcesChanged()`, `resourceUpdated(uri)`) and `.bus` (the `ServerEventBus` listen streams subscribe to).
15+
- `CreateMcpHandlerOptions` gains `bus?: ServerEventBus` (an in-process `InMemoryServerEventBus` is created when omitted), `maxSubscriptions?: number` (default 1024), and `keepAliveMs?: number` (default 15000).
16+
- `ServeStdioOptions` gains `maxSubscriptions?: number` (default 1024). On a modern-pinned connection `serveStdio` routes the pinned instance's existing `send*ListChanged()` calls onto active subscriptions; legacy connections are unchanged.
17+
- `@modelcontextprotocol/core`: `SUBSCRIPTION_ID_META_KEY` (const); `SubscriptionFilter`, `SubscriptionsListenRequest`, `SubscriptionsListenRequestParams`, `SubscriptionsAcknowledgedNotification`, `SubscriptionsAcknowledgedNotificationParams` (types).

packages/codemod/src/generated/specSchemaMap.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,11 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet<string> = new Set([
133133
'StringSchemaSchema',
134134
'SubscribeRequestParamsSchema',
135135
'SubscribeRequestSchema',
136+
'SubscriptionFilterSchema',
137+
'SubscriptionsAcknowledgedNotificationParamsSchema',
138+
'SubscriptionsAcknowledgedNotificationSchema',
139+
'SubscriptionsListenRequestParamsSchema',
140+
'SubscriptionsListenRequestSchema',
136141
'TaskAugmentedRequestParamsSchema',
137142
'TaskMetadataSchema',
138143
'TextContentSchema',

packages/core/src/exports/public/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export {
8585
PARSE_ERROR,
8686
PROTOCOL_VERSION_META_KEY,
8787
RELATED_TASK_META_KEY,
88+
SUBSCRIPTION_ID_META_KEY,
8889
SUPPORTED_PROTOCOL_VERSIONS,
8990
TRACEPARENT_META_KEY,
9091
TRACESTATE_META_KEY

packages/core/src/shared/inboundClassification.ts

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ import { PROTOCOL_VERSION_META_KEY } from '../types/constants.js';
6767
import { ProtocolErrorCode } from '../types/enums.js';
6868
import { ProtocolError, UnsupportedProtocolVersionError } from '../types/errors.js';
6969
import { isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse } from '../types/guards.js';
70-
import type { MessageClassification } from '../types/types.js';
70+
import type { JSONRPCNotification, JSONRPCRequest, MessageClassification } from '../types/types.js';
7171
import { envelopeClaimVersion, hasEnvelopeClaim, requestMetaOf, validateEnvelopeMeta } from './envelope.js';
7272
import { isModernProtocolVersion } from './protocolEras.js';
7373

@@ -126,17 +126,32 @@ export interface InboundLegacyRoute {
126126
requestedVersion?: string;
127127
}
128128

129-
/** The request claims the per-request envelope mechanism and is served on the modern path. */
130-
export interface InboundModernRoute {
131-
kind: 'modern';
132-
/** Whether the classified message is a request or a notification. */
133-
messageKind: 'request' | 'notification';
134-
/**
135-
* The classification handed to the per-request transport and validated by
136-
* the protocol layer against the serving instance's negotiated era.
137-
*/
138-
classification: MessageClassification;
139-
}
129+
/**
130+
* The request claims the per-request envelope mechanism and is served on the
131+
* modern path. Discriminated by `messageKind` so the typed `message` narrows
132+
* with it — the classifier has already proved the JSON-RPC shape via the
133+
* `isJSONRPCRequest` / `isJSONRPCNotification` guards, so consumers never
134+
* cast the body again.
135+
*/
136+
export type InboundModernRoute =
137+
| {
138+
kind: 'modern';
139+
messageKind: 'request';
140+
/** The classified body — guard-proved {@linkcode JSONRPCRequest} shape. */
141+
message: JSONRPCRequest;
142+
/**
143+
* The classification handed to the per-request transport and validated by
144+
* the protocol layer against the serving instance's negotiated era.
145+
*/
146+
classification: MessageClassification;
147+
}
148+
| {
149+
kind: 'modern';
150+
messageKind: 'notification';
151+
/** The classified body — guard-proved {@linkcode JSONRPCNotification} shape. */
152+
message: JSONRPCNotification;
153+
classification: MessageClassification;
154+
};
140155

141156
/** The named steps of the inbound validation ladder, in evaluation order. */
142157
export type InboundValidationRung =
@@ -461,9 +476,9 @@ function classifyBatch(body: readonly unknown[]): InboundClassificationOutcome {
461476
return { kind: 'legacy', reason: 'batch' };
462477
}
463478

464-
function classifyRequestBody(request: InboundHttpRequest, body: Record<string, unknown>): InboundClassificationOutcome {
465-
const params = body['params'];
466-
const method = body['method'] as string;
479+
function classifyRequestBody(request: InboundHttpRequest, body: JSONRPCRequest): InboundClassificationOutcome {
480+
const params = body.params;
481+
const method = body.method;
467482
const headerVersion = request.protocolVersionHeader;
468483
const headerNamesModern = headerVersion !== undefined && isModernProtocolVersion(headerVersion);
469484

@@ -523,7 +538,7 @@ function classifyRequestBody(request: InboundHttpRequest, body: Record<string, u
523538
`the body names method ${method} but the Mcp-Method header names ${request.mcpMethodHeader}`
524539
);
525540
}
526-
return { kind: 'modern', messageKind: 'request', classification: classificationForClaim(claimedVersion) };
541+
return { kind: 'modern', messageKind: 'request', message: body, classification: classificationForClaim(claimedVersion) };
527542
}
528543

529544
// No claim: legacy-era traffic — unless the protocol-version header names a
@@ -554,9 +569,9 @@ function classifyRequestBody(request: InboundHttpRequest, body: Record<string, u
554569
return { kind: 'legacy', reason: 'no-claim', ...(headerVersion !== undefined && { requestedVersion: headerVersion }) };
555570
}
556571

557-
function classifyNotificationBody(request: InboundHttpRequest, body: Record<string, unknown>): InboundClassificationOutcome {
558-
const params = body['params'];
559-
const method = body['method'] as string;
572+
function classifyNotificationBody(request: InboundHttpRequest, body: JSONRPCNotification): InboundClassificationOutcome {
573+
const params = body.params;
574+
const method = body.method;
560575
const headerVersion = request.protocolVersionHeader;
561576
const headerNamesModern = headerVersion !== undefined && isModernProtocolVersion(headerVersion);
562577

@@ -603,7 +618,7 @@ function classifyNotificationBody(request: InboundHttpRequest, body: Record<stri
603618
`the notification body names method ${method} but the Mcp-Method header names ${request.mcpMethodHeader}`
604619
);
605620
}
606-
return { kind: 'modern', messageKind: 'notification', classification };
621+
return { kind: 'modern', messageKind: 'notification', message: body, classification };
607622
}
608623

609624
// Notifications carry no body claim under the current spec, so the
@@ -623,6 +638,7 @@ function classifyNotificationBody(request: InboundHttpRequest, body: Record<stri
623638
return {
624639
kind: 'modern',
625640
messageKind: 'notification',
641+
message: body,
626642
classification: { era: 'modern', revision: headerVersion }
627643
};
628644
}

packages/core/src/types/constants.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ export const CLIENT_INFO_META_KEY = 'io.modelcontextprotocol/clientInfo';
3131
*/
3232
export const CLIENT_CAPABILITIES_META_KEY = 'io.modelcontextprotocol/clientCapabilities';
3333

34+
/**
35+
* `_meta` key carrying the JSON-RPC ID of the `subscriptions/listen` request
36+
* that opened the stream a notification was delivered on.
37+
*
38+
* Stamped by the server on every notification delivered via a
39+
* `subscriptions/listen` stream (including the leading
40+
* `notifications/subscriptions/acknowledged`); on stdio, where all messages
41+
* share one channel, clients use it to correlate notifications with their
42+
* originating subscription. The value is the listen request's JSON-RPC ID
43+
* verbatim.
44+
*/
45+
export const SUBSCRIPTION_ID_META_KEY = 'io.modelcontextprotocol/subscriptionId';
46+
3447
/**
3548
* `_meta` key carrying the desired log level for a request.
3649
*

packages/core/src/types/schemas.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -898,6 +898,67 @@ export const UnsubscribeRequestSchema = RequestSchema.extend({
898898
params: UnsubscribeRequestParamsSchema
899899
});
900900

901+
/* Subscriptions (protocol revision 2026-07-28) */
902+
/**
903+
* The set of notification types a client opts in to on a `subscriptions/listen`
904+
* request. Each type is opt-in; the server MUST NOT send a notification type
905+
* the client has not explicitly requested here.
906+
*/
907+
export const SubscriptionFilterSchema = z.object({
908+
/**
909+
* If true, receive `notifications/tools/list_changed`.
910+
*/
911+
toolsListChanged: z.boolean().optional(),
912+
/**
913+
* If true, receive `notifications/prompts/list_changed`.
914+
*/
915+
promptsListChanged: z.boolean().optional(),
916+
/**
917+
* If true, receive `notifications/resources/list_changed`.
918+
*/
919+
resourcesListChanged: z.boolean().optional(),
920+
/**
921+
* Subscribe to `notifications/resources/updated` for these resource URIs.
922+
* Replaces the former `resources/subscribe` RPC on the 2026-07-28 revision.
923+
*/
924+
resourceSubscriptions: z.array(z.string()).optional()
925+
});
926+
927+
export const SubscriptionsListenRequestParamsSchema = BaseRequestParamsSchema.extend({
928+
/**
929+
* The notifications the client opts in to on this stream. The server MUST
930+
* NOT send notification types the client has not explicitly requested.
931+
*/
932+
notifications: SubscriptionFilterSchema
933+
});
934+
935+
/**
936+
* Sent from the client to open a long-lived channel for receiving notifications
937+
* outside the context of a specific request (protocol revision 2026-07-28).
938+
* Replaces the previous HTTP GET endpoint and `resources/subscribe`.
939+
*/
940+
export const SubscriptionsListenRequestSchema = RequestSchema.extend({
941+
method: z.literal('subscriptions/listen'),
942+
params: SubscriptionsListenRequestParamsSchema
943+
});
944+
945+
export const SubscriptionsAcknowledgedNotificationParamsSchema = NotificationsParamsSchema.extend({
946+
/**
947+
* The subset of requested notification types the server agreed to honor.
948+
*/
949+
notifications: SubscriptionFilterSchema
950+
});
951+
952+
/**
953+
* Sent by the server as the first message on a `subscriptions/listen` stream
954+
* to acknowledge that the subscription has been established and report which
955+
* notification types it agreed to honor (protocol revision 2026-07-28).
956+
*/
957+
export const SubscriptionsAcknowledgedNotificationSchema = NotificationSchema.extend({
958+
method: z.literal('notifications/subscriptions/acknowledged'),
959+
params: SubscriptionsAcknowledgedNotificationParamsSchema
960+
});
961+
901962
/**
902963
* Parameters for a {@linkcode ResourceUpdatedNotification | notifications/resources/updated} notification.
903964
*/
@@ -2025,6 +2086,7 @@ export const ClientRequestSchema = z.union([
20252086
ReadResourceRequestSchema,
20262087
SubscribeRequestSchema,
20272088
UnsubscribeRequestSchema,
2089+
SubscriptionsListenRequestSchema,
20282090
CallToolRequestSchema,
20292091
ListToolsRequestSchema
20302092
]);
@@ -2055,6 +2117,7 @@ export const ServerNotificationSchema = z.union([
20552117
ResourceListChangedNotificationSchema,
20562118
ToolListChangedNotificationSchema,
20572119
PromptListChangedNotificationSchema,
2120+
SubscriptionsAcknowledgedNotificationSchema,
20582121
ElicitationCompleteNotificationSchema
20592122
]);
20602123

packages/core/src/types/specTypeSchema.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@ const SPEC_SCHEMA_KEYS = [
153153
'StringSchemaSchema',
154154
'SubscribeRequestSchema',
155155
'SubscribeRequestParamsSchema',
156+
'SubscriptionFilterSchema',
157+
'SubscriptionsAcknowledgedNotificationSchema',
158+
'SubscriptionsAcknowledgedNotificationParamsSchema',
159+
'SubscriptionsListenRequestSchema',
160+
'SubscriptionsListenRequestParamsSchema',
156161
'TaskAugmentedRequestParamsSchema',
157162
'TaskMetadataSchema',
158163
'TextContentSchema',

packages/core/src/types/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,11 @@ import type {
146146
StringSchemaSchema,
147147
SubscribeRequestParamsSchema,
148148
SubscribeRequestSchema,
149+
SubscriptionFilterSchema,
150+
SubscriptionsAcknowledgedNotificationParamsSchema,
151+
SubscriptionsAcknowledgedNotificationSchema,
152+
SubscriptionsListenRequestParamsSchema,
153+
SubscriptionsListenRequestSchema,
149154
TaskAugmentedRequestParamsSchema,
150155
TaskMetadataSchema,
151156
TextContentSchema,
@@ -352,6 +357,13 @@ export type UnsubscribeRequest = Infer<typeof UnsubscribeRequestSchema>;
352357
export type ResourceUpdatedNotificationParams = Infer<typeof ResourceUpdatedNotificationParamsSchema>;
353358
export type ResourceUpdatedNotification = Infer<typeof ResourceUpdatedNotificationSchema>;
354359

360+
/* Subscriptions (protocol revision 2026-07-28) */
361+
export type SubscriptionFilter = Infer<typeof SubscriptionFilterSchema>;
362+
export type SubscriptionsListenRequestParams = Infer<typeof SubscriptionsListenRequestParamsSchema>;
363+
export type SubscriptionsListenRequest = Infer<typeof SubscriptionsListenRequestSchema>;
364+
export type SubscriptionsAcknowledgedNotificationParams = Infer<typeof SubscriptionsAcknowledgedNotificationParamsSchema>;
365+
export type SubscriptionsAcknowledgedNotification = Infer<typeof SubscriptionsAcknowledgedNotificationSchema>;
366+
355367
/* Prompts */
356368
export type PromptArgument = Infer<typeof PromptArgumentSchema>;
357369
export type Prompt = Infer<typeof PromptSchema>;
@@ -551,6 +563,11 @@ export type ResultTypeMap = {
551563
'resources/read': ReadResourceResult;
552564
'resources/subscribe': EmptyResult;
553565
'resources/unsubscribe': EmptyResult;
566+
// `subscriptions/listen` never receives a JSON-RPC result on the wire:
567+
// termination is stream close (HTTP) or `notifications/cancelled` (stdio).
568+
// The `EmptyResult` entry exists only to keep the mapped types total —
569+
// see the serving entries' listen routers.
570+
'subscriptions/listen': EmptyResult;
554571
'tools/call': CallToolResult;
555572
'tools/list': ListToolsResult;
556573
'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools;

packages/core/src/wire/rev2026-07-28/encodeContract.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ export const DEFAULT_CACHE_SCOPE = 'private';
4141
* Request methods whose spec result vocabulary goes beyond `'complete'` on the
4242
* 2026-07-28 revision: their results may be `input_required` (multi
4343
* round-trip requests), so a handler-provided `resultType` passes through the
44-
* stamp untouched. `subscriptions/listen` joins this set when the
45-
* subscriptions feature is served (its terminal result uses the same
46-
* mechanism).
44+
* stamp untouched. `subscriptions/listen` is NOT in this set: it never emits
45+
* a JSON-RPC result — termination is stream close (HTTP) or
46+
* `notifications/cancelled` (stdio) per the spec.
4747
*/
4848
export const EXTENDED_RESULT_TYPE_METHODS: readonly string[] = ['tools/call', 'prompts/get', 'resources/read'];
4949

packages/core/src/wire/rev2026-07-28/registry.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@
2020
* (the ATK-D flavor-b trap); this hand registry excludes them by
2121
* construction. Their in-band role lands with the MRTR driver (#13).
2222
* - `subscriptions/listen` + `notifications/subscriptions/acknowledged`
23-
* (SEP-1865): 2026-only vocabulary whose SHELLS land with the
24-
* subscriptions feature (#14). Until then they are absent here — inbound
25-
* listen gets −32601 (capability not yet served), which is protocol-legal
26-
* for a server that does not implement subscriptions.
23+
* (SEP-1865): 2026-only vocabulary, present here as registry shells.
24+
* Dispatch never reaches a registered handler — the serving entries
25+
* (`createMcpHandler`, `serveStdio`) recognize listen at the entry layer
26+
* and own ack/filter/stamp/teardown themselves.
2727
*/
2828
import type * as z from 'zod/v4';
2929

0 commit comments

Comments
 (0)