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
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');

Expand Down
37 changes: 37 additions & 0 deletions packages/backend/src/connectors/engines/mcp-client.engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = { ...config.headers };
Expand Down Expand Up @@ -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<string, string> = { ...config.headers };
Expand Down Expand Up @@ -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<string> {
const out = new Set<string>();
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
Expand Down
Loading