15596df7e4
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>
158 lines
4.8 KiB
Go
158 lines
4.8 KiB
Go
// 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
|
|
}
|