From c11ec1457ead49872fd121c352d8f9a9893f84ea Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:58:36 +0200 Subject: [PATCH] Answer subscriptions/listen without building the caller's tool surface Claude clients on MCP 2026-07-28 keep subscriptions/listen streams open for about five minutes; on 13 Oct about 200 were open at once, ~50 new ones a minute. The SDK reads only the server's capabilities and name for such a stream, yet every listen filtered the whole tool registry on the shared /mcp, and on a server's own URL built its full tool surface and kept it referenced for the stream's life. Listen is now answered from a server that only declares the same list-changed capabilities, after the same auth and membership checks. Nothing is ever published on these streams (each request has its own event bus), so clients see the same acknowledgement and an open stream as before. --- .../mcp-endpoint.controller.spec.ts | 50 ++++++++++++++++ .../src/mcp-server/mcp-endpoint.controller.ts | 60 +++++++++++++++++++ .../shared-endpoint.controller.spec.ts | 39 +++++++++++- 3 files changed, 148 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts b/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts index 6a106dca8..9270503e5 100644 --- a/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts +++ b/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts @@ -92,6 +92,56 @@ describe('McpEndpointController — tenant isolation', () => { expect(mcpServersService.getConnectorIds).not.toHaveBeenCalled(); }); + describe('subscriptions/listen', () => { + const LISTEN = { jsonrpc: '2.0', id: 1, method: 'subscriptions/listen', params: {} }; + + it('still refuses a non-member before answering', async () => { + (controller as any).serveStateless = jest.fn(); + const res = makeRes(); + await controller.handlePost( + 'srv-A', + { user: { sub: 'u-b', organizationId: 'org-B', authMethod: 'jwt' } } as any, + res, + LISTEN, + ); + expect(res.status).toHaveBeenCalledWith(403); + expect((controller as any).serveStateless).not.toHaveBeenCalled(); + }); + + it('answers a member without building the tool surface', async () => { + let built: McpServer | undefined; + (controller as any).serveStateless = jest.fn( + async (_req: unknown, _res: unknown, _body: unknown, factory: () => McpServer) => { + built = factory(); + }, + ); + await controller.handlePost( + 'srv-A', + { user: { sub: 'u-a', organizationId: 'org-A', authMethod: 'jwt' }, headers: {} } as any, + makeRes(), + LISTEN, + ); + expect(mcpServersService.getConnectorIds).not.toHaveBeenCalled(); + expect(mcpServersService.getVisibleContent).not.toHaveBeenCalled(); + expect(toolRegistry.getAllTools).not.toHaveBeenCalled(); + expect(built!.server.getCapabilities()).toEqual({ + tools: { listChanged: true }, + resources: { listChanged: true }, + }); + }); + + it('leaves every other method on the full path', async () => { + (controller as any).serveStateless = jest.fn(); + await controller.handlePost( + 'srv-A', + { user: { sub: 'u-a', organizationId: 'org-A', authMethod: 'jwt' }, headers: {} } as any, + makeRes(), + { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, + ); + expect(mcpServersService.getConnectorIds).toHaveBeenCalledWith('srv-A'); + }); + }); + it('allows a multi-org user who is a MEMBER of the server org via membership', async () => { // Primary org is org-B, but the user is also a member of org-A (the // server's org) — must be allowed. diff --git a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts index df070ae3c..cbdfff499 100644 --- a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts +++ b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts @@ -116,6 +116,35 @@ function trimSlash(url: string | undefined): string { return (url || '').trim().replace(/\/+$/, ''); } +/** A single `subscriptions/listen` request (MCP 2026-07-28). */ +export function isListenRequest(body: unknown): boolean { + return ( + !!body && + typeof body === 'object' && + !Array.isArray(body) && + (body as { method?: unknown }).method === 'subscriptions/listen' + ); +} + +/** + * The server a `subscriptions/listen` stream is answered from. The SDK takes + * only its capabilities (which notifications the stream honours) and its name + * from it, closes it, and holds the stream itself until the client leaves, + * about five minutes later. Building the full tool surface for that cost a + * pass over the whole tool registry per listen, and on a server's own URL + * kept that surface alive for the stream's whole life; with ~200 streams open + * at once on 13 Oct, that was ~200 surfaces nobody read. Nothing is ever + * published to these streams anyway: each request has its own event bus, so + * the capabilities only decide what the acknowledgement lists. + */ +export function listenOnlyServer( + name: string, + version: string, + capabilities: { tools?: { listChanged: boolean }; resources?: { listChanged: boolean } }, +): McpServer { + return new McpServer({ name, version }, { capabilities }); +} + /** Trailer appended to every live demo tool result. */ const DEMO_RESULT_FOOTER = '— served by AnythingMCP (public demo, read-only). Self-host it: ' + @@ -234,6 +263,19 @@ export class McpEndpointController { @Post() async handleGlobalPost(@Req() req: Request, @Res() res: Response) { + // A subscriptions/listen stream reads only the server's capabilities and + // name; resolving the caller's tools for it filtered the whole registry + // on every one of the ~50 listens a minute (see listenOnlyServer). + if (this.servesSharedToolset(req) && isListenRequest((req as any).body)) { + await this.serveStateless( + req, + res, + (req as any).body, + () => listenOnlyServer('AnythingMCP', APP_VERSION, { tools: { listChanged: true } }), + 'shared /mcp listen', + ); + return; + } const visible = await this.attachVisibleTools(req); // The fixed tool set (see shared-toolset.ts). Operator credentials // (`visible === null`) keep the direct tools: they only exist on a @@ -980,6 +1022,24 @@ export class McpEndpointController { } } + // A subscriptions/listen stream, once the caller may reach this server: + // answered without building its tool surface (see listenOnlyServer). The + // stateful mode keeps its own path. + if (isListenRequest(body) && !McpSessionManager.isEnabled()) { + await this.serveStateless( + req, + res, + body, + () => + listenOnlyServer(mcpServerConfig.name, mcpServerConfig.version || '1.0.0', { + tools: { listChanged: true }, + resources: { listChanged: true }, + }), + `server ${serverId} listen`, + ); + return; + } + // 2. Get the connectors assigned to this server and, if the user is // identified, the tools their role allows. The role is scoped to THIS // server's organization so it is read from their membership of it, not diff --git a/packages/backend/src/mcp-server/shared-endpoint.controller.spec.ts b/packages/backend/src/mcp-server/shared-endpoint.controller.spec.ts index 62321f341..569c1fdad 100644 --- a/packages/backend/src/mcp-server/shared-endpoint.controller.spec.ts +++ b/packages/backend/src/mcp-server/shared-endpoint.controller.spec.ts @@ -1,6 +1,6 @@ import { Client } from '@modelcontextprotocol/client'; import { InMemoryTransport, McpServer } from '@modelcontextprotocol/server'; -import { McpEndpointController } from './mcp-endpoint.controller'; +import { McpEndpointController, isListenRequest } from './mcp-endpoint.controller'; import { CHATGPT_EXTRA_TOOL_NAMES, SHARED_TOOL_NAMES, sharedToolsetInstructions } from './shared-toolset'; import type { RegisteredTool } from './tool-registry'; @@ -138,6 +138,43 @@ describe('shared /mcp in fixed mode', () => { const orgB = { sub: 'u-b', organizationId: 'org-B', authMethod: 'jwt', email: 'b@x.io' }; + it('answers subscriptions/listen without resolving the caller\'s tools', async () => { + const getAllTools = jest.fn(() => ALL); + const { controller } = build(); + (controller as any).toolRegistry = { getAllTools, countByName: () => 1 }; + const grants = (controller as any).grants; + let built: McpServer | undefined; + (controller as any).serveStateless = jest.fn( + async (_req: unknown, _res: unknown, _body: unknown, factory: () => McpServer) => { + built = factory(); + }, + ); + const req: any = { + user: orgB, + body: { jsonrpc: '2.0', id: 1, method: 'subscriptions/listen', params: {} }, + headers: { host: 'x' }, + }; + await controller.handleGlobalPost(req, {} as any); + expect(getAllTools).not.toHaveBeenCalled(); + expect(grants.resolve).not.toHaveBeenCalled(); + expect(grants.clientRedirectUris).not.toHaveBeenCalled(); + expect(built!.server.getCapabilities()).toEqual({ tools: { listChanged: true } }); + }); + + it('recognises only a single subscriptions/listen message as a listen', () => { + expect(isListenRequest({ jsonrpc: '2.0', id: 1, method: 'subscriptions/listen' })).toBe(true); + for (const body of [ + { method: 'tools/list' }, + [{ method: 'subscriptions/listen' }], + 'subscriptions/listen', + null, + undefined, + {}, + ]) { + expect(isListenRequest(body)).toBe(false); + } + }); + it('lists only the fixed tool set', async () => { const client = await build().connect(orgB); const names = (await client.listTools()).tools.map((t) => t.name).sort();