feat: add the mcp tool registry and its scope gates
This commit is contained in:
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user