diff --git a/.claude/skills/add-mcp-bridge/SKILL.md b/.claude/skills/add-mcp-bridge/SKILL.md new file mode 100644 index 0000000000..9d9da1e6ce --- /dev/null +++ b/.claude/skills/add-mcp-bridge/SKILL.md @@ -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 ` +3. Rebuild: `npm run build && ./container/build.sh` +4. Restart the service diff --git a/container/agent-runner/src/ipc-mcp-stdio.ts b/container/agent-runner/src/ipc-mcp-stdio.ts index 9de0138a08..6e02588a68 100644 --- a/container/agent-runner/src/ipc-mcp-stdio.ts +++ b/container/agent-runner/src/ipc-mcp-stdio.ts @@ -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, + 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 = { + 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 { + 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; + 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 = {}; + 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) => { + 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); diff --git a/src/container-runner.ts b/src/container-runner.ts index a6b58d79a2..1fdd94c669 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -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'; @@ -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', diff --git a/src/index.ts b/src/index.ts index db274f0be8..b96e4a36ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, @@ -488,6 +489,7 @@ async function main(): Promise { proxyServer.close(); await queue.shutdown(10000); for (const ch of channels) await ch.disconnect(); + mcpBridge.stop(); process.exit(0); }; process.on('SIGTERM', () => shutdown('SIGTERM')); @@ -613,6 +615,44 @@ async function main(): Promise { 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); @@ -646,6 +686,7 @@ async function main(): Promise { writeTasksSnapshot(group.folder, group.isMain === true, taskRows); } }, + mcpBridge: mcpBridge.hasServers() ? mcpBridge : undefined, }); queue.setProcessMessagesFn(processGroupMessages); recoverPendingMessages(); diff --git a/src/ipc.ts b/src/ipc.ts index 48efeb5c20..bab14e05de 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -8,6 +8,7 @@ import { AvailableGroup } from './container-runner.js'; import { createTask, deleteTask, getTaskById, updateTask } from './db.js'; import { isValidGroupFolder } from './group-folder.js'; import { logger } from './logger.js'; +import { McpBridge } from './mcp-bridge.js'; import { RegisteredGroup } from './types.js'; export interface IpcDeps { @@ -23,6 +24,7 @@ export interface IpcDeps { registeredJids: Set, ) => void; onTasksChanged: () => void; + mcpBridge?: McpBridge; } let ipcWatcherRunning = false; @@ -145,6 +147,49 @@ export function startIpcWatcher(deps: IpcDeps): void { } catch (err) { logger.error({ err, sourceGroup }, 'Error reading IPC tasks directory'); } + + // Process MCP bridge requests from this group's IPC directory + if (deps.mcpBridge) { + const requestsDir = path.join(ipcBaseDir, sourceGroup, 'mcp_requests'); + const responsesDir = path.join( + ipcBaseDir, + sourceGroup, + 'mcp_responses', + ); + try { + if (fs.existsSync(requestsDir)) { + const requestFiles = fs + .readdirSync(requestsDir) + .filter((f) => f.endsWith('.json')); + for (const file of requestFiles) { + const filePath = path.join(requestsDir, file); + try { + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + fs.unlinkSync(filePath); + + // Process async — write response when done + processMcpRequest( + deps.mcpBridge, + data, + responsesDir, + sourceGroup, + ); + } catch (err) { + logger.error( + { file, sourceGroup, err }, + 'Error reading MCP bridge request', + ); + fs.unlinkSync(filePath); + } + } + } + } catch (err) { + logger.error( + { err, sourceGroup }, + 'Error reading MCP requests directory', + ); + } + } } setTimeout(processIpcFiles, IPC_POLL_INTERVAL); @@ -459,3 +504,75 @@ export async function processTaskIpc( logger.warn({ type: data.type }, 'Unknown IPC task type'); } } + +/** + * Process an MCP bridge request from a container. + * Handles both tool calls and tool discovery (list_tools). + * Forwards to the host-side MCP server and writes the response. + */ +async function processMcpRequest( + bridge: McpBridge, + data: { + requestId: string; + type?: string; + server: string; + tool: string; + args: Record; + }, + responsesDir: string, + sourceGroup: string, +): Promise { + fs.mkdirSync(responsesDir, { recursive: true }); + + const responseFile = path.join(responsesDir, `${data.requestId}.json`); + const tempFile = `${responseFile}.tmp`; + + try { + let result: unknown; + + if (data.type === 'list_tools') { + // Tool discovery: return all tools from all bridged servers + result = await bridge.listAllTools(); + } else { + // Tool call: forward to the specific server + result = await bridge.callTool(data.server, data.tool, data.args); + } + + fs.writeFileSync( + tempFile, + JSON.stringify({ requestId: data.requestId, result, error: null }), + ); + fs.renameSync(tempFile, responseFile); + logger.debug( + { + requestId: data.requestId, + type: data.type || 'call_tool', + server: data.server, + tool: data.tool, + sourceGroup, + }, + 'MCP bridge request completed', + ); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + fs.writeFileSync( + tempFile, + JSON.stringify({ + requestId: data.requestId, + result: null, + error: errorMsg, + }), + ); + fs.renameSync(tempFile, responseFile); + logger.error( + { + requestId: data.requestId, + server: data.server, + tool: data.tool, + sourceGroup, + err, + }, + 'MCP bridge request failed', + ); + } +} diff --git a/src/mcp-bridge.ts b/src/mcp-bridge.ts new file mode 100644 index 0000000000..8cbb1208dc --- /dev/null +++ b/src/mcp-bridge.ts @@ -0,0 +1,251 @@ +/** + * MCP Bridge for NanoClaw + * Manages host-side MCP server processes and forwards tool calls from containers. + * Used for MCP servers that can't run inside Linux containers (e.g. macOS-native APIs). + * + * Generic — no knowledge of specific MCP servers. Servers are configured via env vars. + */ + +import { ChildProcess, spawn } from 'child_process'; + +import { logger } from './logger.js'; + +export interface McpServerConfig { + name: string; + command: string; + args?: string[]; + env?: Record; +} + +interface McpToolSchema { + name: string; + description?: string; + inputSchema?: { + type: string; + properties?: Record; + required?: string[]; + }; +} + +interface PendingRequest { + resolve: (value: JsonRpcResponse) => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +interface JsonRpcResponse { + jsonrpc: '2.0'; + id: number; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +const REQUEST_TIMEOUT = 30_000; + +class McpServerProcess { + private process: ChildProcess | null = null; + private initialized = false; + private initializing: Promise | null = null; + private requestId = 0; + private pendingRequests = new Map(); + private buffer = ''; + private cachedTools: McpToolSchema[] | null = null; + + constructor(private config: McpServerConfig) {} + + async ensureStarted(): Promise { + if (this.initialized) return; + if (this.initializing) return this.initializing; + this.initializing = this.start(); + return this.initializing; + } + + private async start(): Promise { + this.process = spawn(this.config.command, this.config.args || [], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, ...this.config.env }, + }); + + this.process.stdout!.on('data', (chunk: Buffer) => { + this.buffer += chunk.toString(); + this.processBuffer(); + }); + + this.process.stderr!.on('data', (chunk: Buffer) => { + const text = chunk.toString().trim(); + if (text) { + logger.debug({ server: this.config.name }, `MCP stderr: ${text}`); + } + }); + + this.process.on('exit', (code) => { + logger.warn( + { server: this.config.name, code }, + 'MCP server process exited', + ); + this.initialized = false; + this.initializing = null; + this.process = null; + this.cachedTools = null; + // Reject any pending requests + for (const [id, pending] of this.pendingRequests) { + clearTimeout(pending.timer); + pending.reject(new Error('MCP server process exited')); + this.pendingRequests.delete(id); + } + }); + + // Initialize MCP protocol + await this.sendRequest('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'nanoclaw-bridge', version: '1.0.0' }, + }); + + // Send initialized notification + this.sendNotification('notifications/initialized', {}); + this.initialized = true; + logger.info({ server: this.config.name }, 'MCP server initialized'); + } + + private processBuffer(): void { + let newlineIdx: number; + while ((newlineIdx = this.buffer.indexOf('\n')) !== -1) { + const line = this.buffer.slice(0, newlineIdx).trim(); + this.buffer = this.buffer.slice(newlineIdx + 1); + if (!line) continue; + + try { + const response = JSON.parse(line) as JsonRpcResponse; + if (response.id != null) { + const pending = this.pendingRequests.get(response.id); + if (pending) { + clearTimeout(pending.timer); + this.pendingRequests.delete(response.id); + pending.resolve(response); + } + } + } catch { + logger.debug( + { server: this.config.name, line }, + 'Non-JSON line from MCP server', + ); + } + } + } + + async listTools(): Promise { + if (this.cachedTools) return this.cachedTools; + + await this.ensureStarted(); + const response = await this.sendRequest('tools/list', {}); + if (response.error) { + throw new Error(response.error.message); + } + const result = response.result as { tools?: McpToolSchema[] }; + this.cachedTools = result.tools || []; + return this.cachedTools; + } + + async callTool( + toolName: string, + args: Record, + ): Promise { + await this.ensureStarted(); + const response = await this.sendRequest('tools/call', { + name: toolName, + arguments: args, + }); + if (response.error) { + throw new Error(response.error.message); + } + return response.result; + } + + private sendRequest( + method: string, + params: unknown, + ): Promise { + const id = ++this.requestId; + const request = { jsonrpc: '2.0', id, method, params }; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingRequests.delete(id); + reject(new Error(`MCP request timeout: ${method}`)); + }, REQUEST_TIMEOUT); + + this.pendingRequests.set(id, { resolve, reject, timer }); + this.process!.stdin!.write(JSON.stringify(request) + '\n'); + }); + } + + private sendNotification(method: string, params: unknown): void { + const notification = { jsonrpc: '2.0', method, params }; + this.process!.stdin!.write(JSON.stringify(notification) + '\n'); + } + + stop(): void { + if (this.process) { + this.process.kill(); + this.process = null; + this.initialized = false; + this.initializing = null; + this.cachedTools = null; + } + } +} + +export class McpBridge { + private servers = new Map(); + + addServer(config: McpServerConfig): void { + this.servers.set(config.name, new McpServerProcess(config)); + logger.info({ server: config.name }, 'MCP bridge server registered'); + } + + /** + * List all tools from all registered MCP servers. + * Returns tools prefixed with server name for namespacing. + */ + async listAllTools(): Promise< + Array<{ server: string; tools: McpToolSchema[] }> + > { + const results: Array<{ server: string; tools: McpToolSchema[] }> = []; + for (const [name, server] of this.servers) { + try { + const tools = await server.listTools(); + results.push({ server: name, tools }); + } catch (err) { + logger.error( + { server: name, err }, + 'Failed to list tools from MCP server', + ); + results.push({ server: name, tools: [] }); + } + } + return results; + } + + async callTool( + serverName: string, + toolName: string, + args: Record, + ): Promise { + const server = this.servers.get(serverName); + if (!server) { + throw new Error(`Unknown MCP bridge server: ${serverName}`); + } + return server.callTool(toolName, args); + } + + hasServers(): boolean { + return this.servers.size > 0; + } + + stop(): void { + for (const server of this.servers.values()) { + server.stop(); + } + } +}