feat(0145-1): binario devicemesh-mcp + issue doc
Anade el binario standalone cmd/devicemesh-mcp/ que expone via JSON-RPC
sobre stdio el catalogo de devicemesh tools (exec, shell.eval, fs.*,
git.*, pkg.*, proc.*, docker.*) al claude -p parent.
Arquitectura issue 0145:
- main.go: flags (--device-agent, --mode, --tools-allowed, --server-name),
inicializa devicemesh.Client + RegisterBuiltins + FilterByAllowed, lanza
server.ServeStdio del SDK mark3labs/mcp-go (ya dep).
- bridge.go: registra cada ToolSpec como mcp.Tool con WithRawInputSchema +
handler que invoca ToolRegistry.Call (validate->map->HTTP->map). Resultado
serializado a NewToolResultText, errores como NewToolResultError para que
el modelo se autocorrija.
Razon: hoy claude -p ve nuestras tool names solo como TEXTO en el system
prompt y las imita sin ejecutar. Con --mcp-config apuntando a este binario,
claude las descubre via tools/list e invoca via tools/call REALMENTE.
Smoke OK: initialize frame produce {capabilities:{tools:{listChanged:true}},
serverInfo:{name:"devicemesh",version:"0.1.0"}}.
Issue doc 0145 incluido con aceptacion A3 anti-hallucination + DoD triada.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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__<server_name>__<tool_name>` (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
|
||||
}
|
||||
@@ -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__<server_name>__<tool_name>` to the model
|
||||
//
|
||||
// Flags:
|
||||
//
|
||||
// --device-agent <URL> required — http://host:port of the remote device_agent
|
||||
// --mode user|sudo|all default user — filters which builtin tools are registered
|
||||
// --tools-allowed <csv> optional — narrows the catalog after mode filtering
|
||||
// --server-name <name> default "devicemesh" — only used for logs and serverInfo
|
||||
//
|
||||
// Environment:
|
||||
//
|
||||
// MCP_DEBUG_LOG <path> 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")
|
||||
}
|
||||
@@ -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/<agent_id>-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:"<JSON>"}]}
|
||||
→ 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(<json>)` o `mcp.NewToolResultError(<msg>)`.
|
||||
- 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/<agent_id>-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 <path>`.
|
||||
- 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__<server>__<tool>`, 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/<agent_id>-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/<agent_id>-mcp-config.json` generado + cfg overrides aplicados |
|
||||
| claude args | `--mcp-config <path>` + `--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 `<agent_dir>/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.
|
||||
Reference in New Issue
Block a user