// mcp_bridge.go — runtime wiring that makes `claude -p` invoke the // devicemesh tool catalog via a real MCP server instead of imitating tool // calls as plain text in the system prompt (issue 0145). // // What this file does, per call to ApplyMCPBridge: // // 1. Detects whether the agent has device_mesh enabled AND ExposeViaMCP. // 2. Resolves the path to the `bin/devicemesh-mcp` binary (same directory // as the launcher executable). // 3. Resolves the device_agent URL (env override → YAML literal, same // priority as buildDeviceMeshRegistry). // 4. Computes the list of tool names that should be visible to claude. // This is the same list buildDeviceMeshRegistry yields, so the in- // process registry and the MCP-exposed registry stay in lock-step. // 5. Writes the mcp-config JSON to /tmp/-mcp-config.json (0600). // The JSON tells claude how to spawn the child process and which env // vars to pass through. // 6. Mutates cfg.LLM.Primary.ClaudeCode so the existing claudecode.go // code path picks up the bridge: // - MCPConfigPath → triggers `--mcp-config ` // - AllowedTools → prefixed `mcp____` so claude exposes // them to the model // - DisableTools → forced false (DisableTools + AllowedTools is a // contradiction that previously broke startup) // // The function is best-effort: any failure logs a warning and leaves the // config untouched so the agent still boots, just without the bridge. // Tests live in mcp_bridge_test.go. package devagents import ( "encoding/json" "fmt" "log/slog" "os" "path/filepath" "sort" "github.com/enmanuel/agents/internal/config" devicemeshtools "github.com/enmanuel/agents/pkg/tools/devicemesh" ) // defaultMCPServerName is what we drop into the mcpServers map when the // config does not override it. Surfaces in tool names as // `mcp__devicemesh__` on the claude side. const defaultMCPServerName = "devicemesh" // MCPBridgeResult is what ApplyMCPBridge returns when it actually does // something. Exposed so callers (and tests) can log it. When the bridge is // not applied (e.g. device_mesh disabled), the function returns ok=false // and the caller should not mutate config. type MCPBridgeResult struct { ConfigPath string ServerName string ToolNames []string // claude-facing names: mcp____ BinaryPath string DeviceAgentURL string } // ApplyMCPBridge wires the per-agent MCP bridge into cfg.LLM.Primary.ClaudeCode // when device_mesh is enabled with ExposeViaMCP. Returns (result, ok). ok=false // means no changes were made (the agent has no device_mesh, the user opted out, // or something failed and the launcher should keep going without the bridge). func ApplyMCPBridge(cfg *config.AgentConfig, logger *slog.Logger) (MCPBridgeResult, bool) { if cfg == nil || cfg.DeviceMesh == nil { return MCPBridgeResult{}, false } dm := cfg.DeviceMesh if !dm.ShouldExposeViaMCP() { logger.Debug("mcp bridge skipped: device_mesh.ShouldExposeViaMCP()=false", "enabled", dm.Enabled, "expose_via_mcp", dm.ExposeViaMCP, ) return MCPBridgeResult{}, false } // claude-code is the only provider that knows --mcp-config. For other // providers the bridge is meaningless; leave it unconfigured. if cfg.LLM.Primary.Provider != "claude-code" { logger.Debug("mcp bridge skipped: primary provider is not claude-code", "provider", cfg.LLM.Primary.Provider, ) return MCPBridgeResult{}, false } binPath, err := ResolveDevicemeshMCPBinary() if err != nil { logger.Warn("mcp bridge skipped: cannot resolve binary", "err", err, ) return MCPBridgeResult{}, false } url := ResolveDeviceAgentURL(dm) if url == "" { logger.Warn("mcp bridge skipped: no device_agent URL resolved", "url_env", dm.URLEnv, "host", dm.ResolvedHost(), ) return MCPBridgeResult{}, false } toolNames, err := ResolveBridgedToolNames(dm) if err != nil { logger.Warn("mcp bridge skipped: cannot resolve bridged tools", "err", err, ) return MCPBridgeResult{}, false } if len(toolNames) == 0 { logger.Warn("mcp bridge skipped: zero tools after filtering", "mode", dm.Mode, "tools_allowed", dm.ToolsAllowed, ) return MCPBridgeResult{}, false } serverName := cfg.LLM.Primary.ClaudeCode.MCPServerName if serverName == "" { serverName = defaultMCPServerName } configPath, err := WriteMCPConfig(cfg.Agent.ID, serverName, binPath, url, dm.Mode, toolNames) if err != nil { logger.Warn("mcp bridge skipped: cannot write config", "err", err, ) return MCPBridgeResult{}, false } allowed := BuildClaudeAllowedToolNames(serverName, toolNames) prev := cfg.LLM.Primary.ClaudeCode cfg.LLM.Primary.ClaudeCode.MCPConfigPath = configPath cfg.LLM.Primary.ClaudeCode.MCPServerName = serverName cfg.LLM.Primary.ClaudeCode.AllowedTools = allowed // Defensive override: DisableTools=true with a non-empty AllowedTools // produces `--tools "" --allowedTools ...` which claude rejects. The // bridge requires AllowedTools to win. if prev.DisableTools { logger.Warn("mcp bridge forcing disable_tools=false (was true) — AllowedTools takes precedence", "agent_id", cfg.Agent.ID, ) cfg.LLM.Primary.ClaudeCode.DisableTools = false } result := MCPBridgeResult{ ConfigPath: configPath, ServerName: serverName, ToolNames: allowed, BinaryPath: binPath, DeviceAgentURL: url, } logger.Info("mcp bridge applied", "agent_id", cfg.Agent.ID, "config_path", configPath, "binary", binPath, "server_name", serverName, "device_agent_url", url, "tool_count", len(allowed), "tool_names", allowed, ) return result, true } // ResolveDevicemeshMCPBinary returns the absolute path to the // `devicemesh-mcp` executable. Strategy: // // 1. Same directory as os.Executable() (cmd/launcher/main.go → bin/launcher // and bin/devicemesh-mcp ship together). // 2. If (1) does not exist, fall back to "bin/devicemesh-mcp" relative to // CWD (covers `go run` / test scenarios). // 3. If neither exists, return an error. // // Pure-ish — os.Executable + os.Stat are read-only. func ResolveDevicemeshMCPBinary() (string, error) { if exe, err := os.Executable(); err == nil { dir := filepath.Dir(exe) candidate := filepath.Join(dir, "devicemesh-mcp") if st, err := os.Stat(candidate); err == nil && !st.IsDir() { return candidate, nil } } // Fallback: CWD/bin/devicemesh-mcp. Useful for tests and `go run` from // the repo root. candidate, err := filepath.Abs("bin/devicemesh-mcp") if err == nil { if st, err := os.Stat(candidate); err == nil && !st.IsDir() { return candidate, nil } } return "", fmt.Errorf("devicemesh-mcp binary not found (looked next to launcher and at bin/devicemesh-mcp)") } // ResolveDeviceAgentURL applies the env override on top of the YAML // literal. Same precedence as devagents.buildDeviceMeshRegistry so the // in-process registry and the MCP bridge never disagree about which device // they're talking to. func ResolveDeviceAgentURL(dm *config.DeviceMeshConfig) string { if dm == nil { return "" } url := dm.DeviceAgentURL if dm.URLEnv != "" { if v := os.Getenv(dm.URLEnv); v != "" { url = v } } return url } // ResolveBridgedToolNames returns the tool names that should be exposed // through the MCP bridge. Reuses RegisterBuiltins + FilterByAllowed so we // don't drift from the in-process behaviour. func ResolveBridgedToolNames(dm *config.DeviceMeshConfig) ([]string, error) { if dm == nil { return nil, fmt.Errorf("nil DeviceMeshConfig") } mode := normalizeMeshMode(dm.Mode) reg := devicemeshtools.NewToolRegistry(nil) // no client needed — pure registration names := devicemeshtools.RegisterBuiltins(reg, mode) if len(dm.ToolsAllowed) > 0 { filtered := devicemeshtools.FilterByAllowed(reg, dm.ToolsAllowed) reg = filtered // Recompute names from the filtered registry. names = reg.Names() } _ = names // names was set above only when no filter; reg.Names() reflects current state return reg.Names(), nil } // BuildClaudeAllowedToolNames takes raw devicemesh tool names and prefixes // them with `mcp____`, matching the format claude exposes to // the model. Sorted output for deterministic logging. func BuildClaudeAllowedToolNames(serverName string, raw []string) []string { if serverName == "" { serverName = defaultMCPServerName } out := make([]string, 0, len(raw)) for _, n := range raw { out = append(out, fmt.Sprintf("mcp__%s__%s", serverName, n)) } sort.Strings(out) return out } // WriteMCPConfig serialises the mcpServers JSON document and writes it to // /tmp/-mcp-config.json with mode 0600. Returns the absolute // path so the caller can hand it to claude -p --mcp-config. // // The serialised shape matches the schema claude-code accepts: // // { // "mcpServers": { // "": { // "command": "", // "args": ["--device-agent", "", "--mode", "", // "--tools-allowed", "", "--server-name", ""], // "env": {"MCP_DEBUG_LOG": "/tmp/-mcp.log"} // } // } // } func WriteMCPConfig(agentID, serverName, binPath, deviceAgentURL, mode string, toolNames []string) (string, error) { if agentID == "" { return "", fmt.Errorf("agent_id is empty") } if binPath == "" { return "", fmt.Errorf("binPath is empty") } args := []string{"--device-agent", deviceAgentURL} if mode != "" { args = append(args, "--mode", mode) } if len(toolNames) > 0 { args = append(args, "--tools-allowed", joinCSV(toolNames)) } args = append(args, "--server-name", serverName) logFile := fmt.Sprintf("/tmp/%s-mcp.log", agentID) doc := map[string]any{ "mcpServers": map[string]any{ serverName: map[string]any{ "command": binPath, "args": args, "env": map[string]any{ "MCP_DEBUG_LOG": logFile, }, }, }, } raw, err := json.MarshalIndent(doc, "", " ") if err != nil { return "", fmt.Errorf("marshal mcp config: %w", err) } path := fmt.Sprintf("/tmp/%s-mcp-config.json", agentID) if err := os.WriteFile(path, raw, 0o600); err != nil { return "", fmt.Errorf("write %s: %w", path, err) } return path, nil } // joinCSV is a tiny helper that turns a slice into a comma-separated string. // Empty slice → empty string. Pure. func joinCSV(parts []string) string { out := "" for i, p := range parts { if i > 0 { out += "," } out += p } return out }