diff --git a/.changeset/bright-tools-listen.md b/.changeset/bright-tools-listen.md new file mode 100644 index 0000000000..22ebb049b9 --- /dev/null +++ b/.changeset/bright-tools-listen.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/core': patch +--- + +Allow extension-namespaced subscription filters and publish matching extension notifications through `ServerNotifier`. diff --git a/packages/core-internal/src/types/spec.types.2026-07-28.ts b/packages/core-internal/src/types/spec.types.2026-07-28.ts index f4430b850f..23e360d070 100644 --- a/packages/core-internal/src/types/spec.types.2026-07-28.ts +++ b/packages/core-internal/src/types/spec.types.2026-07-28.ts @@ -1262,6 +1262,7 @@ export interface ResourceListChangedNotification extends JSONRPCNotification { * @category `subscriptions/listen` */ export interface SubscriptionFilter { + [key: string]: unknown; /** * If true, receive {@link ToolListChangedNotification | notifications/tools/list_changed}. */ diff --git a/packages/core-internal/src/wire/rev2026-07-28/buildSchemas.ts b/packages/core-internal/src/wire/rev2026-07-28/buildSchemas.ts index 6831062fd3..ae2c9afcb5 100644 --- a/packages/core-internal/src/wire/rev2026-07-28/buildSchemas.ts +++ b/packages/core-internal/src/wire/rev2026-07-28/buildSchemas.ts @@ -1060,7 +1060,7 @@ function build() { const DiscoverRequestSchema = wireRequest('server/discover', {}); /** Anchor SubscriptionFilter (2026-only). */ - const SubscriptionFilterSchema = z.object({ + const SubscriptionFilterSchema = z.looseObject({ toolsListChanged: z.boolean().optional(), promptsListChanged: z.boolean().optional(), resourcesListChanged: z.boolean().optional(), diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index 3a7dd5b56d..de11ec3cf7 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -925,7 +925,7 @@ export const UnsubscribeRequestSchema = RequestSchema.extend({ * request. Each type is opt-in; the server MUST NOT send a notification type * the client has not explicitly requested here. */ -export const SubscriptionFilterSchema = z.object({ +export const SubscriptionFilterSchema = z.looseObject({ /** * If true, receive `notifications/tools/list_changed`. */ diff --git a/packages/core/test/coreSchemas.test.ts b/packages/core/test/coreSchemas.test.ts index 3efcfdbd79..bad86a0653 100644 --- a/packages/core/test/coreSchemas.test.ts +++ b/packages/core/test/coreSchemas.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import * as core from '../src/index'; -import { CursorSchema, InitializeRequestSchema, OAuthTokensSchema } from '../src/index'; +import { CursorSchema, InitializeRequestSchema, OAuthTokensSchema, SubscriptionFilterSchema } from '../src/index'; function readCore(relativePath: string): string { return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8'); @@ -24,6 +24,12 @@ describe('@modelcontextprotocol/core', () => { expect(OAuthTokensSchema.safeParse({ access_token: 'tok', token_type: 'Bearer' }).success).toBe(true); }); + it('preserves extension-namespaced subscription filter keys', () => { + expect(SubscriptionFilterSchema.parse({ 'io.modelcontextprotocol/tasks': true })).toEqual({ + 'io.modelcontextprotocol/tasks': true + }); + }); + it('re-exports exactly core’s spec + OAuth schemas — no internal helpers (drift guard)', () => { // core's public surface is two SEPARATE groups, mirroring core-internal's own spec-vs-auth split: // 1. spec `*Schema` constants from core-internal/src/types/schemas.ts (minus internal helpers with no diff --git a/packages/server/src/server/serverEventBus.ts b/packages/server/src/server/serverEventBus.ts index 3f97f62309..fade18593a 100644 --- a/packages/server/src/server/serverEventBus.ts +++ b/packages/server/src/server/serverEventBus.ts @@ -1,4 +1,4 @@ -import type { ServerCapabilities, SubscriptionFilter } from '@modelcontextprotocol/core-internal'; +import type { JSONObject, ServerCapabilities, SubscriptionFilter } from '@modelcontextprotocol/core-internal'; /** * A change event a server publishes for delivery on open `subscriptions/listen` @@ -8,6 +8,7 @@ import type { ServerCapabilities, SubscriptionFilter } from '@modelcontextprotoc * - `prompts_list_changed` → `notifications/prompts/list_changed` * - `resources_list_changed` → `notifications/resources/list_changed` * - `resource_updated` → `notifications/resources/updated` (carries the URI) + * - `extension` → an extension notification (carries its method and params) * * The bus carries the EVENT, not the wire shape — the entry's listen router * owns subscription-id stamping and per-stream filtering. @@ -16,7 +17,8 @@ export type ServerEvent = | { kind: 'tools_list_changed' } | { kind: 'prompts_list_changed' } | { kind: 'resources_list_changed' } - | { kind: 'resource_updated'; uri: string }; + | { kind: 'resource_updated'; uri: string } + | { kind: 'extension'; filterKey: string; method: string; params: JSONObject }; /** * The server-side change-event seam for `subscriptions/listen`. @@ -100,6 +102,8 @@ export interface ServerNotifier { promptsChanged(): void; /** Publish `notifications/resources/list_changed` to every open subscription that opted in. */ resourcesChanged(): void; + /** Publish an extension notification to every open subscription that opted in to `filterKey`. */ + extension(filterKey: string, method: string, params: JSONObject): void; /** Publish `notifications/resources/updated` for `uri` to every open subscription that opted in to that URI. */ resourceUpdated(uri: string): void; } @@ -110,6 +114,7 @@ export function createServerNotifier(bus: ServerEventBus): ServerNotifier { toolsChanged: () => bus.publish({ kind: 'tools_list_changed' }), promptsChanged: () => bus.publish({ kind: 'prompts_list_changed' }), resourcesChanged: () => bus.publish({ kind: 'resources_list_changed' }), + extension: (filterKey: string, method: string, params: JSONObject) => bus.publish({ kind: 'extension', filterKey, method, params }), resourceUpdated: (uri: string) => bus.publish({ kind: 'resource_updated', uri }) }; } @@ -138,6 +143,9 @@ export function listenFilterAccepts(filter: SubscriptionFilter, event: ServerEve case 'resource_updated': { return filter.resourceSubscriptions !== undefined && filter.resourceSubscriptions.includes(event.uri); } + case 'extension': { + return filter[event.filterKey] === true; + } } } @@ -172,11 +180,14 @@ export function honoredSubset(requested: SubscriptionFilter, capabilities?: Serv ) { honored.resourceSubscriptions = [...requested.resourceSubscriptions]; } + for (const [key, value] of Object.entries(requested)) { + if (!(key in honored) && value === true && key.includes('/')) honored[key] = true; + } return honored; } /** Map a {@linkcode ServerEvent} onto its wire notification `{method, params}`. */ -export function serverEventToNotification(event: ServerEvent): { method: string; params?: { uri: string } } { +export function serverEventToNotification(event: ServerEvent): { method: string; params?: JSONObject } { switch (event.kind) { case 'tools_list_changed': { return { method: 'notifications/tools/list_changed' }; @@ -190,5 +201,8 @@ export function serverEventToNotification(event: ServerEvent): { method: string; case 'resource_updated': { return { method: 'notifications/resources/updated', params: { uri: event.uri } }; } + case 'extension': { + return { method: event.method, params: event.params }; + } } } diff --git a/packages/server/test/server/createMcpHandlerListen.test.ts b/packages/server/test/server/createMcpHandlerListen.test.ts index 7a0d2c8675..823d80196a 100644 --- a/packages/server/test/server/createMcpHandlerListen.test.ts +++ b/packages/server/test/server/createMcpHandlerListen.test.ts @@ -190,6 +190,40 @@ describe('createMcpHandler — subscriptions/listen', () => { await handler.close(); }); + it('acknowledges and delivers an opted-in extension notification', async () => { + const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0 }); + const response = await handler.fetch(listenRequest('tasks-subscription', { 'io.modelcontextprotocol/tasks': true })); + handler.notify.extension('io.modelcontextprotocol/tasks', 'notifications/tasks', { + taskId: 'task-1', + status: 'working', + progress: { completed: 1, total: 2 } + }); + handler.notify.extension('io.modelcontextprotocol/other', 'notifications/other', { ignored: true }); + + const messages = (await readMessages(response, 2)) as { method: string; params: Record }[]; + expect(messages).toEqual([ + { + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { + _meta: { [SUBSCRIPTION_ID_META_KEY]: 'tasks-subscription' }, + notifications: { 'io.modelcontextprotocol/tasks': true } + } + }, + { + jsonrpc: '2.0', + method: 'notifications/tasks', + params: { + _meta: { [SUBSCRIPTION_ID_META_KEY]: 'tasks-subscription' }, + taskId: 'task-1', + status: 'working', + progress: { completed: 1, total: 2 } + } + } + ]); + await handler.close(); + }); + it("refuses pre-ack with -32603 'Subscription limit reached' when at capacity", async () => { const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0, maxSubscriptions: 1 }); const first = await handler.fetch(listenRequest(1, { toolsListChanged: true })); diff --git a/packages/server/test/server/serverEventBus.test.ts b/packages/server/test/server/serverEventBus.test.ts index 0c42974d9d..8dab2c3650 100644 --- a/packages/server/test/server/serverEventBus.test.ts +++ b/packages/server/test/server/serverEventBus.test.ts @@ -30,6 +30,28 @@ describe('listenFilterAccepts', () => { expect(listenFilterAccepts({ resourceSubscriptions: [] }, { kind: 'resource_updated', uri: 'file:///x' })).toBe(false); // Absent = no resource updates accepted. expect(listenFilterAccepts({}, { kind: 'resource_updated', uri: 'file:///x' })).toBe(false); + expect( + listenFilterAccepts( + { 'io.modelcontextprotocol/tasks': true }, + { + kind: 'extension', + filterKey: 'io.modelcontextprotocol/tasks', + method: 'notifications/tasks', + params: { taskId: 'task-1', status: 'working' } + } + ) + ).toBe(true); + expect( + listenFilterAccepts( + {}, + { + kind: 'extension', + filterKey: 'io.modelcontextprotocol/tasks', + method: 'notifications/tasks', + params: { taskId: 'task-1', status: 'working' } + } + ) + ).toBe(false); }); it('an empty filter accepts nothing (un-requested types are provably never delivered)', () => { @@ -52,6 +74,9 @@ describe('honoredSubset', () => { it('returns an empty object for an all-absent / all-false filter', () => { expect(honoredSubset({})).toEqual({}); expect(honoredSubset({ toolsListChanged: false, resourceSubscriptions: [] })).toEqual({}); + expect(honoredSubset({ 'io.modelcontextprotocol/tasks': true })).toEqual({ + 'io.modelcontextprotocol/tasks': true + }); }); it('does not alias the requested resourceSubscriptions array', () => { @@ -91,6 +116,14 @@ describe('serverEventToNotification', () => { method: 'notifications/resources/updated', params: { uri: 'file:///a' } }); + expect( + serverEventToNotification({ + kind: 'extension', + filterKey: 'io.modelcontextprotocol/tasks', + method: 'notifications/tasks', + params: { taskId: 'task-1', status: 'working' } + }) + ).toEqual({ method: 'notifications/tasks', params: { taskId: 'task-1', status: 'working' } }); }); }); @@ -140,11 +173,18 @@ describe('InMemoryServerEventBus', () => { notify.toolsChanged(); notify.promptsChanged(); notify.resourcesChanged(); + notify.extension('io.modelcontextprotocol/tasks', 'notifications/tasks', { taskId: 'task-1', status: 'working' }); notify.resourceUpdated('file:///a'); expect(seen).toEqual([ { kind: 'tools_list_changed' }, { kind: 'prompts_list_changed' }, { kind: 'resources_list_changed' }, + { + kind: 'extension', + filterKey: 'io.modelcontextprotocol/tasks', + method: 'notifications/tasks', + params: { taskId: 'task-1', status: 'working' } + }, { kind: 'resource_updated', uri: 'file:///a' } ]); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 839b152070..03389ffba6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1938,19 +1938,10 @@ importers: version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) test/conformance: - devDependencies: + dependencies: '@modelcontextprotocol/client': specifier: workspace:^ version: link:../../packages/client - '@modelcontextprotocol/conformance': - specifier: 0.2.0-alpha.10 - version: 0.2.0-alpha.10(@cfworker/json-schema@4.1.1) - '@modelcontextprotocol/core-internal': - specifier: workspace:^ - version: link:../../packages/core-internal - '@modelcontextprotocol/eslint-config': - specifier: workspace:^ - version: link:../../common/eslint-config '@modelcontextprotocol/express': specifier: workspace:^ version: link:../../packages/middleware/express @@ -1960,6 +1951,25 @@ importers: '@modelcontextprotocol/server': specifier: workspace:^ version: link:../../packages/server + cors: + specifier: catalog:runtimeServerOnly + version: 2.8.6 + express: + specifier: catalog:runtimeServerOnly + version: 5.2.1 + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + '@modelcontextprotocol/conformance': + specifier: 0.2.0-alpha.10 + version: 0.2.0-alpha.10(@cfworker/json-schema@4.1.1) + '@modelcontextprotocol/core-internal': + specifier: workspace:^ + version: link:../../packages/core-internal + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config '@modelcontextprotocol/test-helpers': specifier: workspace:^ version: link:../helpers @@ -1969,36 +1979,43 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config - cors: - specifier: catalog:runtimeServerOnly - version: 2.8.6 - express: - specifier: catalog:runtimeServerOnly - version: 5.2.1 tsx: specifier: catalog:devTools version: 4.21.0 - zod: - specifier: catalog:runtimeShared - version: 4.3.6 test/e2e: - devDependencies: - '@hono/node-server': - specifier: catalog:runtimeServerOnly - version: 1.19.11(hono@4.12.9) + dependencies: '@modelcontextprotocol/client': specifier: workspace:^ version: link:../../packages/client '@modelcontextprotocol/core-internal': specifier: workspace:^ version: link:../../packages/core-internal - '@modelcontextprotocol/eslint-config': - specifier: workspace:^ - version: link:../../common/eslint-config '@modelcontextprotocol/express': specifier: workspace:^ version: link:../../packages/middleware/express + '@modelcontextprotocol/server': + specifier: workspace:^ + version: link:../../packages/server + '@modelcontextprotocol/server-legacy': + specifier: workspace:^ + version: link:../../packages/server-legacy + express: + specifier: catalog:runtimeServerOnly + version: 5.2.1 + vitest: + specifier: catalog:devTools + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config '@modelcontextprotocol/fastify': specifier: workspace:^ version: link:../../packages/middleware/fastify @@ -2008,12 +2025,6 @@ importers: '@modelcontextprotocol/node': specifier: workspace:^ version: link:../../packages/middleware/node - '@modelcontextprotocol/server': - specifier: workspace:^ - version: link:../../packages/server - '@modelcontextprotocol/server-legacy': - specifier: workspace:^ - version: link:../../packages/server-legacy '@modelcontextprotocol/test-helpers': specifier: workspace:^ version: link:../helpers @@ -2038,9 +2049,6 @@ importers: cors: specifier: catalog:runtimeServerOnly version: 2.8.6 - express: - specifier: catalog:runtimeServerOnly - version: 5.2.1 fastify: specifier: catalog:runtimeServerOnly version: 5.8.4 @@ -2059,14 +2067,12 @@ importers: valibot: specifier: catalog:devTools version: 1.3.1(typescript@5.9.3) + + test/helpers: + dependencies: vitest: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) - zod: - specifier: catalog:runtimeShared - version: 4.3.6 - - test/helpers: devDependencies: '@modelcontextprotocol/core-internal': specifier: workspace:^ @@ -2080,9 +2086,6 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config - vitest: - specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) zod: specifier: catalog:runtimeShared version: 4.3.6 diff --git a/test/conformance/package.json b/test/conformance/package.json index 8558f92b50..e5ecaf0e30 100644 --- a/test/conformance/package.json +++ b/test/conformance/package.json @@ -37,20 +37,22 @@ "test:conformance:server:run": "node --import tsx ./src/everythingServer.ts", "test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all" }, - "devDependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.10", + "dependencies": { "@modelcontextprotocol/client": "workspace:^", - "@modelcontextprotocol/server": "workspace:^", - "@modelcontextprotocol/core-internal": "workspace:^", "@modelcontextprotocol/express": "workspace:^", "@modelcontextprotocol/node": "workspace:^", + "@modelcontextprotocol/server": "workspace:^", + "cors": "catalog:runtimeServerOnly", + "express": "catalog:runtimeServerOnly", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "@modelcontextprotocol/conformance": "0.2.0-alpha.10", + "@modelcontextprotocol/core-internal": "workspace:^", "@modelcontextprotocol/tsconfig": "workspace:^", "@modelcontextprotocol/vitest-config": "workspace:^", "@modelcontextprotocol/eslint-config": "workspace:^", "@modelcontextprotocol/test-helpers": "workspace:^", - "cors": "catalog:runtimeServerOnly", - "express": "catalog:runtimeServerOnly", - "tsx": "catalog:devTools", - "zod": "catalog:runtimeShared" + "tsx": "catalog:devTools" } } diff --git a/test/e2e/package.json b/test/e2e/package.json index 2b052482e2..ec92a11ce4 100644 --- a/test/e2e/package.json +++ b/test/e2e/package.json @@ -29,13 +29,18 @@ "test": "vitest run", "test:watch": "vitest" }, - "devDependencies": { - "@hono/node-server": "catalog:runtimeServerOnly", + "dependencies": { "@modelcontextprotocol/client": "workspace:^", "@modelcontextprotocol/core-internal": "workspace:^", + "@modelcontextprotocol/express": "workspace:^", "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/server-legacy": "workspace:^", - "@modelcontextprotocol/express": "workspace:^", + "express": "catalog:runtimeServerOnly", + "vitest": "catalog:devTools", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", "@modelcontextprotocol/fastify": "workspace:^", "@modelcontextprotocol/hono": "workspace:^", "@modelcontextprotocol/node": "workspace:^", @@ -48,14 +53,11 @@ "@valibot/to-json-schema": "catalog:devTools", "arktype": "catalog:devTools", "cors": "catalog:runtimeServerOnly", - "express": "catalog:runtimeServerOnly", "fastify": "catalog:runtimeServerOnly", "hono": "catalog:runtimeServerOnly", "jose": "catalog:runtimeClientOnly", "tsx": "catalog:devTools", "typescript": "catalog:devTools", - "valibot": "catalog:devTools", - "vitest": "catalog:devTools", - "zod": "catalog:runtimeShared" + "valibot": "catalog:devTools" } } diff --git a/test/helpers/package.json b/test/helpers/package.json index c359fbe6c2..5ee622f27d 100644 --- a/test/helpers/package.json +++ b/test/helpers/package.json @@ -26,10 +26,12 @@ "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", "check": "npm run typecheck && npm run lint" }, + "dependencies": { + "vitest": "catalog:devTools" + }, "devDependencies": { "@modelcontextprotocol/core-internal": "workspace:^", "zod": "catalog:runtimeShared", - "vitest": "catalog:devTools", "@modelcontextprotocol/tsconfig": "workspace:^", "@modelcontextprotocol/vitest-config": "workspace:^", "@modelcontextprotocol/eslint-config": "workspace:^"