feat: add the mcp tool registry and its scope gates

This commit is contained in:
2026-09-08 13:50:01 +00:00
parent 7ea8e2fff0
commit 5943d98681
2 changed files with 224 additions and 0 deletions
+133
View File
@@ -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 }
+91
View File
@@ -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)
}
}
}