diff --git a/server/internal/mcp/registry.go b/server/internal/mcp/registry.go new file mode 100644 index 0000000..7906d2d --- /dev/null +++ b/server/internal/mcp/registry.go @@ -0,0 +1,133 @@ +// Package mcp exposes Vantage to LLM agents over the Model Context Protocol. +// +// It is a presentation layer over the service layer and introduces no authority +// of its own: every tool calls the same service functions the REST handlers +// call, and every decision about who may do what is made by machinery that +// already exists. Three gates apply to every call — the licence feature, the +// mcp:* scope, and the tool's own resource scope — and all three must pass. +package mcp + +import ( + "context" + "strings" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" +) + +// Gate names, returned by Allowed so a refusal can be audited and explained to +// the model in words it can act on. +const ( + GateMCPScope = "mcp_scope" + GateResourceScope = "resource_scope" +) + +// ToolFunc is one tool's implementation. args is the decoded argument object; +// the returned value is marshalled as the tool result. +type ToolFunc func(ctx context.Context, c Caller, args map[string]any) (any, error) + +// Caller is the acting credential, built from the gin session by the transport +// layer. The mcp package never reads a request or a cookie itself. +type Caller struct { + InstanceID string + Scopes []string + TokenScope map[string]string + TokenName string +} + +// Tool is one registered capability. +type Tool struct { + Name string + // Description is prompt text the model reads to choose a tool, so it states + // blast radius in plain words rather than describing an endpoint. + Description string + // Scope is the resource scope required, e.g. "servers:read". + Scope string + // Write marks a tool that changes something. A write tool is omitted from + // the listing for a caller without mcp:write. + Write bool + Handler ToolFunc +} + +// Registry holds the tool set in registration order, which is the order a +// client sees. +type Registry struct { + order []string + tools map[string]Tool +} + +func NewRegistry() *Registry { + return &Registry{tools: map[string]Tool{}} +} + +func (r *Registry) Register(t Tool) { + if _, exists := r.tools[t.Name]; exists { + panic("mcp: duplicate tool " + t.Name) + } + r.order = append(r.order, t.Name) + r.tools[t.Name] = t +} + +func (r *Registry) Lookup(name string) (Tool, bool) { + t, ok := r.tools[name] + return t, ok +} + +// Tools returns every registered tool regardless of caller, for tests and +// documentation generation. +func (r *Registry) Tools() []Tool { + out := make([]Tool, 0, len(r.order)) + for _, n := range r.order { + out = append(out, r.tools[n]) + } + return out +} + +// Visible is what this caller's tools/list returns. +func (r *Registry) Visible(c Caller) []Tool { + out := []Tool{} + for _, t := range r.Tools() { + if ok, _ := Allowed(t, c); ok { + out = append(out, t) + } + } + return out +} + +// Allowed reports whether this caller may invoke this tool, and names the gate +// that refused when they may not. +// +// The licence gate is not checked here: it is route middleware, so a caller +// reaching this code has already passed it. +func Allowed(t Tool, c Caller) (bool, string) { + required := "mcp:read" + if t.Write { + required = "mcp:write" + } + if !services.ScopeSatisfied(c.Scopes, required) { + return false, GateMCPScope + } + if !services.ScopeSatisfied(c.Scopes, t.Scope) { + return false, GateResourceScope + } + return true, "" +} + +func knownScope(s string) bool { + resource, action, ok := strings.Cut(s, ":") + if !ok || (action != services.ScopeRead && action != services.ScopeWrite) { + return false + } + for _, r := range services.ScopeResources { + if r == resource { + return true + } + } + return false +} + +// all is the process-wide registry the tool files populate from their init +// functions, and the transport serves. +var all = NewRegistry() + +// All returns the process-wide registry. +func All() *Registry { return all } diff --git a/server/internal/mcp/registry_test.go b/server/internal/mcp/registry_test.go new file mode 100644 index 0000000..4ca5b6f --- /dev/null +++ b/server/internal/mcp/registry_test.go @@ -0,0 +1,91 @@ +package mcp + +import "testing" + +func testRegistry() *Registry { + r := NewRegistry() + r.Register(Tool{Name: "list_servers", Scope: "servers:read", Write: false}) + r.Register(Tool{Name: "run_workflow", Scope: "workflows:write", Write: true}) + return r +} + +// A token without mcp:read is not an agent token, whatever else it holds. +func TestNoMCPScopeSeesNothing(t *testing.T) { + c := Caller{Scopes: []string{"servers:read", "workflows:write"}} + if got := testRegistry().Visible(c); len(got) != 0 { + t.Errorf("Visible = %d tools, want 0", len(got)) + } +} + +// Write tools are OMITTED from the listing, not merely refused on call: an +// agent cannot be talked into using a tool it has never been told exists. +func TestReadOnlyCallerCannotSeeWriteTools(t *testing.T) { + c := Caller{Scopes: []string{"mcp:read", "servers:read", "workflows:write"}} + + names := map[string]bool{} + for _, tool := range testRegistry().Visible(c) { + names[tool.Name] = true + } + if !names["list_servers"] { + t.Error("list_servers hidden from a read-capable caller") + } + if names["run_workflow"] { + t.Error("run_workflow listed without mcp:write") + } +} + +func TestWriteCallerSeesBoth(t *testing.T) { + c := Caller{Scopes: []string{"mcp:write", "servers:read", "workflows:write"}} + if got := testRegistry().Visible(c); len(got) != 2 { + t.Errorf("Visible = %d tools, want 2", len(got)) + } +} + +// The resource scope is enforced independently of the MCP scope. +func TestResourceScopeStillRequired(t *testing.T) { + c := Caller{Scopes: []string{"mcp:write", "servers:read"}} + + for _, tool := range testRegistry().Visible(c) { + if tool.Name == "run_workflow" { + t.Error("run_workflow listed without workflows:write") + } + } + + run, _ := testRegistry().Lookup("run_workflow") + ok, gate := Allowed(run, c) + if ok { + t.Error("run_workflow allowed without workflows:write") + } + if gate != GateResourceScope { + t.Errorf("gate = %q, want %q", gate, GateResourceScope) + } +} + +func TestAllowedNamesTheMCPGate(t *testing.T) { + c := Caller{Scopes: []string{"servers:read"}} + list, _ := testRegistry().Lookup("list_servers") + ok, gate := Allowed(list, c) + if ok { + t.Error("call allowed without mcp:read") + } + if gate != GateMCPScope { + t.Errorf("gate = %q, want %q", gate, GateMCPScope) + } +} + +// Every tool must declare a scope from the real vocabulary, or a tool added +// tomorrow could be reachable with no resource scope at all. +func TestEveryRegisteredToolDeclaresAKnownScope(t *testing.T) { + for _, tool := range All().Tools() { + if tool.Scope == "" { + t.Errorf("tool %q declares no scope", tool.Name) + continue + } + if !knownScope(tool.Scope) { + t.Errorf("tool %q declares unknown scope %q", tool.Name, tool.Scope) + } + if tool.Description == "" { + t.Errorf("tool %q has no description; descriptions are prompt text", tool.Name) + } + } +}