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
6 changes: 6 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,12 @@ Manages connections to external MCP servers defined in config. Uses `@ai-sdk/mcp
- Supports stdio and HTTP/SSE transports
- Auto-discovers tools on connect and registers them in `ToolRegistry`
- Hot-reloads when config changes (connects new, disconnects removed)
- Refuses self-connections: HTTP/SSE URLs that target this daemon's own
listening HTTP or gRPC ports on a local address (`localhost`, `127.0.0.1`,
`::1`, IPv4-mapped loopback, hostname, or any local interface IP) are
rejected so the daemon cannot recurse into its own `/mcp` endpoint
(DR-039). When listen ports are not yet registered, local hosts are
refused fail-closed.

```yaml
# In config.yaml
Expand Down
23 changes: 23 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -603,3 +603,26 @@ gated, and empty consumers meant allow-all even on `0.0.0.0` — findings H8/H10
Fail-closed on network exposure closes unauthenticated secret/config/shutdown
access while keeping single-user local workflows frictionless. Timing-safe
compare closes token oracle leaks.

---

## DR-039: Block MCP self-connections to the daemon's own endpoints

**Date:** 2026-07-20
**Decision:** `McpClientPool` tracks the daemon's HTTP and gRPC listen ports
via `setListenEndpoints()` and rejects HTTP/SSE MCP server URLs whose host is
a local address (loopback including IPv4-mapped forms such as
`::ffff:127.0.0.1`, hostname, or any local interface IP) and whose port
matches a tracked listen port. When no listen ports are registered yet, local
hosts are refused fail-closed (remote hosts remain allowed). Both config
`connect()` and dynamic `connectDynamic()` throw a clear self-connection error
and record a failed status. Listen ports are registered at daemon start
(`httpPort` / `grpcPort` options) and when the embedded web server binds.
Self URLs may still appear in saved config; rejection is at connect time.
**Rationale:** `isSelfConnection()` previously always returned `false`, so the
daemon could register its own `/mcp` HTTP endpoint as an external MCP server
and amplify tool calls recursively. Comparing target URL host+port against
known listen endpoints closes that loop without blocking legitimate remote or
other-localhost MCP servers on different ports once ports are known. Fail-closed
behavior when the listen set is empty closes the race before
`setListenEndpoints()` runs (finding H7 / AAP-82830).
13 changes: 12 additions & 1 deletion packages/daemon/src/daemon/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ export interface DaemonOptions {
grpcPort?: number;
/** Host/IP to bind the TCP gRPC listener to (default: 127.0.0.1). */
grpcHost?: string;
/**
* Expected HTTP/web port for self-connection detection.
* Set even before the web server binds so MCP init cannot recurse into /mcp.
*/
httpPort?: number;
/** TLS / insecure bind options for the TCP gRPC listener. */
grpcTls?: GrpcTlsOptions;
/**
Expand Down Expand Up @@ -127,7 +132,13 @@ export async function startDaemon(opts?: DaemonOptions): Promise<DaemonState> {

// Create state
state = new DaemonState();


// Register known listen ports before MCP init so self-connections are blocked
state.mcpClientPool.setListenEndpoints({
httpPorts: opts?.httpPort != null ? [opts.httpPort] : [],
grpcPorts: opts?.grpcPort != null ? [opts.grpcPort] : [],
});

// Load proto and create server
const proto = loadProto();
const abbenayProto = (proto as unknown as { abbenay: { v1: { Abbenay: { service: grpc.ServiceDefinition } } } }).abbenay.v1;
Expand Down
2 changes: 1 addition & 1 deletion packages/daemon/src/daemon/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,11 @@ async function runServer(opts: ServerOptions): Promise<void> {
keepAlive: false,
grpcPort,
grpcHost,
httpPort: port,
grpcTls,
allowOpenAuth,
});
const { url, app, security } = await startEmbeddedWebServer(daemonState, port, host);

if (mcp && app) {
await daemonState.mcpServer.start(app);
}
Expand Down
140 changes: 139 additions & 1 deletion packages/daemon/src/daemon/mcp-client-pool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ vi.mock('@ai-sdk/mcp/mcp-stdio', () => ({

// ── Import (after mocks) ──────────────────────────────────────────────────

import { McpClientPool } from './mcp-client-pool.js';
import { McpClientPool, isSelfConnectionUrl, normalizeHost } from './mcp-client-pool.js';
import * as os from 'node:os';

// ── Test setup ────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -390,3 +391,140 @@ describe('callTool', () => {
await expect(pool.callTool('mcp:empty', 'missing', {})).rejects.toThrow('not found');
});
});

// ── self-connection guard ─────────────────────────────────────────────────

describe('isSelfConnectionUrl', () => {
const endpoints = { httpPorts: [8787], grpcPorts: [50051] };

it('detects localhost / 127.0.0.1 / IPv6 loopback on HTTP port', () => {
expect(isSelfConnectionUrl('http://localhost:8787/mcp', endpoints)).toBe(true);
expect(isSelfConnectionUrl('http://127.0.0.1:8787/mcp', endpoints)).toBe(true);
expect(isSelfConnectionUrl('http://[::1]:8787/mcp', endpoints)).toBe(true);
expect(isSelfConnectionUrl('HTTP://LocalHost:8787/MCP', endpoints)).toBe(true);
});

it('detects IPv4-mapped IPv6 loopback', () => {
// Node URL canonicalizes ::ffff:127.0.0.1 → ::ffff:7f00:1
expect(isSelfConnectionUrl('http://[::ffff:127.0.0.1]:8787/mcp', endpoints)).toBe(true);
expect(isSelfConnectionUrl('http://[::ffff:7f00:1]:8787/mcp', endpoints)).toBe(true);
expect(normalizeHost('::ffff:127.0.0.1')).toBe('127.0.0.1');
expect(normalizeHost('::ffff:7f00:1')).toBe('127.0.0.1');
});

it('detects self on gRPC listen port', () => {
expect(isSelfConnectionUrl('http://127.0.0.1:50051/', endpoints)).toBe(true);
});

it('detects machine hostname when port matches', () => {
const host = normalizeHost(os.hostname());
expect(isSelfConnectionUrl(`http://${host}:8787/mcp`, endpoints)).toBe(true);
});

it('allows remote hosts even when port number matches', () => {
expect(isSelfConnectionUrl('http://mcp.example.com:8787/mcp', endpoints)).toBe(false);
expect(isSelfConnectionUrl('https://remote.example.org/mcp', endpoints)).toBe(false);
});

it('allows localhost on a different port', () => {
expect(isSelfConnectionUrl('http://127.0.0.1:3001/sse', endpoints)).toBe(false);
expect(isSelfConnectionUrl('http://localhost:9999/mcp', endpoints)).toBe(false);
});

it('fail-closes local hosts when no listen ports are configured', () => {
const empty = { httpPorts: [] as number[], grpcPorts: [] as number[] };
expect(isSelfConnectionUrl('http://127.0.0.1:8787/mcp', empty)).toBe(true);
expect(isSelfConnectionUrl('http://localhost:3001/sse', empty)).toBe(true);
expect(isSelfConnectionUrl('http://[::1]:9999/mcp', empty)).toBe(true);
});

it('allows remote hosts when no listen ports are configured', () => {
expect(isSelfConnectionUrl('http://mcp.example.com:8787/mcp', {
httpPorts: [],
grpcPorts: [],
})).toBe(false);
});

it('returns false for invalid URLs', () => {
expect(isSelfConnectionUrl('not-a-url', endpoints)).toBe(false);
});
});

describe('self-connection rejection', () => {
beforeEach(() => {
pool.setListenEndpoints({ httpPorts: [8787], grpcPorts: [] });
});

it('rejects config connect to own /mcp (cannot remain a no-op)', async () => {
await expect(pool.connect('self', {
transport: 'http',
url: 'http://127.0.0.1:8787/mcp',
enabled: true,
})).rejects.toThrow(/self-connection/i);

expect(pool.connectedCount).toBe(0);
expect(mockCreateMCPClient).not.toHaveBeenCalled();
const status = pool.getStatus('self');
expect(status?.connected).toBe(false);
expect(status?.error).toMatch(/self-connection/i);
});

it('rejects all localhost URL variants for dynamic registration', async () => {
const variants = [
'http://localhost:8787/mcp',
'http://127.0.0.1:8787/mcp',
'http://[::1]:8787/mcp',
`http://${normalizeHost(os.hostname())}:8787/mcp`,
];

for (const [i, url] of variants.entries()) {
await expect(pool.connectDynamic(`self-${i}`, {
transport: 'http',
url,
enabled: true,
})).rejects.toThrow(/self-connection/i);
}

expect(mockCreateMCPClient).not.toHaveBeenCalled();
});

it('rejects SSE transport self-connections', async () => {
await expect(pool.connectDynamic('self-sse', {
transport: 'sse',
url: 'http://localhost:8787/sse',
enabled: true,
})).rejects.toThrow(/self-connection/i);
});

it('allows non-self remote HTTP MCP URLs', async () => {
await pool.connectDynamic('remote', {
transport: 'http',
url: 'http://mcp.example.com:443/mcp',
enabled: true,
});

expect(pool.getStatus('remote')?.connected).toBe(true);
expect(mockCreateMCPClient).toHaveBeenCalled();
});

it('allows localhost MCP on a different port', async () => {
await pool.connect('other-local', {
transport: 'http',
url: 'http://127.0.0.1:3001/sse',
enabled: true,
});

expect(pool.getStatus('other-local')?.connected).toBe(true);
});

it('skips self URLs during connectAll without aborting other servers', async () => {
await pool.connectAll({
self: { transport: 'http', url: 'http://localhost:8787/mcp', enabled: true },
ok: { transport: 'stdio', command: 'ok', enabled: true },
});

expect(pool.getStatus('self')?.connected).toBe(false);
expect(pool.getStatus('self')?.error).toMatch(/self-connection/i);
expect(pool.getStatus('ok')?.connected).toBe(true);
});
});
Loading
Loading