// 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") }