Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/mcp-host-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@herdctl/core": minor
---

Add host-side MCP server support for Docker agents. Servers with `host: true` are spawned on the host and bridged into the container via HTTP, enabling MCP servers that need host resources (filesystem, credentials) while the agent runs in Docker. Also fix MCP HTTP bridge URLs to use `host.docker.internal` so containers can reach host-side bridges.
2 changes: 2 additions & 0 deletions packages/core/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,8 @@ export const McpServerSchema = z.object({
args: z.array(z.string()).optional(),
env: z.record(z.string(), z.string()).optional(),
url: z.string().optional(),
/** When true and Docker is enabled, run this MCP server on the host and bridge into the container */
host: z.boolean().optional(),
});

// =============================================================================
Expand Down
147 changes: 147 additions & 0 deletions packages/core/src/runner/runtime/__tests__/mcp-host-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { describe, expect, it, vi } from "vitest";
import type { McpServer } from "../../../config/index.js";

// Mock the MCP SDK before importing the module under test
const mockConnect = vi.fn().mockResolvedValue(undefined);
const mockClose = vi.fn().mockResolvedValue(undefined);
const mockListTools = vi.fn().mockResolvedValue({
tools: [
{
name: "search_notes",
description: "Search Bear notes",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
},
{
name: "create_note",
description: "Create a Bear note",
inputSchema: {
type: "object",
properties: { title: { type: "string" }, text: { type: "string" } },
},
},
],
});
const mockCallTool = vi.fn().mockResolvedValue({
content: [{ type: "text", text: "Note found: Hello World" }],
isError: false,
});

vi.mock("@modelcontextprotocol/sdk/client/index.js", () => {
return {
Client: class MockClient {
connect = mockConnect;
close = mockClose;
listTools = mockListTools;
callTool = mockCallTool;
},
};
});

vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => {
return {
StdioClientTransport: class MockTransport {},
};
});

import { createMcpHostBridge, partitionMcpServers } from "../mcp-host-bridge.js";

describe("partitionMcpServers", () => {
it("separates host and container servers", () => {
const servers: Record<string, McpServer> = {
bear: { command: "node", args: ["bear.js"], host: true },
gmail: { command: "npx", args: ["gmail-mcp"] },
contacts: { command: "python3", args: ["-m", "contacts"], host: true },
};

const [host, container] = partitionMcpServers(servers);

expect(Object.keys(host)).toEqual(["bear", "contacts"]);
expect(Object.keys(container)).toEqual(["gmail"]);
expect(host.bear.host).toBe(true);
expect(container.gmail.host).toBeUndefined();
});

it("returns empty records for undefined input", () => {
const [host, container] = partitionMcpServers(undefined);
expect(Object.keys(host)).toHaveLength(0);
expect(Object.keys(container)).toHaveLength(0);
});

it("puts all servers in container when none have host: true", () => {
const servers: Record<string, McpServer> = {
a: { command: "a" },
b: { command: "b" },
};
const [host, container] = partitionMcpServers(servers);
expect(Object.keys(host)).toHaveLength(0);
expect(Object.keys(container)).toHaveLength(2);
});

it("puts all servers in host when all have host: true", () => {
const servers: Record<string, McpServer> = {
a: { command: "a", host: true },
b: { command: "b", host: true },
};
const [host, container] = partitionMcpServers(servers);
expect(Object.keys(host)).toHaveLength(2);
expect(Object.keys(container)).toHaveLength(0);
});
});

describe("createMcpHostBridge", () => {
it("throws if no command is specified", async () => {
await expect(createMcpHostBridge("test", { host: true })).rejects.toThrow(
"no command specified",
);
});

it("connects to the MCP server and discovers tools", async () => {
const config: McpServer = {
command: "node",
args: ["/srv/mcp-servers/bear/index.js"],
env: { BEAR_DB_PATH: "/data/bear.sqlite" },
host: true,
};

const handle = await createMcpHostBridge("bear", config);

expect(mockConnect).toHaveBeenCalled();
expect(mockListTools).toHaveBeenCalled();
expect(handle.serverDef.name).toBe("bear");
expect(handle.serverDef.tools).toHaveLength(2);
expect(handle.serverDef.tools[0].name).toBe("search_notes");
expect(handle.serverDef.tools[1].name).toBe("create_note");
});

it("tool handler delegates to client.callTool", async () => {
const handle = await createMcpHostBridge("bear", {
command: "node",
args: ["bear.js"],
host: true,
});

const result = await handle.serverDef.tools[0].handler({ query: "hello" });

expect(mockCallTool).toHaveBeenCalledWith({
name: "search_notes",
arguments: { query: "hello" },
});
expect(result.content[0].text).toBe("Note found: Hello World");
expect(result.isError).toBe(false);
});

it("close() calls client.close()", async () => {
const handle = await createMcpHostBridge("bear", {
command: "node",
args: ["bear.js"],
host: true,
});

await handle.close();
expect(mockClose).toHaveBeenCalled();
});
});
87 changes: 87 additions & 0 deletions packages/core/src/runner/runtime/cli-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { execa, type Subprocess } from "execa";
import { createLogger } from "../../utils/logger.js";
import { transformMcpServers } from "../sdk-adapter.js";
import type { SDKMessage } from "../types.js";
import { type McpHttpBridge, startMcpHttpBridge } from "./mcp-http-bridge.js";
import { getCliSessionDir, getCliSessionFile, waitForNewSessionFile } from "./cli-session-path.js";
import { CLISessionWatcher } from "./cli-session-watcher.js";
import type { RuntimeExecuteOptions, RuntimeInterface } from "./interface.js";
Expand Down Expand Up @@ -63,6 +64,14 @@ interface CLIRuntimeOptions {
* container session files are visible (e.g., .herdctl/docker-sessions).
*/
sessionDirOverride?: string;

/**
* Hostname for MCP HTTP bridge URLs
*
* Defaults to '127.0.0.1'. For Docker execution, set to 'host.docker.internal'
* so the container can reach host-side bridges.
*/
mcpBridgeHost?: string;
}

/**
Expand Down Expand Up @@ -98,6 +107,7 @@ interface CLIRuntimeOptions {
export class CLIRuntime implements RuntimeInterface {
private processSpawner: ProcessSpawner;
private sessionDirOverride?: string;
private mcpBridgeHost: string;

constructor(options?: CLIRuntimeOptions) {
// Default to local execa spawning with prompt via stdin
Expand All @@ -111,6 +121,7 @@ export class CLIRuntime implements RuntimeInterface {
}));

this.sessionDirOverride = options?.sessionDirOverride;
this.mcpBridgeHost = options?.mcpBridgeHost ?? "127.0.0.1";
}
/**
* Execute an agent using the Claude CLI
Expand Down Expand Up @@ -176,6 +187,82 @@ export class CLIRuntime implements RuntimeInterface {
args.push("--mcp-config", mcpConfig);
}

// Track env mutation so we can restore it (see CLAUDE_CODE_STREAM_CLOSE_TIMEOUT below)
let savedStreamCloseTimeout: string | undefined | null = null; // null = not mutated

// Start HTTP bridges for injected MCP servers (e.g., file sender)
// Same pattern as container-runner: expose in-process handlers via HTTP,
// then pass as HTTP-type MCP servers in --mcp-config
const bridges: McpHttpBridge[] = [];
if (options.injectedMcpServers && Object.keys(options.injectedMcpServers).length > 0) {
for (const [name, def] of Object.entries(options.injectedMcpServers)) {
let bridge: McpHttpBridge;
try {
bridge = await startMcpHttpBridge(def);
} catch (bridgeError) {
// Clean up any bridges that started successfully before this failure
for (const b of bridges) {
try {
await b.close();
} catch {
// best-effort cleanup
}
}
bridges.length = 0;
throw bridgeError;
}
bridges.push(bridge);

// Build or extend the --mcp-config to include this HTTP server
// Find existing --mcp-config arg index to merge with it
const mcpConfigIdx = args.indexOf("--mcp-config");
let mcpConfig: { mcpServers: Record<string, unknown> };

if (mcpConfigIdx !== -1 && mcpConfigIdx + 1 < args.length) {
// Parse existing config and add the bridge
mcpConfig = JSON.parse(args[mcpConfigIdx + 1]);
} else {
mcpConfig = { mcpServers: {} };
}

mcpConfig.mcpServers[name] = {
type: "http",
url: `http://${this.mcpBridgeHost}:${bridge.port}/mcp`,
};

const configJson = JSON.stringify(mcpConfig);
if (mcpConfigIdx !== -1) {
args[mcpConfigIdx + 1] = configJson;
} else {
args.push("--mcp-config", configJson);
}

logger.debug(`Started MCP HTTP bridge for '${name}' on port ${bridge.port}`);
}

// Auto-add injected MCP tool patterns to allowedTools.
// Only needed when the agent has an explicit allowlist — without one, all tools
// (including injected MCP tools) are allowed by default.
const allowedToolsIdx = args.indexOf("--allowedTools");
if (allowedToolsIdx !== -1 && allowedToolsIdx + 1 < args.length) {
const existing = args[allowedToolsIdx + 1];
const injectedPatterns = Object.keys(options.injectedMcpServers).map(
(name) => `mcp__${name}__*`,
);
args[allowedToolsIdx + 1] = [existing, ...injectedPatterns].join(",");
}

// File uploads via MCP tools can take longer than the default 60s timeout.
// Save the original value so we can restore it in `finally` to avoid leaking
// state across concurrent jobs.
if (options.injectedMcpServers["herdctl-file-sender"]) {
if (!process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT) {
savedStreamCloseTimeout = undefined; // marker: was not set
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = "120000";
}
}
}

Comment on lines +190 to +265

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Close started MCP bridges and restore env mutation in finally.

Bridges started at Line 196 are never closed, and CLAUDE_CODE_STREAM_CLOSE_TIMEOUT set at Lines 259-262 is never restored. This leaks ports and process-global state across executions.

🐛 Suggested fix
-    } finally {
-      // Cleanup
-      watcher?.stop();
-    }
+    } finally {
+      // Cleanup watcher
+      watcher?.stop();
+
+      // Always close MCP HTTP bridges
+      for (const bridge of bridges) {
+        try {
+          await bridge.close();
+        } catch (err) {
+          logger.error(`Failed to close MCP HTTP bridge: ${err}`);
+        }
+      }
+
+      // Restore env mutation if we changed it
+      if (savedStreamCloseTimeout !== null) {
+        if (savedStreamCloseTimeout === undefined) {
+          delete process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
+        } else {
+          process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = savedStreamCloseTimeout;
+        }
+      }
+    }

Also applies to: 495-498

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/runner/runtime/cli-runtime.ts` around lines 190 - 265, The
code starts MCP HTTP bridges (bridges array via startMcpHttpBridge) and mutates
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT (with savedStreamCloseTimeout) but
never restores or closes them on exit; add a try/finally around the logic that
uses injectedMcpServers so that in the finally you iterate bridges and await
b.close() (best-effort catch per bridge) and restore
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT to savedStreamCloseTimeout (use
undefined/null marker semantics already set), and apply the same finally-style
cleanup pattern to the other similar block referenced (around the code at the
other occurrence).

// Add session options
if (options.resume) {
args.push("--resume", options.resume);
Expand Down
56 changes: 54 additions & 2 deletions packages/core/src/runner/runtime/container-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ import { buildContainerEnv, buildContainerMounts, ContainerManager } from "./con
import type { DockerConfig } from "./docker-config.js";
import type { RuntimeExecuteOptions, RuntimeInterface } from "./interface.js";
import { type McpHttpBridge, startMcpHttpBridge } from "./mcp-http-bridge.js";
import {
createMcpHostBridge,
type McpHostBridgeHandle,
partitionMcpServers,
} from "./mcp-host-bridge.js";
import { SDKRuntime } from "./sdk-runtime.js";

const logger = createLogger("ContainerRunner");
Expand Down Expand Up @@ -83,18 +88,55 @@ export class ContainerRunner implements RuntimeInterface {
// Get or create container
const container = await this.manager.getOrCreateContainer(agent.name, this.config, mounts, env);

// Partition host: true MCP servers — these run on the host and get bridged
const hostBridges: McpHostBridgeHandle[] = [];
let effectiveOptions = options;

if (agent.mcp_servers) {
const [hostServers, containerServers] = partitionMcpServers(agent.mcp_servers);

if (Object.keys(hostServers).length > 0) {
// Start host-side MCP bridges
for (const [name, config] of Object.entries(hostServers)) {
const handle = await createMcpHostBridge(name, config);
hostBridges.push(handle);
}

// Build effective options with host servers moved to injectedMcpServers
const injected = { ...(options.injectedMcpServers ?? {}) };
for (const handle of hostBridges) {
injected[handle.serverDef.name] = handle.serverDef;
}

effectiveOptions = {
...options,
agent: {
...agent,
mcp_servers:
Object.keys(containerServers).length > 0 ? containerServers : undefined,
},
injectedMcpServers: injected,
};

logger.info(
`Partitioned MCP servers: ${Object.keys(hostServers).length} host, ` +
`${Object.keys(containerServers).length} container`,
);
}
}
Comment on lines +91 to +126

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Move host-bridge setup inside the guarded try/finally scope.

At Line 89 the container is already created, but host bridge startup (Lines 95-103) runs before the Line 128 try. A failure here skips cleanup for both partially started bridges and container lifecycle.

🐛 Suggested fix
-    if (agent.mcp_servers) {
-      const [hostServers, containerServers] = partitionMcpServers(agent.mcp_servers);
+    try {
+      if (agent.mcp_servers) {
+        const [hostServers, containerServers] = partitionMcpServers(agent.mcp_servers);
@@
-      }
-    }
-
-    try {
+      }
+
       // Get container ID for docker exec
       const containerInfo = await container.inspect();
       const containerId = containerInfo.Id;
@@
-    } catch (error) {
+    } catch (error) {
       const errorMessage = error instanceof Error ? error.message : String(error);
@@
-    } finally {
+    } finally {
       // Close host MCP bridges (kill subprocess + MCP client)
       for (const handle of hostBridges) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Partition host: true MCP servers — these run on the host and get bridged
const hostBridges: McpHostBridgeHandle[] = [];
let effectiveOptions = options;
if (agent.mcp_servers) {
const [hostServers, containerServers] = partitionMcpServers(agent.mcp_servers);
if (Object.keys(hostServers).length > 0) {
// Start host-side MCP bridges
for (const [name, config] of Object.entries(hostServers)) {
const handle = await createMcpHostBridge(name, config);
hostBridges.push(handle);
}
// Build effective options with host servers moved to injectedMcpServers
const injected = { ...(options.injectedMcpServers ?? {}) };
for (const handle of hostBridges) {
injected[handle.serverDef.name] = handle.serverDef;
}
effectiveOptions = {
...options,
agent: {
...agent,
mcp_servers:
Object.keys(containerServers).length > 0 ? containerServers : undefined,
},
injectedMcpServers: injected,
};
logger.info(
`Partitioned MCP servers: ${Object.keys(hostServers).length} host, ` +
`${Object.keys(containerServers).length} container`,
);
}
}
// Partition host: true MCP servers — these run on the host and get bridged
const hostBridges: McpHostBridgeHandle[] = [];
let effectiveOptions = options;
try {
if (agent.mcp_servers) {
const [hostServers, containerServers] = partitionMcpServers(agent.mcp_servers);
if (Object.keys(hostServers).length > 0) {
// Start host-side MCP bridges
for (const [name, config] of Object.entries(hostServers)) {
const handle = await createMcpHostBridge(name, config);
hostBridges.push(handle);
}
// Build effective options with host servers moved to injectedMcpServers
const injected = { ...(options.injectedMcpServers ?? {}) };
for (const handle of hostBridges) {
injected[handle.serverDef.name] = handle.serverDef;
}
effectiveOptions = {
...options,
agent: {
...agent,
mcp_servers:
Object.keys(containerServers).length > 0 ? containerServers : undefined,
},
injectedMcpServers: injected,
};
logger.info(
`Partitioned MCP servers: ${Object.keys(hostServers).length} host, ` +
`${Object.keys(containerServers).length} container`,
);
}
}
// Get container ID for docker exec
const containerInfo = await container.inspect();
const containerId = containerInfo.Id;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/runner/runtime/container-runner.ts` around lines 91 - 126,
Move the host bridge startup into the same lifecycle try/finally that manages
container cleanup so partial failures get cleaned up: stop creating hostBridges
and calling createMcpHostBridge(...) before the try; instead, build the list of
host server configs (using partitionMcpServers(agent.mcp_servers)) before the
try, then inside the try call createMcpHostBridge for each host server and push
into hostBridges, populate injectedMcpServers and set effectiveOptions there
(updating agent.mcp_servers to containerServers as currently done), and keep the
existing finally block to close hostBridges and tear down the container; ensure
references to hostBridges, effectiveOptions, injectedMcpServers, and
createMcpHostBridge are used as shown so cleanup covers bridge startup failures.


try {
// Get container ID for docker exec
const containerInfo = await container.inspect();
const containerId = containerInfo.Id;

// Handle CLI runtime with session file watching
if (this.wrapped instanceof CLIRuntime) {
yield* this.executeCLIRuntime(containerId, dockerSessionsDir, options);
yield* this.executeCLIRuntime(containerId, dockerSessionsDir, effectiveOptions);
}
// Handle SDK runtime with wrapper script
else if (this.wrapped instanceof SDKRuntime) {
yield* this.executeSDKRuntime(container, options);
yield* this.executeSDKRuntime(container, effectiveOptions);
}
// Unknown runtime type
else {
Expand All @@ -112,6 +154,15 @@ export class ContainerRunner implements RuntimeInterface {

// Container cleanup happens in finally block
} finally {
// Close host MCP bridges (kill subprocess + MCP client)
for (const handle of hostBridges) {
try {
await handle.close();
} catch (err) {
logger.error(`Failed to close host MCP bridge: ${err}`);
}
}

// For ephemeral containers, stop immediately after execution
// This triggers AutoRemove so the container is cleaned up automatically
if (this.config.ephemeral) {
Expand Down Expand Up @@ -146,6 +197,7 @@ export class ContainerRunner implements RuntimeInterface {
): AsyncIterable<SDKMessage> {
// Create CLI runtime with Docker-specific spawner
const cliRuntime = new CLIRuntime({
mcpBridgeHost: "host.docker.internal",
processSpawner: (args, _cwd, prompt, signal) => {
// Build docker exec command with prompt piped to stdin
// Uses printf to avoid issues with newlines and special chars in prompt
Expand Down
Loading
Loading