diff --git a/cmd/devicemesh-mcp/bridge.go b/cmd/devicemesh-mcp/bridge.go new file mode 100644 index 0000000..9c2e35a --- /dev/null +++ b/cmd/devicemesh-mcp/bridge.go @@ -0,0 +1,157 @@ +// bridge.go — adapter that registers every devicemesh.ToolSpec from a +// ToolRegistry as an MCP tool on a mcp-go server.MCPServer. +// +// Tool name preservation: we register tools under their dotted devicemesh +// name verbatim ("exec", "shell.eval", "fs.read"). claude exposes them to +// the model as `mcp____` (the MCP transport prefixes +// automatically). +// +// Schema: ToolSpec.InputSchema is already a JSON-Schema-lite map. We +// marshal it to a json.RawMessage and feed it via mcp.WithRawInputSchema so +// the LLM sees the full structure (required fields, enums, descriptions). +// +// Handler: each tool's handler invokes reg.Call(ctx, name, args). The +// registry runs ValidateInput → ArgMapping → HTTP dispatch → ResultMapping +// just like the in-process tool-use path. The result is JSON-encoded into +// an MCP text-content block. Errors become NewToolResultError so the model +// can self-correct on the next turn. +package main + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + "github.com/enmanuel/agents/pkg/tools/devicemesh" +) + +// RegisterToolBridge walks reg and registers each spec on srv. Returns the +// first registration error, if any. Pure data adapter except for the slog +// debug events. +func RegisterToolBridge(srv *server.MCPServer, reg *devicemesh.ToolRegistry, logger *slog.Logger) error { + if srv == nil { + return fmt.Errorf("RegisterToolBridge: srv is nil") + } + if reg == nil { + return fmt.Errorf("RegisterToolBridge: reg is nil") + } + for _, spec := range reg.List() { + tool, err := buildMCPTool(spec) + if err != nil { + return fmt.Errorf("build MCP tool %q: %w", spec.Name, err) + } + handler := makeHandler(reg, spec, logger) + srv.AddTool(tool, handler) + if logger != nil { + logger.Debug("registered MCP tool", + "name", spec.Name, + "capability", spec.Capability, + "requires_approval", spec.RequiresApproval, + ) + } + } + return nil +} + +// buildMCPTool transforms a devicemesh.ToolSpec into an mcp.Tool with the +// raw input schema attached. The description is augmented with the +// capability marker so the model knows the tool is remote. +func buildMCPTool(spec devicemesh.ToolSpec) (mcp.Tool, error) { + desc := spec.Description + if spec.Capability != "" { + desc = fmt.Sprintf("%s [device_mesh: %s]", desc, spec.Capability) + } + if spec.RequiresApproval { + desc += " (approval required)" + } + + opts := []mcp.ToolOption{mcp.WithDescription(desc)} + if spec.InputSchema != nil { + raw, err := json.Marshal(spec.InputSchema) + if err != nil { + return mcp.Tool{}, fmt.Errorf("marshal input schema: %w", err) + } + opts = append(opts, mcp.WithRawInputSchema(raw)) + } + return mcp.NewTool(spec.Name, opts...), nil +} + +// makeHandler returns a server.ToolHandlerFunc bound to a single spec. The +// closure captures the registry so the HTTP dispatch goes through the same +// validate → map → call pipeline as the in-process path. +func makeHandler(reg *devicemesh.ToolRegistry, spec devicemesh.ToolSpec, logger *slog.Logger) server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + if args == nil { + args = map[string]any{} + } + if logger != nil { + logger.Debug("tools/call received", + "tool", spec.Name, + "capability", spec.Capability, + "arg_keys", keysOf(args), + ) + } + + result, err := reg.Call(ctx, spec.Name, args) + if err != nil { + if logger != nil { + logger.Warn("tools/call failed", + "tool", spec.Name, + "err", err.Error(), + ) + } + // NewToolResultError returns a CallToolResult with isError=true. + // Returning (result, nil) lets the model see and self-correct + // instead of treating it as a transport-level failure. + return mcp.NewToolResultError(err.Error()), nil + } + + text := encodeResult(result) + if logger != nil { + logger.Debug("tools/call ok", + "tool", spec.Name, + "result_len", len(text), + ) + } + return mcp.NewToolResultText(text), nil + } +} + +// encodeResult converts a tool result (any) to the string payload the model +// will see. Mirrors devicemesh.AdaptTool's formatToolResult so MCP and the +// in-process path produce consistent transcripts. +// +// - nil → "" +// - string → returned as-is (avoids double-encoding JSON strings) +// - other → json.Marshal; on failure fall back to fmt.Sprintf so we never +// drop data on the floor. +func encodeResult(v any) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + return s + } + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(b) +} + +// keysOf returns the sorted keys of a map for log context. Pure helper. +func keysOf(m map[string]any) []string { + if len(m) == 0 { + return nil + } + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/cmd/devicemesh-mcp/main.go b/cmd/devicemesh-mcp/main.go new file mode 100644 index 0000000..c760b03 --- /dev/null +++ b/cmd/devicemesh-mcp/main.go @@ -0,0 +1,208 @@ +// Command devicemesh-mcp is a per-agent MCP server (stdio) that exposes the +// agents_and_robots device-mesh tool catalog (exec, shell.eval, fs.*, git.*, +// pkg.*, proc.*, docker.*) to a parent `claude -p` subprocess. +// +// Architecture (issue 0145): +// +// claude -p +// ├─ spawns this binary as child via --mcp-config +// ├─ JSON-RPC over stdio +// ├─ initialize / tools/list / tools/call / ping / notifications/initialized +// └─ tool names exposed as `mcp____` to the model +// +// Flags: +// +// --device-agent required — http://host:port of the remote device_agent +// --mode user|sudo|all default user — filters which builtin tools are registered +// --tools-allowed optional — narrows the catalog after mode filtering +// --server-name default "devicemesh" — only used for logs and serverInfo +// +// Environment: +// +// MCP_DEBUG_LOG optional — write structured logs to this file +// (stderr is reserved by claude for the MCP transport +// framing in some setups, so we prefer a file sink) +// +// Returns non-zero on flag parse error or stdio listen error. +package main + +import ( + "flag" + "fmt" + "io" + "log/slog" + "os" + "strings" + "time" + + "github.com/mark3labs/mcp-go/server" + + "github.com/enmanuel/agents/pkg/tools/devicemesh" +) + +// version is overwritten via -ldflags at build time when needed. Kept simple +// so the binary stays self-contained. +var version = "0.1.0" + +func main() { + var ( + deviceAgentURL string + mode string + toolsAllowed string + serverName string + showVersion bool + ) + + flag.StringVar(&deviceAgentURL, "device-agent", "", "URL of the device_agent (http://host:port). Required.") + flag.StringVar(&mode, "mode", "user", "Tool registration mode: user|sudo|all") + flag.StringVar(&toolsAllowed, "tools-allowed", "", "CSV of tool names to keep after mode filtering. Empty = keep all.") + flag.StringVar(&serverName, "server-name", "devicemesh", "MCP server name (used in serverInfo and log context)") + flag.BoolVar(&showVersion, "version", false, "Print version and exit") + flag.Parse() + + if showVersion { + fmt.Fprintf(os.Stdout, "devicemesh-mcp %s\n", version) + return + } + + logger := newLogger() + logger.Info("devicemesh-mcp starting", + "version", version, + "server_name", serverName, + "mode", mode, + "device_agent_url", deviceAgentURL, + "tools_allowed", toolsAllowed, + ) + + if deviceAgentURL == "" { + logger.Error("--device-agent is required") + fmt.Fprintln(os.Stderr, "fatal: --device-agent is required") + os.Exit(2) + } + + // Build the per-process devicemesh registry. Mirrors the launcher's + // buildDeviceMeshRegistry but driven by CLI flags instead of YAML. + reg, err := buildRegistry(deviceAgentURL, mode, splitCSV(toolsAllowed)) + if err != nil { + logger.Error("build registry failed", "err", err) + fmt.Fprintf(os.Stderr, "fatal: %s\n", err) + os.Exit(1) + } + logger.Info("registry ready", "tool_count", reg.Len(), "names", reg.Names()) + + // Build the MCP server, wire every devicemesh tool as an MCP tool, and + // serve over stdio. ServeStdio handles initialize / tools/list / + // tools/call / ping / notifications/initialized for us — the bridge only + // has to register tools. + srv := server.NewMCPServer(serverName, version) + if err := RegisterToolBridge(srv, reg, logger); err != nil { + logger.Error("register tool bridge failed", "err", err) + fmt.Fprintf(os.Stderr, "fatal: %s\n", err) + os.Exit(1) + } + + logger.Info("starting stdio server") + if err := server.ServeStdio(srv); err != nil { + // Stdin EOF is the normal shutdown signal when the claude parent + // exits; treat it as a clean exit. + if isCleanShutdown(err) { + logger.Info("stdio server exited cleanly", "err", err) + return + } + logger.Error("stdio server error", "err", err) + fmt.Fprintf(os.Stderr, "fatal: %s\n", err) + os.Exit(1) + } +} + +// buildRegistry constructs the devicemesh ToolRegistry from CLI flags. Pure +// in the sense that it does no I/O — RegisterBuiltins + FilterByAllowed are +// data shuffling, the HTTP transport only fires when a tool is actually +// called via reg.Call. Exposed for tests. +func buildRegistry(deviceAgentURL, modeStr string, allowed []string) (*devicemesh.ToolRegistry, error) { + client := devicemesh.NewClient(deviceAgentURL) + // Conservative timeout: stdio frames from claude can sit in our queue for + // a while while the model thinks. Per-call HTTP timeout stays at the + // devicemesh default (30s) which is fine for exec/shell.eval. + client.Timeout = 60 * time.Second + + mode := parseMode(modeStr) + reg := devicemesh.NewToolRegistry(client) + names := devicemesh.RegisterBuiltins(reg, mode) + if len(names) == 0 { + return nil, fmt.Errorf("RegisterBuiltins yielded zero tools for mode=%q", modeStr) + } + + if len(allowed) > 0 { + filtered := devicemesh.FilterByAllowed(reg, allowed) + if filtered.Len() == 0 { + return nil, fmt.Errorf("FilterByAllowed yielded zero tools (allowed=%v, mode=%q)", allowed, modeStr) + } + reg = filtered + } + return reg, nil +} + +// parseMode maps the CLI string to a devicemesh RegistrationMode. Unknown +// modes fall back to ModeUser (safer default). +func parseMode(s string) devicemesh.RegistrationMode { + switch strings.ToLower(strings.TrimSpace(s)) { + case "sudo": + return devicemesh.ModeSudo + case "all": + return devicemesh.ModeAll + case "user", "": + return devicemesh.ModeUser + default: + return devicemesh.ModeUser + } +} + +// splitCSV splits a comma-separated list, trims spaces, and drops empties. +// Pure helper. +func splitCSV(s string) []string { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +// newLogger builds a slog.Logger that writes to MCP_DEBUG_LOG if set, or +// io.Discard otherwise. We avoid stdout (reserved for JSON-RPC frames) and +// stderr (transport framing varies between MCP clients). +func newLogger() *slog.Logger { + logPath := os.Getenv("MCP_DEBUG_LOG") + var w io.Writer = io.Discard + if logPath != "" { + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err == nil { + w = f + } + } + return slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{Level: slog.LevelDebug})) +} + +// isCleanShutdown reports whether err looks like a normal stdio shutdown. +// ServeStdio returns io.EOF / "file already closed" when the parent claude +// exits and tears down our pipes. We don't want those to flip the exit code. +func isCleanShutdown(err error) bool { + if err == nil { + return true + } + if err == io.EOF { + return true + } + msg := err.Error() + return strings.Contains(msg, "EOF") || + strings.Contains(msg, "file already closed") || + strings.Contains(msg, "use of closed") +} diff --git a/dev/issues/0145-mcp-bridge-claude-code-devicemesh.md b/dev/issues/0145-mcp-bridge-claude-code-devicemesh.md new file mode 100644 index 0000000..a5813b3 --- /dev/null +++ b/dev/issues/0145-mcp-bridge-claude-code-devicemesh.md @@ -0,0 +1,151 @@ +--- +id: "0145" +title: "MCP bridge claude-code → devicemesh tools" +status: pending +type: feature +domain: + - agents + - llm + - mcp + - devicemesh +scope: app +priority: high +depends: + - "0134" + - "0144" +related_flows: + - "0009" +related_issues: + - "0134" + - "0144" +created: 2026-05-24 +updated: 2026-05-24 +tags: [mcp, claude-code, devicemesh, agents] +flow: "0009" +--- + +# 0145 — MCP bridge claude-code → devicemesh tools + +## Objetivo + +Hacer que `claude -p` (subprocess que usa el provider `claude-code` de cada agent) **invoque REALMENTE** las 14+ tools de `pkg/tools/devicemesh` (`exec`, `shell.eval`, `fs.*`, `git.*`, `pkg.*`, `proc.*`, `docker.*`) en lugar de imitar el formato como texto. Esto se logra exponiendo el `ToolRegistry` per-agent como un **servidor MCP** (Model Context Protocol) que claude descubre via `--mcp-config` y consume via JSON-RPC stdio. + +## Contexto + +Hoy `claude -p` se invoca con `disable_tools: true` → `--tools ""`, y las tools de device-mesh viven solo en el system prompt como **descripcion textual**. Resultado: + +- claude **imita** el formato (`{"tool": "exec", ...}`) pero **NO ejecuta** nada. +- El audit chain del `device_agent` queda **vacio** tras un "exec" anunciado por el bot. +- Anti-criterio A3 del flow 0009 (anti-hallucination) **falla**: el bot dice que hizo algo, el device no recibe nada. + +El fix correcto es darle a claude un **transporte real** para invocar tools. MCP es el contrato nativo de claude-code: + +1. Cada agent levanta su propio MCP server (binario Go child de `claude`). +2. claude descubre tools via `tools/list`, invoca via `tools/call`. +3. El binario MCP traduce `tools/call` → `ToolRegistry.Call` → HTTP al `device_agent` remoto. +4. claude ve los resultados reales, audit DB se llena, anti-hallucination pasa. + +## Arquitectura + +``` +agents_and_robots (VPS) +├─ launcher (Go) +│ └─ devagents.New(cfg) +│ ├─ buildDeviceMeshRegistry() -- per-agent ToolRegistry +│ ├─ buildMCPConfig() -- escribe /tmp/-mcp-config.json +│ └─ override cfg.LLM.Primary.ClaudeCode (MCPConfigPath, AllowedTools, DisableTools=false) +│ +└─ bin/devicemesh-mcp (binario standalone) + ├─ stdin ← JSON-RPC frames del claude parent + ├─ stdout → JSON-RPC responses + ├─ tools/list → enumera 14+ tools del registry filtered + └─ tools/call → dispatch HTTP al device_agent + via pkg/tools/devicemesh.NewClient + RegisterBuiltins +``` + +Flujo real una vez activado: + +``` +operator → Matrix DM → agent-wsl-lucas + → claude -p --mcp-config /tmp/agent-wsl-lucas-mcp-config.json --allowedTools "mcp__devicemesh__exec" ... + → claude spawna ./bin/devicemesh-mcp como child + → claude envia tools/list → devicemesh-mcp responde con 14 tools + → claude decide ejecutar exec + → claude envia tools/call name=exec args={argv:["ls"]} + → devicemesh-mcp llama ToolRegistry.Call("exec", {argv:["ls"]}) + → POST http://10.42.0.10:7474/capability {capability:"shell.exec", args:{argv:["ls"]}} + → device_agent ejecuta, registra en audit.db, devuelve resultado + → devicemesh-mcp empaqueta como MCP {content:[{type:"text", text:""}]} + → claude recibe resultado real, lo razona, responde al operador +``` + +## Tareas + +### Pieza 1 — Binario `cmd/devicemesh-mcp/` + +- `cmd/devicemesh-mcp/main.go` — entrypoint con flags `--device-agent`, `--mode`, `--tools-allowed`. Inicializa `Client` + `RegisterBuiltins` + `FilterByAllowed`. Lanza loop stdio via `mcp-go server.ServeStdio`. +- `cmd/devicemesh-mcp/bridge.go` — adapter: itera `ToolRegistry.List()` y registra cada spec como MCP tool, con handler que invoca `reg.Call(ctx, name, args)` y devuelve `mcp.NewToolResultText()` o `mcp.NewToolResultError()`. +- Build target: `bin/devicemesh-mcp`. + +### Pieza 2 — Schema config + +- `internal/config/schema.go`: + - `ClaudeCodeCfg`: anadir `MCPConfigPath string` y `MCPServerName string` (default "devicemesh"). + - `DeviceMeshConfig`: anadir `ExposeViaMCP *bool` (puntero para distinguir "no establecido" vs "false explicito"). Helper `ShouldExposeViaMCP()` que devuelve true cuando enabled && (nil || *true). + +### Pieza 3 — Launcher integration + +- `devagents/mcp_bridge.go` — funcion `BuildMCPBridge(cfg, logger)` que: + - Resuelve binario `bin/devicemesh-mcp` relativo al ejecutable del launcher. + - Resuelve URL device_agent (env override igual que `buildDeviceMeshRegistry`). + - Construye lista de tools allowed. + - Genera el JSON de mcp-config en `/tmp/-mcp-config.json` (mode 0600). + - Devuelve `(configPath, allowedToolNames, err)`. +- `devagents/runtime.go` o `cmd/launcher/main.go`: tras cargar config si `DeviceMesh.Enabled && ShouldExposeViaMCP`, llamar `BuildMCPBridge` y aplicar overrides a `cfg.LLM.Primary.ClaudeCode` (MCPConfigPath, AllowedTools, DisableTools=false). Logging explicito. + +### Pieza 4 — `shell/llm/claudecode.go` + +- En `buildClaudeArgs`: si `cfg.MCPConfigPath != ""`, append `--mcp-config `. +- Validacion defensiva: si `DisableTools=true` y `AllowedTools` no vacio, log warning + ignorar DisableTools (AllowedTools tiene prioridad). + +### Pieza 5 — Tests + +- `cmd/devicemesh-mcp/main_test.go`: + - `TestInitialize` — frame initialize → serverInfo + capabilities. + - `TestToolsList` — frame tools/list → 14+ tools con `inputSchema`. Mock device-agent via httptest. + - `TestToolsCallExec` — tools/call name=exec → device-agent devuelve stdout=hi → assert MCP content contiene "hi". + - `TestToolsCallInvalidTool` — tools/call name=nonexistent → assert isError. + - `TestNotificationsInitialized` — notification (no id) → assert NO response. + - `TestUserModeFilter` — --mode user → pkg.install NO listado; --mode sudo → si. +- `cmd/devicemesh-mcp/integration_test.go` — spawn subprocess + secuencia completa. +- `devagents/mcp_bridge_test.go` — assert config JSON valido, allowed_tools formato `mcp____`, override DisableTools. + +### Pieza 6 — Build + smoke + +1. `go build -tags goolm -o bin/devicemesh-mcp ./cmd/devicemesh-mcp` clean. +2. `go build -tags goolm -o bin/launcher ./cmd/launcher` clean. +3. Smoke test del binario: `echo '{"jsonrpc":"2.0","id":1,"method":"initialize",...}' | bin/devicemesh-mcp` produce JSON-RPC response. +4. Deploy a VPS + restart `agents_and_robots.service`. +5. Verificar `/tmp/agent-wsl-lucas-mcp-config.json` se genera tras restart + logs muestran tools registered + claude-code-with-MCP. + +## Aceptacion (anti-criterio A3 anti-hallucination) + +- Al pedirle a `agent-wsl-lucas` que ejecute `ls`, una entry aparece en `audit.db` del device dentro de 5s. +- `claude -p` logs muestran `tool_use: mcp__devicemesh__exec` (no texto imitado). +- `/tmp/-mcp-config.json` valido, mode 0600. +- `bin/devicemesh-mcp` standalone responde a `initialize`/`tools/list`/`tools/call` en JSON-RPC. + +## DoD triada por capas + +| Capa | Verificacion | +|---|---| +| Binario MCP | `bin/devicemesh-mcp` build clean + tests passing | +| Launcher | `/tmp/-mcp-config.json` generado + cfg overrides aplicados | +| claude args | `--mcp-config ` + `--allowedTools mcp__devicemesh__*` presentes | +| Smoke real | Audit DB del device crece tras prompt al agent | + +## Decisiones de diseno + +1. **MCP via mcp-go SDK** en vez de implementar JSON-RPC raw. La dep `github.com/mark3labs/mcp-go v0.44.1` ya existe (`shell/mcp/server.go` ya la usa). Usar `server.ServeStdio` reduce superficie de bugs y test surface. +2. **Binario standalone** (`cmd/devicemesh-mcp/`) en vez de embebido en el launcher. Razon: claude lo lanza como child via `--mcp-config` — necesita un ejecutable separado. Tambien permite debuggear en aislamiento (`echo ... | bin/devicemesh-mcp`). +3. **MCPConfigPath en `/tmp/`** (no en `/data/`). El path es runtime-only, regenerable cada arranque, contiene path absoluto al binario del launcher actual + URL devicemesh. Persistirlo en repo crea drift PC↔VPS.