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
127 changes: 127 additions & 0 deletions .claude/skills/add-mcp-bridge/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
---
name: add-mcp-bridge
description: Bridge host-side MCP servers into agent containers. Use for MCP servers that can't run inside Linux containers (e.g. macOS-native APIs like Apple Reminders, Calendar via EventKit). Triggers on "mcp bridge", "host mcp", "bridge mcp", "add mcp bridge".
---

# Add MCP Bridge

This skill bridges host-side MCP servers into agent containers via IPC. Use it for MCP servers that **cannot run inside Linux containers** — typically macOS-native APIs that require EventKit, IOKit, or other OS frameworks.

For MCP servers that **can** run inside containers (HTTP APIs like Fastmail, Plex, etc.), use the standard approach: add them directly to `mcpServers` in `container/agent-runner/src/index.ts`. See `/add-parallel` for an example.

## Phase 1: Pre-flight

### Check if already applied

Check if `src/mcp-bridge.ts` exists. If it does, skip to Phase 3 (Configuration). The code changes are already in place.

## Phase 2: Apply Code Changes

### Merge the skill branch

```bash
git fetch upstream skill/mcp-bridge
git merge upstream/skill/mcp-bridge
```

If there are merge conflicts, resolve them. The key files added/modified:

| File | Change |
|------|--------|
| `src/mcp-bridge.ts` | **New** — Generic JSON-RPC subprocess manager for host MCP servers |
| `src/ipc.ts` | **Modified** — MCP request/response forwarding via IPC files |
| `src/index.ts` | **Modified** — Env-based MCP bridge initialization |
| `src/container-runner.ts` | **Modified** — IPC directories + bridge manifest |
| `container/agent-runner/src/ipc-mcp-stdio.ts` | **Modified** — Dynamic tool discovery from host |

### Rebuild

```bash
npm run build
./container/build.sh
```

## Phase 3: Configuration

### Ask the user

Use `AskUserQuestion` to collect MCP server details:

AskUserQuestion: What MCP server(s) do you want to bridge from the host? I need for each:
1. A short name (e.g. "reminders", "calendar")
2. The path to the MCP server binary on the host
3. Any command-line arguments (optional)

### Configure .env

Add entries to `.env` for each server. The naming convention is:

```
MCP_BRIDGE_SERVERS=server1,server2
MCP_BRIDGE_SERVER1_COMMAND=/path/to/mcp-server-binary
MCP_BRIDGE_SERVER1_ARGS=--flag1 --flag2
MCP_BRIDGE_SERVER2_COMMAND=/path/to/other-binary
```

Rules:
- `MCP_BRIDGE_SERVERS` is a comma-separated list of server names
- Each server needs `MCP_BRIDGE_{NAME}_COMMAND` (uppercase, hyphens replaced with underscores)
- `_ARGS` is optional — space-separated arguments

Example for Apple Reminders + Calendar:
```
MCP_BRIDGE_SERVERS=reminders,calendar
MCP_BRIDGE_REMINDERS_COMMAND=/path/to/apple-reminders-mcp
MCP_BRIDGE_CALENDAR_COMMAND=/path/to/apple-calendar-mcp
```

### Restart

```bash
npm run build
# macOS
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
# Linux
# systemctl --user restart nanoclaw
```

## Phase 4: Verify

Tell the user to send a message that would trigger one of the bridged tools. Check container logs for:
- `MCP bridge server registered` — server was configured
- `MCP server initialized` — server process started successfully
- `MCP bridge request completed` — tool call was forwarded and returned

If tools don't appear in the container, check:
1. `.env` has `MCP_BRIDGE_SERVERS` set
2. The MCP server binary path exists and is executable
3. Container logs for errors: `cat groups/*/logs/container-*.log | tail -50`

## How It Works

```
Agent (in container)
↓ calls tool (e.g. list_reminders)
IPC MCP Server (ipc-mcp-stdio.ts)
↓ writes request to /workspace/ipc/mcp_requests/
Host IPC Watcher (ipc.ts)
↓ reads request, forwards to McpBridge
McpBridge (mcp-bridge.ts)
↓ JSON-RPC stdio call to host MCP server process
Host MCP Server (e.g. Apple Reminders via EventKit)
↓ returns result
McpBridge → writes response to /workspace/ipc/mcp_responses/
Container polls response, returns to agent
```

Tool discovery happens automatically at container startup. The container calls `list_tools` via IPC, the host queries all registered MCP servers for their tool schemas, and the container dynamically registers them. No hardcoded tool definitions needed.

## Uninstalling

To remove the MCP bridge:

1. Remove `MCP_BRIDGE_*` entries from `.env`
2. Revert the merge: `git log --oneline` to find the merge commit, then `git revert -m 1 <merge-commit>`
3. Rebuild: `npm run build && ./container/build.sh`
4. Restart the service
159 changes: 159 additions & 0 deletions container/agent-runner/src/ipc-mcp-stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,165 @@ Use available_groups.json to find the JID for a group. The folder name must be c
},
);

// --- MCP Bridge: dynamically discover and proxy host-side MCP tools ---

const MCP_REQUESTS_DIR = path.join(IPC_DIR, 'mcp_requests');
const MCP_RESPONSES_DIR = path.join(IPC_DIR, 'mcp_responses');
const MCP_BRIDGE_POLL_INTERVAL = 100; // ms
const MCP_BRIDGE_TIMEOUT = 30_000; // ms

async function callHostMcp(
serverName: string,
tool: string,
args: Record<string, unknown>,
type?: string,
): Promise<{ content: Array<{ type: 'text'; text: string }> }> {
fs.mkdirSync(MCP_REQUESTS_DIR, { recursive: true });

const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const requestFile = path.join(MCP_REQUESTS_DIR, `${requestId}.json`);
const tempFile = `${requestFile}.tmp`;

// Write request atomically
const request: Record<string, unknown> = {
requestId,
server: serverName,
tool,
args,
};
if (type) request.type = type;

fs.writeFileSync(tempFile, JSON.stringify(request));
fs.renameSync(tempFile, requestFile);

// Poll for response
const responseFile = path.join(MCP_RESPONSES_DIR, `${requestId}.json`);
const start = Date.now();

while (Date.now() - start < MCP_BRIDGE_TIMEOUT) {
if (fs.existsSync(responseFile)) {
const raw = fs.readFileSync(responseFile, 'utf-8');
fs.unlinkSync(responseFile);
const response = JSON.parse(raw);
if (response.error) {
return {
content: [
{ type: 'text', text: `Error: ${response.error}` },
],
};
}
return {
content: [
{
type: 'text',
text: JSON.stringify(response.result),
},
],
};
}
await new Promise((r) => setTimeout(r, MCP_BRIDGE_POLL_INTERVAL));
}

return {
content: [{ type: 'text', text: 'Error: MCP bridge request timed out' }],
};
}

// Discover bridged tools from host and register them dynamically.
// Only attempts discovery if the host wrote a bridge manifest file,
// avoiding a 30s timeout on every container spawn when no bridge is configured.
async function registerBridgedTools(): Promise<void> {
const manifestFile = path.join(IPC_DIR, 'mcp_bridge_enabled');
if (!fs.existsSync(manifestFile)) return;

try {
const result = await callHostMcp('', '', {}, 'list_tools');
const text = result.content[0]?.text;
if (!text || text.startsWith('Error:')) return;

const serverTools: Array<{
server: string;
tools: Array<{
name: string;
description?: string;
inputSchema?: {
type: string;
properties?: Record<string, unknown>;
required?: string[];
};
}>;
}> = JSON.parse(text);

for (const entry of serverTools) {
const serverName = entry.server;
for (const tool of entry.tools) {
// Build zod schema from JSON Schema properties
const zodSchema: Record<string, z.ZodType> = {};
const props = tool.inputSchema?.properties || {};
const requiredFields = new Set(tool.inputSchema?.required || []);

for (const [key, schemaDef] of Object.entries(props)) {
const def = schemaDef as {
type?: string;
description?: string;
enum?: string[];
items?: { type?: string };
};
let zodType: z.ZodType;

// Map JSON Schema types to zod
switch (def.type) {
case 'number':
case 'integer':
zodType = z.number();
break;
case 'boolean':
zodType = z.boolean();
break;
case 'array':
if (def.items?.type === 'number') {
zodType = z.array(z.number());
} else {
zodType = z.array(z.string());
}
break;
default:
// string, or unknown — treat as string
if (def.enum) {
zodType = z.enum(def.enum as [string, ...string[]]);
} else {
zodType = z.string();
}
}

if (def.description) {
zodType = zodType.describe(def.description);
}

if (!requiredFields.has(key)) {
zodType = zodType.optional();
}

zodSchema[key] = zodType;
}

server.tool(
tool.name,
tool.description || `Bridged tool: ${tool.name}`,
zodSchema,
async (args: Record<string, unknown>) => {
return callHostMcp(serverName, tool.name, args);
},
);
}
}
} catch {
// No bridged tools available — not an error, bridge may not be configured
}
}

await registerBridgedTools();

// Start the stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);
12 changes: 12 additions & 0 deletions src/container-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
stopContainer,
} from './container-runtime.js';
import { detectAuthMode } from './credential-proxy.js';
import { readEnvFile } from './env.js';
import { validateAdditionalMounts } from './mount-security.js';
import { RegisteredGroup } from './types.js';

Expand Down Expand Up @@ -169,6 +170,17 @@ function buildVolumeMounts(
fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
fs.mkdirSync(path.join(groupIpcDir, 'mcp_requests'), { recursive: true });
fs.mkdirSync(path.join(groupIpcDir, 'mcp_responses'), { recursive: true });

// Write bridge manifest so containers know to attempt tool discovery
const bridgeEnv = readEnvFile(['MCP_BRIDGE_SERVERS']);
const bridgeManifest = path.join(groupIpcDir, 'mcp_bridge_enabled');
if (bridgeEnv.MCP_BRIDGE_SERVERS) {
fs.writeFileSync(bridgeManifest, bridgeEnv.MCP_BRIDGE_SERVERS);
} else if (fs.existsSync(bridgeManifest)) {
fs.unlinkSync(bridgeManifest);
}
mounts.push({
hostPath: groupIpcDir,
containerPath: '/workspace/ipc',
Expand Down
41 changes: 41 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
import { GroupQueue } from './group-queue.js';
import { resolveGroupFolderPath } from './group-folder.js';
import { startIpcWatcher } from './ipc.js';
import { McpBridge } from './mcp-bridge.js';
import { findChannel, formatMessages, formatOutbound } from './router.js';
import {
restoreRemoteControl,
Expand Down Expand Up @@ -488,6 +489,7 @@ async function main(): Promise<void> {
proxyServer.close();
await queue.shutdown(10000);
for (const ch of channels) await ch.disconnect();
mcpBridge.stop();
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
Expand Down Expand Up @@ -613,6 +615,44 @@ async function main(): Promise<void> {
if (text) await channel.sendMessage(jid, text);
},
});
// Initialize MCP bridge for host-side MCP servers that can't run in containers
const mcpBridge = new McpBridge();
{
const { readEnvFile } = await import('./env.js');
const envKeys = ['MCP_BRIDGE_SERVERS'];
const env = readEnvFile(envKeys);
const serverNames = (env.MCP_BRIDGE_SERVERS || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);

if (serverNames.length > 0) {
// Read per-server config: MCP_BRIDGE_{NAME}_COMMAND, _ARGS, _ENV_{KEY}
const serverEnvKeys = serverNames.flatMap((name) => {
const prefix = `MCP_BRIDGE_${name.toUpperCase().replace(/-/g, '_')}`;
return [`${prefix}_COMMAND`, `${prefix}_ARGS`];
});
const serverEnv = readEnvFile(serverEnvKeys);

for (const name of serverNames) {
const prefix = `MCP_BRIDGE_${name.toUpperCase().replace(/-/g, '_')}`;
const command = serverEnv[`${prefix}_COMMAND`];
if (!command) {
logger.warn(
{ server: name },
`MCP bridge server "${name}" missing ${prefix}_COMMAND, skipping`,
);
continue;
}

const argsStr = serverEnv[`${prefix}_ARGS`];
const args = argsStr ? argsStr.split(' ').filter(Boolean) : undefined;

mcpBridge.addServer({ name, command, args });
}
}
}

startIpcWatcher({
sendMessage: (jid, text) => {
const channel = findChannel(channels, jid);
Expand Down Expand Up @@ -646,6 +686,7 @@ async function main(): Promise<void> {
writeTasksSnapshot(group.folder, group.isMain === true, taskRows);
}
},
mcpBridge: mcpBridge.hasServers() ? mcpBridge : undefined,
});
queue.setProcessMessagesFn(processGroupMessages);
recoverPendingMessages();
Expand Down
Loading