d1fd78324b
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>
166 lines
5.3 KiB
Go
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
|
|
}
|