Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
60 changes: 60 additions & 0 deletions packages/backend/src/mcp-server/mcp-endpoint.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: ' +
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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();
Expand Down
Loading