From 4255514bab8cbeacf578ea6e38a3b83a212ad19d Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 17 Apr 2026 07:40:20 +0200 Subject: [PATCH] feat: add skill registry with tool routing --- internal/registry/registry.go | 50 +++++++++++++++++++++++++++++ internal/registry/registry_test.go | 51 ++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 internal/registry/registry.go create mode 100644 internal/registry/registry_test.go diff --git a/internal/registry/registry.go b/internal/registry/registry.go new file mode 100644 index 0000000..b458e50 --- /dev/null +++ b/internal/registry/registry.go @@ -0,0 +1,50 @@ +package registry + +import ( + "context" + "encoding/json" + "fmt" +) + +// ToolDef describes a single MCP tool exposed by a skill. +type ToolDef struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"inputSchema"` +} + +// Skill is implemented by each skill package (tdd, review, etc.). +type Skill interface { + Name() string + Tools() []ToolDef + Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) +} + +// Registry routes MCP tool calls to the correct skill handler. +type Registry struct { + skills map[string]Skill // tool name → skill + tools []ToolDef +} + +func New() *Registry { + return &Registry{skills: make(map[string]Skill)} +} + +func (r *Registry) Register(s Skill) { + for _, t := range s.Tools() { + r.skills[t.Name] = s + r.tools = append(r.tools, t) + } +} + +func (r *Registry) Tools() []ToolDef { + return r.tools +} + +func (r *Registry) Dispatch(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { + s, ok := r.skills[tool] + if !ok { + return nil, fmt.Errorf("unknown tool: %s", tool) + } + return s.Handle(ctx, tool, args) +} diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go new file mode 100644 index 0000000..c7de9a3 --- /dev/null +++ b/internal/registry/registry_test.go @@ -0,0 +1,51 @@ +package registry_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/mathiasbq/supervisor/internal/registry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type stubSkill struct { + name string +} + +func (s stubSkill) Name() string { return s.name } +func (s stubSkill) Tools() []registry.ToolDef { + return []registry.ToolDef{{ + Name: s.name + "_tool", + Description: "stub tool", + InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), + }} +} +func (s stubSkill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { + return json.RawMessage(`{"ok":true}`), nil +} + +func TestRegistryRoutes(t *testing.T) { + r := registry.New() + r.Register(stubSkill{name: "tdd"}) + + result, err := r.Dispatch(context.Background(), "tdd_tool", json.RawMessage(`{}`)) + require.NoError(t, err) + assert.JSONEq(t, `{"ok":true}`, string(result)) +} + +func TestRegistryUnknownTool(t *testing.T) { + r := registry.New() + _, err := r.Dispatch(context.Background(), "unknown_tool", json.RawMessage(`{}`)) + assert.ErrorContains(t, err, "unknown tool") +} + +func TestRegistryListsTools(t *testing.T) { + r := registry.New() + r.Register(stubSkill{name: "tdd"}) + + tools := r.Tools() + require.Len(t, tools, 1) + assert.Equal(t, "tdd_tool", tools[0].Name) +}