Files
egutierrez d1fd78324b test(0145): unit + integration + launcher + claudecode coverage
cmd/devicemesh-mcp/main_test.go (10 tests):
- TestInitialize: JSON-RPC initialize frame → serverInfo + capabilities.
- TestToolsList: tools/list → 16 user-mode entries, cada uno con name +
  inputSchema valido.
- TestToolsCallExec: tools/call name=exec → mock device-agent (httptest)
  recibe capability=shell.exec, MCP response content contiene "hi".
- TestToolsCallInvalidTool: name desconocido → isError o error envelope.
- TestNotificationsInitializedNoResponse: notification (sin id) → cero
  responses.
- TestUserModeFiltersPkgInstall: --mode user oculta pkg.install,
  --mode sudo la expone.
- TestToolsAllowedNarrows: --tools-allowed exec,fs.read → solo 2.
- TestSplitCSV, TestParseMode, TestIsCleanShutdown: helpers.

cmd/devicemesh-mcp/integration_test.go:
- TestIntegrationBinarySubprocess: build el binario en tmp + spawn como
  child via exec.Command + pipe real + secuencia initialize ->
  notifications/initialized -> tools/list -> tools/call. Valida el path
  identico al que usara claude.

devagents/mcp_bridge_test.go (9 tests):
- Disabled paths (nil DM, ExposeViaMCP=false, provider!=claude-code).
- Applied path: /tmp/<agent>-mcp-config.json JSON valido, mode 0600,
  mcpServers.devicemesh con command apuntando al binario fake.
- AllowedTools formato mcp__<server>__<tool>.
- DisableTools=true overrideado a false.
- URLEnv override gana sobre YAML.
- Binary missing → ok=false sin panico.
- BuildClaudeAllowedToolNames default server name.
- ResolveBridgedToolNames respeta mode + ToolsAllowed.
- ShouldExposeViaMCP cubre nil/disabled/default/explicit-true/false.

shell/llm/claudecode_test.go:
- TestBuildClaudeArgs_DisableTools actualizado: solo emite --tools "" cuando
  AllowedTools ESTA vacio. La regla nueva (issue 0145) da precedencia a
  AllowedTools.
- Anadido TestBuildClaudeArgs_DisableToolsButAllowedToolsWins.
- Anadido TestBuildClaudeArgs_MCPConfigPath.

bridge.go fix: cambio NewTool + WithRawInputSchema a NewToolWithRawSchema
porque NewTool inicializa ToolInputSchema.Type="object" por default, lo
cual entra en conflicto con RawInputSchema en MarshalJSON del SDK.

Suite completa pasa con -tags goolm -count=1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:33:24 +02:00

166 lines
5.3 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.
//
// We use mcp.NewToolWithRawSchema (not NewTool + WithRawInputSchema) because
// NewTool initialises a default ToolInputSchema with Type="object", which
// then conflicts at marshal time with our RawInputSchema (the SDK rejects
// having both set — see mcp/tools.go ::Tool.MarshalJSON).
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)"
}
if spec.InputSchema == nil {
// Fall back to a minimal "no params" schema so the tool is still
// callable. Should not happen for the builtins (they all set
// InputSchema), but the adapter must not panic on third-party specs.
return mcp.NewToolWithRawSchema(spec.Name, desc,
json.RawMessage(`{"type":"object","properties":{}}`)), nil
}
raw, err := json.Marshal(spec.InputSchema)
if err != nil {
return mcp.Tool{}, fmt.Errorf("marshal input schema: %w", err)
}
return mcp.NewToolWithRawSchema(spec.Name, desc, raw), 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
}