diff --git a/packages/backend/src/connectors/engines/mcp-client.engine.spec.ts b/packages/backend/src/connectors/engines/mcp-client.engine.spec.ts index 6e469948..d04667ff 100644 --- a/packages/backend/src/connectors/engines/mcp-client.engine.spec.ts +++ b/packages/backend/src/connectors/engines/mcp-client.engine.spec.ts @@ -1,4 +1,4 @@ -import { McpClientEngine, explainMcpConnectError } from './mcp-client.engine'; +import { McpClientEngine, assertNotThisServer, explainMcpConnectError } from './mcp-client.engine'; import { OAuth2TokenService } from './oauth2-token.service'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { assertSafeOutboundUrl } from '../../common/ssrf.util'; @@ -171,6 +171,32 @@ describe('McpClientEngine endpoint resolution', () => { }); }); +describe('assertNotThisServer', () => { + const env = { SERVER_URL: 'https://cloud.anythingmcp.com', FRONTEND_URL: 'https://cloud.anythingmcp.com' } as NodeJS.ProcessEnv; + + it.each(['https://cloud.anythingmcp.com/mcp', 'https://CLOUD.anythingmcp.com/mcp/abc123'])( + 'refuses an MCP connector that points at this server: %s', + (url) => { + expect(() => assertNotThisServer(new URL(url), env)).toThrow(/points at this AnythingMCP server itself/); + }, + ); + + it('allows another server, including another AnythingMCP instance', () => { + expect(() => assertNotThisServer(new URL('https://mcp.example.com/mcp'), env)).not.toThrow(); + expect(() => assertNotThisServer(new URL('https://amcp.customer.de/mcp'), env)).not.toThrow(); + }); + + it('on a self-hosted instance, another MCP server on the same machine is allowed', () => { + const local = { SERVER_URL: 'http://localhost:4000' } as NodeJS.ProcessEnv; + expect(() => assertNotThisServer(new URL('http://localhost:8080/mcp'), local)).not.toThrow(); + expect(() => assertNotThisServer(new URL('http://localhost:4000/mcp'), local)).toThrow(/itself/); + }); + + it('does nothing when the instance does not know its own URL', () => { + expect(() => assertNotThisServer(new URL('https://cloud.anythingmcp.com/mcp'), {} as NodeJS.ProcessEnv)).not.toThrow(); + }); +}); + describe('explainMcpConnectError', () => { const url = new URL('https://soap-shipping.trycloudflare.com/mcp'); diff --git a/packages/backend/src/connectors/engines/mcp-client.engine.ts b/packages/backend/src/connectors/engines/mcp-client.engine.ts index 7a71eb39..6b841f3c 100644 --- a/packages/backend/src/connectors/engines/mcp-client.engine.ts +++ b/packages/backend/src/connectors/engines/mcp-client.engine.ts @@ -38,6 +38,7 @@ export class McpClientEngine { const mcpUrl = resolveMcpEndpointUrl(config.baseUrl, endpointMapping.path); this.warnLegacyUrlChange(config.baseUrl, endpointMapping.path, mcpUrl); + assertNotThisServer(mcpUrl); await assertSafeOutboundUrl(mcpUrl.toString()); const headers: Record = { ...config.headers }; @@ -134,6 +135,7 @@ export class McpClientEngine { // Discovery reaches a user-supplied URL just like execute() does, so it // needs the same SSRF guard — it was missing here. + assertNotThisServer(mcpUrl); await assertSafeOutboundUrl(mcpUrl.toString()); const headers: Record = { ...config.headers }; @@ -243,6 +245,41 @@ export class McpClientEngine { } } +/** + * The public hosts of this AnythingMCP instance (SERVER_URL, FRONTEND_URL, + * CLOUD_PUBLIC_URL), lower-cased, with the port when it is not the default: + * another MCP server on the same machine (localhost:8080 next to + * localhost:4000) is a different server. + */ +export function thisServerHostnames(env: NodeJS.ProcessEnv = process.env): Set { + const out = new Set(); + for (const raw of [env.SERVER_URL, env.FRONTEND_URL, env.CLOUD_PUBLIC_URL]) { + if (!raw) continue; + try { + out.add(new URL(raw).host.toLowerCase()); + } catch { + /* not a URL: ignore */ + } + } + return out; +} + +/** + * An MCP connector pointing at this very server makes every call re-enter + * our own /mcp, which calls the connector again: an endless loop that holds + * a request for the full timeout at each hop. On 4 Oct 2026 one such + * connector ("Claude Etsy", URL cloud.anythingmcp.com) made 13,709 calls in + * two hours, each timing out after 60 s, and doubled everyone's p95. + */ +export function assertNotThisServer(mcpUrl: URL, env: NodeJS.ProcessEnv = process.env): void { + if (!thisServerHostnames(env).has(mcpUrl.host.toLowerCase())) return; + throw new Error( + `This MCP connector points at this AnythingMCP server itself (${mcpUrl.host}), so every call ` + + `would call itself again in a loop. Its tools are already here: use this server's own connectors ` + + `instead, and point an MCP connector only at another MCP server.`, + ); +} + /** * A remote MCP server built on the MCP SDK with DNS-rebinding protection on * answers 403 "Invalid Host header" / "host not allowed" to any hostname it