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();