// 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____` (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 }