subscriptions/listen cannot carry extension notifications (blocks notifications/tasks)
Package: @modelcontextprotocol/server@2.0.0
Spec: 2026-07-28 · Tasks extension (io.modelcontextprotocol/tasks, SEP-2663)
Summary
The Tasks extension specifies that clients opt into task status notifications through
subscriptions/listen:
Servers can push status updates via notifications/tasks. Clients opt into these through the
subscriptions/listen mechanism.
The SDK's subscriptions/listen implementation cannot express this, in both directions.
Detail
1. The filter is a strict, closed object. SubscriptionFilterSchema admits exactly four keys:
const SubscriptionFilterSchema = z.object({
toolsListChanged: z.boolean().optional(),
promptsListChanged: z.boolean().optional(),
resourcesListChanged: z.boolean().optional(),
resourceSubscriptions: z.array(z.string()).optional(),
});
It is z.object, not z.looseObject, so an extension-namespaced key such as
"io.modelcontextprotocol/tasks": true does not survive parsing. A client has no way to opt in.
2. ServerEvent is a closed union over the same four kinds.
type ServerEvent =
| { kind: 'tools_list_changed' }
| { kind: 'prompts_list_changed' }
| { kind: 'resources_list_changed' }
| { kind: 'resource_updated'; uri: string };
ServerEventBus.publish accepts only these, and ServerNotifier exposes only the matching four
methods. A server has no way to publish a task event onto an open stream.
3. The only task notification in the method registry is the 2025 name.
notificationMethodKeys carries notifications/tasks/status (2025-11-25 vocabulary, already marked
@deprecated elsewhere in the SDK), not the extension's notifications/tasks.
Net effect: subscriptions/listen is usable for the four core change types and closed to every
extension, including the one official extension that depends on it.
Reproduction
Send a subscriptions/listen whose filter contains an extension key:
{"jsonrpc":"2.0","id":1,"method":"subscriptions/listen",
"params":{"notifications":{"io.modelcontextprotocol/tasks":true}}}
The stream opens, returns HTTP 200 and a well-formed acknowledgement carrying
"notifications": {} — an empty filter. No error is raised on either side; the client waits
indefinitely for events it believes it subscribed to.
(Worth noting independently: the same silent-empty-filter behaviour occurs for core keys when the
server has not declared the corresponding listChanged capability. An ack echoing an empty filter
after a non-empty request is difficult to distinguish from a working subscription. A warning, or
echoing which requested keys were dropped, would make that failure visible.)
Suggested direction
Any one of:
- Make
SubscriptionFilterSchema a looseObject, and widen ServerEvent with an
extension-carrying variant, e.g. { kind: 'extension'; method: string; params: unknown }, with a
matching ServerNotifier.extension(method, params).
- Add a registration hook so an extension can contribute a filter key plus an event kind.
- Document that extension notifications must be served ahead of
createMcpHandler, and export
enough of the listen-stream machinery (ack framing, SUBSCRIPTION_ID_META_KEY stamping,
graceful close) that a third-party implementation stays wire-compatible.
Workaround in use
We serve the extension's stream ourselves, in front of createMcpHandler, keyed on the extension
identifier so it cannot collide with a future core key; core subscriptions/listen requests are
passed through untouched. This is the same seam we already use for tasks/get / tasks/cancel /
tasks/update, which the handler likewise rejects as unknown methods.
Happy to open a PR for option 1 if that direction is welcome.
subscriptions/listencannot carry extension notifications (blocksnotifications/tasks)Package:
@modelcontextprotocol/server@2.0.0Spec: 2026-07-28 · Tasks extension (
io.modelcontextprotocol/tasks, SEP-2663)Summary
The Tasks extension specifies that clients opt into task status notifications through
subscriptions/listen:The SDK's
subscriptions/listenimplementation cannot express this, in both directions.Detail
1. The filter is a strict, closed object.
SubscriptionFilterSchemaadmits exactly four keys:It is
z.object, notz.looseObject, so an extension-namespaced key such as"io.modelcontextprotocol/tasks": truedoes not survive parsing. A client has no way to opt in.2.
ServerEventis a closed union over the same four kinds.ServerEventBus.publishaccepts only these, andServerNotifierexposes only the matching fourmethods. A server has no way to publish a task event onto an open stream.
3. The only task notification in the method registry is the 2025 name.
notificationMethodKeyscarriesnotifications/tasks/status(2025-11-25 vocabulary, already marked@deprecatedelsewhere in the SDK), not the extension'snotifications/tasks.Net effect:
subscriptions/listenis usable for the four core change types and closed to everyextension, including the one official extension that depends on it.
Reproduction
Send a
subscriptions/listenwhose filter contains an extension key:{"jsonrpc":"2.0","id":1,"method":"subscriptions/listen", "params":{"notifications":{"io.modelcontextprotocol/tasks":true}}}The stream opens, returns HTTP 200 and a well-formed acknowledgement carrying
"notifications": {}— an empty filter. No error is raised on either side; the client waitsindefinitely for events it believes it subscribed to.
(Worth noting independently: the same silent-empty-filter behaviour occurs for core keys when the
server has not declared the corresponding
listChangedcapability. An ack echoing an empty filterafter a non-empty request is difficult to distinguish from a working subscription. A warning, or
echoing which requested keys were dropped, would make that failure visible.)
Suggested direction
Any one of:
SubscriptionFilterSchemaalooseObject, and widenServerEventwith anextension-carrying variant, e.g.
{ kind: 'extension'; method: string; params: unknown }, with amatching
ServerNotifier.extension(method, params).createMcpHandler, and exportenough of the listen-stream machinery (ack framing,
SUBSCRIPTION_ID_META_KEYstamping,graceful close) that a third-party implementation stays wire-compatible.
Workaround in use
We serve the extension's stream ourselves, in front of
createMcpHandler, keyed on the extensionidentifier so it cannot collide with a future core key; core
subscriptions/listenrequests arepassed through untouched. This is the same seam we already use for
tasks/get/tasks/cancel/tasks/update, which the handler likewise rejects as unknown methods.Happy to open a PR for option 1 if that direction is welcome.