From a2eee958f06192f786911cc7a0d8802c441d1e9a Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 8 Sep 2026 13:24:03 +0000 Subject: [PATCH] docs: implementation plan for the mcp server, and creation tools in the spec --- .../plans/2026-09-08-mcp-server.md | 2553 +++++++++++++++++ .../specs/2026-09-08-mcp-server-design.md | 41 +- 2 files changed, 2592 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-08-mcp-server.md diff --git a/docs/superpowers/plans/2026-09-08-mcp-server.md b/docs/superpowers/plans/2026-09-08-mcp-server.md new file mode 100644 index 0000000..059ecfc --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-mcp-server.md @@ -0,0 +1,2553 @@ +# MCP Server Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expose Vantage to LLM agents as an MCP tool surface at `/api/mcp`, authenticated by the existing API token, gated by a new licence feature, and restricted by a new tag selector on API tokens. + +**Architecture:** A new `server/internal/mcp` package registers task-shaped tools that call the existing service layer in-process. It is mounted inside the existing `/api` gin group, so bearer auth, rate limiting, licence checks and scope enforcement apply unchanged. Authority is never invented in the MCP layer: three gates (licence feature, `mcp:*` scope, per-tool resource scope) are all made of machinery that already exists, plus one new general capability — tag-scoped API tokens — that ships useful on its own. + +**Tech Stack:** Go 1.26, gin, MongoDB (mongo-driver v2), `github.com/modelcontextprotocol/go-sdk`, Next.js 16 (web), and the `vantage-admin` HQ service plus Paddle for licensing. + +**Spec:** `docs/superpowers/specs/2026-09-08-mcp-server-design.md` + +## Global Constraints + +- Three repos are touched: `vantage-shared` (licence constant), `vantage-app` (server + web), `vantage-admin` (catalogue + labels). They have **no import cycle and no build dependency between app and admin** — do not create one. +- Go module path for the app server is `gitea.hostxtra.co.uk/mrhid6/vantage/server`; for shared, `gitea.hostxtra.co.uk/vantage/vantage-shared`. +- The licence feature key is exactly `"mcp"`, constant `license.FeatureMCP`. +- The new scope resource is exactly `"mcp"`, producing `mcp:read` and `mcp:write`. Do **not** invent an `mcp:use` scope. +- Tag scoping is **not** licence-gated. Only MCP is. +- Paddle: sandbox only, GBP, £9.00/month and £90.00/year, tax category `standard`. Never write to the production Paddle account. +- Existing tests are pure unit tests with no database. Keep new logic in pure functions where possible so it stays that way; run with `go test ./...` from `vantage-app/server`. +- Commit style in this repo is conventional commits (`feat:`, `docs:`, `fix:`). +- Work happens on branch `feat/mcp-server`, which already exists and holds the spec commit. + +--- + +### Task 1: Licence feature constant + +**Files:** +- Modify: `vantage-shared/license/license.go:32-42` + +**Interfaces:** +- Produces: `license.FeatureMCP` (string constant, value `"mcp"`), consumed by every later task in both `vantage-app` and `vantage-admin`. + +- [ ] **Step 1: Add the constant** + +In `vantage-shared/license/license.go`, alongside the existing feature constants: + +```go + // FeatureMCP gates agent access over the Model Context Protocol. It is + // opt-in per customer like console and OIDC, so no plan bundles it. + FeatureMCP = "mcp" +``` + +Place it with the other `Feature*` constants, keeping the existing grouping and comment style. + +- [ ] **Step 2: Verify it compiles** + +Run from `vantage-shared`: `go build ./...` +Expected: no output. + +- [ ] **Step 3: Commit** + +```bash +cd vantage-shared +git add license/license.go +git commit -m "feat: add the mcp licence feature constant" +``` + +- [ ] **Step 4: Make it available to the app and admin modules** + +Both consume `vantage-shared` as a Go module. From `vantage-app/server`: + +```bash +go get gitea.hostxtra.co.uk/vantage/vantage-shared@latest +go mod tidy +``` + +If the repos are wired with a `replace` directive to a local path, this step is a no-op — check `go.mod` first and skip if so. Do the same from `vantage-admin/server`. + +- [ ] **Step 5: Commit the module bump if one happened** + +```bash +git add go.mod go.sum +git commit -m "chore: pick up the mcp licence feature from vantage-shared" +``` + +--- + +### Task 2: The `mcp` scope resource + +**Files:** +- Modify: `vantage-app/server/internal/services/scopes.go:20-30` +- Test: `vantage-app/server/internal/services/scopes_test.go` (create) + +**Interfaces:** +- Produces: `"mcp:read"` and `"mcp:write"` as valid scopes, accepted by `services.ValidScopes` and resolved by `services.ScopeSatisfied`. Consumed by Tasks 3, 8, 9, 10. + +- [ ] **Step 1: Write the failing test** + +Create `vantage-app/server/internal/services/scopes_test.go`: + +```go +package services + +import "testing" + +// The MCP endpoint is reached with an ordinary scope from the ordinary +// vocabulary. A bespoke action verb here would be the first exception in a +// table whose whole value is having none. +func TestMCPScopesExist(t *testing.T) { + if err := ValidScopes([]string{"mcp:read"}); err != nil { + t.Errorf("ValidScopes(mcp:read) = %v, want nil", err) + } + if err := ValidScopes([]string{"mcp:write"}); err != nil { + t.Errorf("ValidScopes(mcp:write) = %v, want nil", err) + } + if err := ValidScopes([]string{"mcp:use"}); err == nil { + t.Error("ValidScopes(mcp:use) = nil, want an error") + } +} + +// Write implies read on the same resource, so a token minted with mcp:write +// alone still reaches the endpoint. +func TestMCPWriteImpliesRead(t *testing.T) { + if !ScopeSatisfied([]string{"mcp:write"}, "mcp:read") { + t.Error("mcp:write does not satisfy mcp:read") + } + if ScopeSatisfied([]string{"mcp:read"}, "mcp:write") { + t.Error("mcp:read satisfies mcp:write, want false") + } +} + +func TestAllScopesAdvertisesMCP(t *testing.T) { + want := map[string]bool{"mcp:read": false, "mcp:write": false} + for _, s := range AllScopes() { + if _, ok := want[s]; ok { + want[s] = true + } + } + for s, found := range want { + if !found { + t.Errorf("AllScopes() is missing %q", s) + } + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/services/ -run 'TestMCP|TestAllScopes' -v` +Expected: FAIL — `ValidScopes(mcp:read)` returns an invalid-scope error. + +- [ ] **Step 3: Add the resource** + +In `internal/services/scopes.go`, append to `ScopeResources`: + +```go + "status", + // mcp:read is permission to reach the MCP endpoint at all; mcp:write is + // permission for its write tools, which are not merely refused without it + // but omitted from tools/list entirely. + "mcp", +``` + +Update the doc comment above `ScopeResources` — it says "Eight resources" and there are now ten. Count the slice and write the real number; the comment exists so the count is deliberate rather than drifted. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/services/ -run 'TestMCP|TestAllScopes' -v` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/services/scopes.go internal/services/scopes_test.go +git commit -m "feat: add the mcp scope resource" +``` + +--- + +### Task 3: Tag selector on API tokens + +**Files:** +- Modify: `vantage-app/server/internal/models/api_token.go:20-45` +- Modify: `vantage-app/server/internal/services/tokens.go:62-140` +- Test: `vantage-app/server/internal/services/tokenscope_test.go` (create) +- Create: `vantage-app/server/internal/services/tokenscope.go` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + - `models.APIToken.TagSelector map[string]string` + - `services.ServerInTokenScope(srv models.Server, sel map[string]string) bool` + - `services.IntersectSelectors(caller, requested map[string]string) (map[string]string, bool)` + - `services.SelectorNarrowerOrEqual(child, parent map[string]string) bool` + - `services.CreateAPIToken(instanceID, userID, name, role string, scopes []string, tagSelector map[string]string, expiresInDays *int, ip string)` — note the **new sixth parameter**, consumed by Task 4. + +- [ ] **Step 1: Write the failing test** + +Create `vantage-app/server/internal/services/tokenscope_test.go`: + +```go +package services + +import ( + "testing" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" +) + +func srv(tags map[string]string) models.Server { + return models.Server{ServerID: "s1", Tags: tags} +} + +// The critical asymmetry with MatchesTags: an EMPTY token selector means the +// whole fleet, where an empty workflow selector matches nothing. Reusing +// MatchesTags here would lock every unrestricted token out of everything. +func TestServerInTokenScopeEmptySelectorAllowsAll(t *testing.T) { + if !ServerInTokenScope(srv(nil), nil) { + t.Error("nil selector rejected a server, want whole-fleet access") + } + if !ServerInTokenScope(srv(map[string]string{"env": "prod"}), map[string]string{}) { + t.Error("empty selector rejected a server, want whole-fleet access") + } +} + +func TestServerInTokenScopeRequiresEveryTag(t *testing.T) { + s := srv(map[string]string{"env": "staging", "team": "core"}) + + if !ServerInTokenScope(s, map[string]string{"env": "staging"}) { + t.Error("matching selector rejected") + } + if !ServerInTokenScope(s, map[string]string{"env": "staging", "team": "core"}) { + t.Error("fully matching selector rejected") + } + if ServerInTokenScope(s, map[string]string{"env": "prod"}) { + t.Error("non-matching selector accepted") + } + if ServerInTokenScope(s, map[string]string{"env": "staging", "team": "web"}) { + t.Error("partially matching selector accepted, every tag must match") + } +} + +func TestIntersectSelectors(t *testing.T) { + // No token restriction: the request's own selector stands. + got, ok := IntersectSelectors(nil, map[string]string{"env": "prod"}) + if !ok || got["env"] != "prod" || len(got) != 1 { + t.Errorf("IntersectSelectors(nil, env=prod) = %v, %v", got, ok) + } + + // Disjoint values for the same key can never both hold. + if _, ok := IntersectSelectors( + map[string]string{"env": "staging"}, + map[string]string{"env": "prod"}, + ); ok { + t.Error("conflicting selectors intersected to something, want impossible") + } + + // Different keys combine. + got, ok = IntersectSelectors( + map[string]string{"env": "staging"}, + map[string]string{"team": "core"}, + ) + if !ok || got["env"] != "staging" || got["team"] != "core" { + t.Errorf("IntersectSelectors = %v, %v, want both keys", got, ok) + } +} + +func TestSelectorNarrowerOrEqual(t *testing.T) { + parent := map[string]string{"env": "staging"} + + // Same selector, and a stricter one, are both allowed. + if !SelectorNarrowerOrEqual(parent, parent) { + t.Error("identical selector rejected") + } + if !SelectorNarrowerOrEqual(map[string]string{"env": "staging", "team": "core"}, parent) { + t.Error("stricter selector rejected") + } + + // A token may not mint one that reaches further than itself. + if SelectorNarrowerOrEqual(nil, parent) { + t.Error("unrestricted child of a restricted parent allowed") + } + if SelectorNarrowerOrEqual(map[string]string{"env": "prod"}, parent) { + t.Error("child escaping the parent's tag allowed") + } + + // An unrestricted parent permits anything. + if !SelectorNarrowerOrEqual(map[string]string{"env": "prod"}, nil) { + t.Error("restricted child of an unrestricted parent rejected") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/services/ -run 'TokenScope|Intersect|Narrower' -v` +Expected: FAIL to compile — `ServerInTokenScope`, `IntersectSelectors` and `SelectorNarrowerOrEqual` are undefined. + +- [ ] **Step 3: Write the selector helpers** + +Create `vantage-app/server/internal/services/tokenscope.go`: + +```go +package services + +import "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + +// ServerInTokenScope reports whether a credential restricted to sel may see +// this server. +// +// This is deliberately NOT MatchesTags. That function serves workflow +// targeting, where an empty selector selects nothing because the caller named +// servers by ID instead. Here an empty selector means the token is +// unrestricted, so it must select everything. The two rules are opposite and +// sharing one function would silently lock every unrestricted token out of the +// whole fleet. +func ServerInTokenScope(srv models.Server, sel map[string]string) bool { + if len(sel) == 0 { + return true + } + for k, v := range sel { + if srv.Tags[k] != v { + return false + } + } + return true +} + +// IntersectSelectors merges the caller's token restriction with a selector the +// request asked for. ok is false when the two can never both hold, which means +// the request resolves to no servers rather than to an error. +func IntersectSelectors(caller, requested map[string]string) (map[string]string, bool) { + out := make(map[string]string, len(caller)+len(requested)) + for k, v := range caller { + out[k] = v + } + for k, v := range requested { + if existing, ok := out[k]; ok && existing != v { + return nil, false + } + out[k] = v + } + return out, true +} + +// SelectorNarrowerOrEqual reports whether child reaches no further than parent. +// +// It is the tag equivalent of the rule ScopeSatisfied already enforces for +// scopes: a credential may only mint one no more powerful than itself. +func SelectorNarrowerOrEqual(child, parent map[string]string) bool { + for k, v := range parent { + if child[k] != v { + return false + } + } + return true +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/services/ -run 'TokenScope|Intersect|Narrower' -v` +Expected: PASS (4 tests). + +- [ ] **Step 5: Add the model field** + +In `internal/models/api_token.go`, inside the `APIToken` struct after `Scopes`: + +```go + // TagSelector restricts this token to servers carrying every tag in the + // map. Empty or nil means the whole fleet. + // + // Immutable after creation for the same reason as Role and Scopes: changing + // what a credential already deployed in CI can reach, with no record of what + // it could reach before, is worse than requiring a rotation. + TagSelector map[string]string `bson:"tag_selector,omitempty" json:"tag_selector,omitempty"` +``` + +Extend the struct's doc comment, which currently says "Role and Scopes are immutable after creation", to name `TagSelector` too. + +- [ ] **Step 6: Thread it through token creation** + +In `internal/services/tokens.go`, change the `CreateAPIToken` signature to take the selector after `scopes`: + +```go +func CreateAPIToken(instanceID, userID, name, role string, scopes []string, tagSelector map[string]string, expiresInDays *int, ip string) (*models.APIToken, string, error) { +``` + +After the existing `ValidScopes(scopes)` check, add validation and the narrowing rule: + +```go + if len(tagSelector) > 0 { + if err := ValidateTags(tagSelector); err != nil { + return nil, "", err + } + } +``` + +And in the struct literal that builds `tok`, after `Scopes: scopes,`: + +```go + TagSelector: tagSelector, +``` + +The caller-narrowing check belongs in the handler rather than here, because the service does not know the caller — Task 4 adds it. + +- [ ] **Step 7: Fix the call sites** + +Run: `go build ./...` +Expected: FAIL, naming each caller of `CreateAPIToken` with the wrong argument count. Update each — in `internal/api/tokens.go` pass the value from the request body (Task 4 adds the field; for now pass `nil`), and in any test or seed caller pass `nil`. + +- [ ] **Step 8: Run the full service tests** + +Run: `go test ./internal/services/ -v` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add internal/models/api_token.go internal/services/tokens.go internal/services/tokenscope.go internal/services/tokenscope_test.go internal/api/tokens.go +git commit -m "feat: allow an API token to be restricted to servers by tag" +``` + +--- + +### Task 4: Carry the selector on the session and accept it at token creation + +**Files:** +- Modify: `vantage-app/server/internal/auth/session.go:19-32` +- Modify: `vantage-app/server/internal/auth/middleware.go:128-201` +- Modify: `vantage-app/server/internal/api/tokens.go:70-145` + +**Interfaces:** +- Consumes: `models.APIToken.TagSelector`, `services.SelectorNarrowerOrEqual`, the new `services.CreateAPIToken` signature (Task 3). +- Produces: `auth.ServerScope(c *gin.Context) map[string]string`, consumed by Tasks 5, 9, 10. + +- [ ] **Step 1: Add the session field** + +In `internal/auth/session.go`, inside `Session` alongside `TokenID`, `TokenName` and `Scopes`: + +```go + TokenScope map[string]string `json:"-"` +``` + +The existing comment above those fields says three fields are token-only and never persisted to Redis. Change it to four and keep the reasoning intact. + +- [ ] **Step 2: Populate it** + +In `internal/auth/middleware.go`, in `sessionFromToken`, add to the returned `&Session{...}`: + +```go + TokenScope: tok.TagSelector, +``` + +- [ ] **Step 3: Add the accessor** + +At the bottom of `internal/auth/middleware.go`, beside `Scopes` and `IsToken`: + +```go +// ServerScope is the tag restriction the acting credential carries, or nil for +// an unrestricted token and for every cookie session. Callers pass it to +// services.ServerInTokenScope or services.IntersectSelectors — nil means the +// whole fleet, never nothing. +func ServerScope(c *gin.Context) map[string]string { + if s := GetSessionFromContext(c); s != nil { + return s.TokenScope + } + return nil +} +``` + +- [ ] **Step 4: Accept the field at token creation** + +In `internal/api/tokens.go`, add to the create request body struct: + +```go + TagSelector map[string]string `json:"tag_selector"` +``` + +Then, beside the existing loop that checks `services.ScopeSatisfied(callerScopes, s)`, add the tag equivalent: + +```go + if !services.SelectorNarrowerOrEqual(body.TagSelector, auth.ServerScope(c)) { + c.JSON(http.StatusForbidden, gin.H{ + "error": "a token cannot reach servers its creator cannot reach", + }) + return + } +``` + +Pass `body.TagSelector` as the new sixth argument to `services.CreateAPIToken`. + +- [ ] **Step 5: Record it in the audit detail** + +The handler already logs token creation with role, scopes and expiry. Extend that message so a restricted token is visible in the audit log: + +```go + detail := fmt.Sprintf("API token '%s' created with role %s, scopes %v, %s", + tok.Name, tok.Role, tok.Scopes, expiry) + if len(tok.TagSelector) > 0 { + detail += fmt.Sprintf(", restricted to %v", tok.TagSelector) + } +``` + +Use `detail` in the existing `services.LogEvent` call. + +- [ ] **Step 6: Verify it builds and tests pass** + +Run: `go build ./... && go test ./...` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/auth/session.go internal/auth/middleware.go internal/api/tokens.go +git commit -m "feat: carry the token tag restriction on the session" +``` + +--- + +### Task 5: Enforce the selector at the server-resolution chokepoints + +**Files:** +- Modify: `vantage-app/server/internal/services/servers.go:62-72` (`GetServer`) +- Modify: `vantage-app/server/internal/services/targets.go:58-72` (`ResolveTargets`) +- Modify: `vantage-app/server/internal/api/handlers.go` (the server list and detail handlers) +- Test: `vantage-app/server/internal/services/tokenscope_test.go` (extend) + +**Interfaces:** +- Consumes: `services.ServerInTokenScope`, `services.IntersectSelectors`, `auth.ServerScope`. +- Produces: `services.ResolveTargetsScoped(instanceID string, ids []string, sel, tokenScope map[string]string) ([]models.Server, error)` and `services.GetServerScoped(instanceID, serverID string, tokenScope map[string]string) (*models.Server, error)`, consumed by Tasks 9 and 10. + +- [ ] **Step 1: Write the failing test** + +Append to `internal/services/tokenscope_test.go`: + +```go +// UnionTargets is the pure core of target resolution, so scoping can be proved +// without a database by filtering its input the way ResolveTargetsScoped does. +func TestScopedTargetsExcludeOutOfScopeServers(t *testing.T) { + all := []models.Server{ + {ServerID: "a", Tags: map[string]string{"env": "staging"}}, + {ServerID: "b", Tags: map[string]string{"env": "prod"}}, + } + + scope := map[string]string{"env": "staging"} + visible := []models.Server{} + for _, s := range all { + if ServerInTokenScope(s, scope) { + visible = append(visible, s) + } + } + + // Naming an out-of-scope server by ID must not reach it. + got := UnionTargets(visible, []string{"a", "b"}, nil) + if len(got) != 1 || got[0].ServerID != "a" { + t.Errorf("scoped targets = %v, want only a", got) + } +} +``` + +- [ ] **Step 2: Run the test to verify it passes for the right reason** + +Run: `go test ./internal/services/ -run TestScopedTargets -v` +Expected: PASS. This test encodes the intended behaviour of the wrapper written next; if it fails, `ServerInTokenScope` is wrong and Task 3 must be revisited before continuing. + +- [ ] **Step 3: Add the scoped wrappers** + +In `internal/services/targets.go`, below `ResolveTargets`: + +```go +// ResolveTargetsScoped is ResolveTargets narrowed by the acting credential's +// tag restriction. +// +// This is the chokepoint that matters: workflow runs, console connections and +// update application all resolve targets through here, so filtering once here +// covers the mutating surface rather than each handler remembering. +// +// A request naming an out-of-scope server by ID resolves to nothing rather than +// to an error, which is what makes an out-of-scope host indistinguishable from +// one that does not exist. +func ResolveTargetsScoped(instanceID string, ids []string, sel, tokenScope map[string]string) ([]models.Server, error) { + all, err := ListServers(instanceID) + if err != nil { + return nil, err + } + + visible := make([]models.Server, 0, len(all)) + for _, s := range all { + if ServerInTokenScope(s, tokenScope) { + visible = append(visible, s) + } + } + + matched := UnionTargets(visible, ids, sel) + if len(matched) == 0 { + return nil, ErrNoTargets + } + return matched, nil +} +``` + +In `internal/services/servers.go`, below `GetServer`: + +```go +// GetServerScoped is GetServer narrowed by the acting credential's tag +// restriction. An out-of-scope server reads as not-found, never as forbidden: +// a restricted token must not be able to enumerate the fleet it cannot see by +// noticing which IDs answer differently. +func GetServerScoped(instanceID, serverID string, tokenScope map[string]string) (*models.Server, error) { + srv, err := GetServer(instanceID, serverID) + if err != nil { + return nil, err + } + if !ServerInTokenScope(*srv, tokenScope) { + return nil, ErrServerNotFound + } + return srv, nil +} +``` + +Use whatever not-found error `GetServer` already returns — read it first and reuse that identifier rather than introducing a second one. + +- [ ] **Step 4: Point the handlers at the scoped versions** + +Find every handler calling `services.GetServer`, `services.ListServers` or `services.ResolveTargets`: + +```bash +grep -rn "services.GetServer(\|services.ListServers(\|services.ResolveTargets(" internal/api/ +``` + +For each: +- `GetServer` becomes `GetServerScoped(..., auth.ServerScope(c))`. +- `ListServers` becomes `ListServersFiltered(instanceID, sel)` where `sel, ok := services.IntersectSelectors(auth.ServerScope(c), requestSelector)`; when `ok` is false, return an empty list rather than an error. +- `ResolveTargets` becomes `ResolveTargetsScoped(..., auth.ServerScope(c))`. + +- [ ] **Step 5: Add the completeness assertion** + +Create `internal/api/serverscope.go`: + +```go +package api + +import "fmt" + +// serverScopedRoutes declares, for every route that returns or acts on +// server-derived data, whether it honours the acting token's tag restriction. +// +// It is a maintained map for the same reason routeScopes is: a route added +// tomorrow that reads server data without filtering would leak a restricted +// token's blind spot silently, and this turns that into a failure at boot. +// +// false means "deliberately fleet-wide" and requires a comment saying why. +var serverScopedRoutes = map[string]bool{ + "GET /api/servers": true, + "GET /api/servers/:id": true, + "DELETE /api/servers/:id": true, + "POST /api/servers/:id/apply-updates": true, + "POST /api/servers/:id/update-agent": true, + "PUT /api/servers/:id/tags": true, + "POST /api/servers/:id/generate-key": true, + "POST /api/console/connect": true, + "GET /api/console/tunnel": true, + "POST /api/workflows/:id/run": true, + // Creating a server has no server to filter yet. + "POST /api/servers": false, + // The agent's own enrolment routes authenticate as the agent, not as a + // user token, so no session selector exists to apply. + "GET /api/servers/new": false, + "POST /api/servers/new": false, +} + +// AssertServerScopeMapComplete refuses to boot when a route touching server +// data is missing from serverScopedRoutes. +func AssertServerScopeMapComplete(routes []string) error { + for _, r := range routes { + if _, ok := serverScopedRoutes[r]; !ok { + if _, guarded := routeScopes[r]; !guarded { + continue + } + return fmt.Errorf("route %q is not declared in serverScopedRoutes", r) + } + } + return nil +} +``` + +Read `AssertScopeMapComplete` in `internal/api/scopes.go` first and mirror how it obtains the route list and how it is called at boot; call the new assertion immediately after it, and restrict the routes it checks to those whose path contains `server`, `console` or `workflows/:id/run` so unrelated routes are not swept in. Adjust the map above to the real route list the grep produces — the entries here are what the current `routeScopes` shows, and any route that exists but is absent must be added with a decision, not omitted. + +- [ ] **Step 6: Verify boot and tests** + +Run: `go build ./... && go test ./...` +Expected: PASS. If the assertion fires, that is the feature working — add the missing route to the map with a true/false decision. + +- [ ] **Step 7: Commit** + +```bash +git add internal/services/targets.go internal/services/servers.go internal/services/tokenscope_test.go internal/api/ +git commit -m "feat: enforce token tag restrictions at the server resolution chokepoints" +``` + +--- + +### Task 6: The tool registry and its gate logic + +**Files:** +- Create: `vantage-app/server/internal/mcp/registry.go` +- Test: `vantage-app/server/internal/mcp/registry_test.go` + +**Interfaces:** +- Consumes: `services.ScopeSatisfied`, `services.ScopeResources`. +- Produces: + - `mcp.Tool` struct with fields `Name string`, `Description string`, `Scope string`, `Write bool`, `Handler ToolFunc` + - `mcp.Caller` struct with fields `InstanceID string`, `Scopes []string`, `TokenScope map[string]string`, `TokenName string` + - `mcp.Registry` with `Register(Tool)`, `Visible(Caller) []Tool`, `Lookup(name string) (Tool, bool)` + - `mcp.Allowed(t Tool, c Caller) (bool, string)` — returns whether the call may proceed and, when not, the gate that refused + - Consumed by Tasks 7, 8, 9, 10. + +- [ ] **Step 1: Write the failing test** + +Create `vantage-app/server/internal/mcp/registry_test.go`: + +```go +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) + } + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/mcp/ -v` +Expected: FAIL to build — the package does not exist. + +- [ ] **Step 3: Write the registry** + +Create `vantage-app/server/internal/mcp/registry.go`: + +```go +// 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 } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/mcp/ -v` +Expected: PASS (6 tests). `TestEveryRegisteredToolDeclaresAKnownScope` passes trivially over an empty registry now and becomes meaningful in Tasks 9 and 10. + +- [ ] **Step 5: Commit** + +```bash +git add internal/mcp/ +git commit -m "feat: add the mcp tool registry and its scope gates" +``` + +--- + +### Task 7: Audit and the fan-out guard + +**Files:** +- Create: `vantage-app/server/internal/mcp/audit.go` +- Test: `vantage-app/server/internal/mcp/audit_test.go` + +**Interfaces:** +- Consumes: `services.LogEvent`, `mcp.Caller`, `mcp.Tool`. +- Produces: + - `mcp.LogCall(c Caller, t Tool, args map[string]any, servers int)` + - `mcp.LogDenied(c Caller, toolName, gate string)` + - `mcp.SummariseArgs(args map[string]any) string` + - `mcp.ErrConfirmRequired`, `mcp.CheckFanOut(count int, args map[string]any) error` + - Consumed by Tasks 8 and 10. + +- [ ] **Step 1: Write the failing test** + +Create `vantage-app/server/internal/mcp/audit_test.go`: + +```go +package mcp + +import ( + "strings" + "testing" +) + +// Arguments can carry arbitrary model output and the audit log is read by +// humans in a UI, so they are summarised rather than dumped. +func TestSummariseArgsIsBoundedAndOrdered(t *testing.T) { + got := SummariseArgs(map[string]any{ + "workflow_id": "wf-1", + "note": strings.Repeat("x", 500), + }) + + if len(got) > 200 { + t.Errorf("summary is %d chars, want at most 200", len(got)) + } + if !strings.Contains(got, "workflow_id") { + t.Errorf("summary %q omits an argument name", got) + } + // Deterministic ordering, or two identical calls produce different audit + // rows and nothing can be compared. + if SummariseArgs(map[string]any{"b": 1, "a": 2}) != SummariseArgs(map[string]any{"a": 2, "b": 1}) { + t.Error("SummariseArgs is not deterministic") + } +} + +func TestCheckFanOutRequiresConfirmation(t *testing.T) { + if err := CheckFanOut(5, nil); err != nil { + t.Errorf("CheckFanOut(5) = %v, want nil", err) + } + + err := CheckFanOut(200, nil) + if err == nil { + t.Fatal("CheckFanOut(200) = nil, want a refusal") + } + if !strings.Contains(err.Error(), "200") { + t.Errorf("refusal %q does not say how many servers", err) + } + + if err := CheckFanOut(200, map[string]any{"confirm": true}); err != nil { + t.Errorf("CheckFanOut(200, confirm) = %v, want nil", err) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/mcp/ -run 'Summarise|FanOut' -v` +Expected: FAIL to build — `SummariseArgs` and `CheckFanOut` are undefined. + +- [ ] **Step 3: Write the implementation** + +Create `vantage-app/server/internal/mcp/audit.go`: + +```go +package mcp + +import ( + "errors" + "fmt" + "sort" + "strings" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" +) + +// FanOutLimit is how many servers a write tool may touch before it demands +// explicit confirmation. Cheap insurance against a mis-parsed selector reaching +// the whole fleet on one badly phrased instruction. +const FanOutLimit = 25 + +// ErrConfirmRequired is returned to the model as a tool error it can act on: +// it says what would have happened and how to proceed deliberately. +var ErrConfirmRequired = errors.New("confirmation required") + +// CheckFanOut refuses a write that would touch more servers than FanOutLimit +// unless the call passed confirm:true. +func CheckFanOut(count int, args map[string]any) error { + if count <= FanOutLimit { + return nil + } + if confirm, ok := args["confirm"].(bool); ok && confirm { + return nil + } + return fmt.Errorf("%w: this would affect %d servers, above the limit of %d; "+ + "call again with confirm:true if that is intended", + ErrConfirmRequired, count, FanOutLimit) +} + +// SummariseArgs renders an argument object as a short, deterministic, +// bounded string for the audit log. Values are described rather than +// reproduced: an argument may carry arbitrary text a model generated. +func SummariseArgs(args map[string]any) string { + if len(args) == 0 { + return "no arguments" + } + + keys := make([]string, 0, len(args)) + for k := range args { + keys = append(keys, k) + } + sort.Strings(keys) + + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, k+"="+summariseValue(args[k])) + } + + out := strings.Join(parts, " ") + if len(out) > 200 { + out = out[:197] + "..." + } + return out +} + +func summariseValue(v any) string { + switch t := v.(type) { + case string: + if len(t) > 40 { + return fmt.Sprintf("<%d chars>", len(t)) + } + return t + case bool, float64, int: + return fmt.Sprint(t) + case []any: + return fmt.Sprintf("<%d items>", len(t)) + case map[string]any: + return fmt.Sprintf("<%d fields>", len(t)) + default: + return "" + } +} + +// LogCall records a successful tool call. Reads are recorded as well as writes: +// the point of an agent-facing surface is being able to reconstruct afterwards +// what the agent looked at, not only what it changed. +func LogCall(c Caller, t Tool, args map[string]any, servers int) { + detail := fmt.Sprintf("tool %s (%s)", t.Name, SummariseArgs(args)) + if servers > 0 { + detail += fmt.Sprintf(", %d server(s) affected", servers) + } + services.LogEvent(c.InstanceID, "mcp.tool_call", c.TokenName, "", "", detail) +} + +// LogDenied records a refusal and which gate refused, which is what turns "the +// agent said it could not" into a diagnosable event. +func LogDenied(c Caller, toolName, gate string) { + services.LogEvent(c.InstanceID, "mcp.tool_denied", c.TokenName, "", "", + fmt.Sprintf("tool %s refused by %s", toolName, gate)) +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/mcp/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/mcp/audit.go internal/mcp/audit_test.go +git commit -m "feat: audit mcp tool calls and guard against fleet-wide fan-out" +``` + +--- + +### Task 8: Transport, mount and licence gate + +**Files:** +- Create: `vantage-app/server/internal/mcp/transport.go` +- Modify: `vantage-app/server/internal/api/handlers.go` (route registration) +- Modify: `vantage-app/server/internal/api/scopes.go:27-...` (`routeScopes`) +- Modify: `vantage-app/server/go.mod` + +**Interfaces:** +- Consumes: `mcp.All()`, `mcp.Caller`, `mcp.Allowed`, `mcp.LogCall`, `mcp.LogDenied`, `auth.*` accessors, `api.RequireFeature`, `license.FeatureMCP`. +- Produces: `mcp.Handler() gin.HandlerFunc`, mounted at `POST /api/mcp` and `GET /api/mcp`. + +- [ ] **Step 1: Add and inspect the SDK** + +```bash +cd vantage-app/server +go get github.com/modelcontextprotocol/go-sdk/mcp@latest +go doc github.com/modelcontextprotocol/go-sdk/mcp | head -60 +``` + +The SDK's exact constructor and handler names must be read from `go doc` rather than assumed. Write down the three things needed before continuing: how a server is constructed, how a tool is added with a name/description/schema, and the name of the Streamable HTTP handler type. The code below uses `mcp.NewServer`, `mcp.AddTool` and `mcp.NewStreamableHTTPHandler`; if `go doc` shows different names, use what it shows and keep the structure. + +Note the local package is also called `mcp`, so import the SDK with an alias: + +```go +sdk "github.com/modelcontextprotocol/go-sdk/mcp" +``` + +- [ ] **Step 2: Write the transport** + +Create `vantage-app/server/internal/mcp/transport.go`: + +```go +package mcp + +import ( + "context" + "errors" + "net/http" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth" + "github.com/gin-gonic/gin" + sdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// callerFromContext builds the acting credential from the session the auth +// middleware already resolved. The mcp package reads no cookie and no header of +// its own: identity is settled before a request reaches here. +func callerFromContext(c *gin.Context) Caller { + return Caller{ + InstanceID: auth.InstanceID(c), + Scopes: auth.Scopes(c), + TokenScope: auth.ServerScope(c), + TokenName: auth.TokenName(c), + } +} + +// Handler serves the MCP endpoint. It is stateless: no session resumption, each +// request self-contained, which is what lets it sit behind ordinary request +// middleware with no special casing. +func Handler() gin.HandlerFunc { + return func(c *gin.Context) { + caller := callerFromContext(c) + + // A cookie session is not an agent. MCP is a credential-shaped surface + // and browsing to it in a logged-in tab must not act as one. + if !auth.IsToken(c) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "the mcp endpoint requires an API token", + }) + return + } + + srv := sdk.NewServer(&sdk.Implementation{ + Name: "vantage", + Version: buildVersion, + }, nil) + + for _, tool := range All().Visible(caller) { + registerSDKTool(srv, tool, caller) + } + + sdk.NewStreamableHTTPHandler(func(*http.Request) *sdk.Server { + return srv + }, nil).ServeHTTP(c.Writer, c.Request) + } +} + +// registerSDKTool adapts one registered Tool onto the SDK, wrapping it in the +// gate check and the audit write. The gate is re-checked here rather than +// trusted from Visible, because listing and calling are separate requests and a +// token's scopes are re-read on each. +func registerSDKTool(srv *sdk.Server, tool Tool, caller Caller) { + sdk.AddTool(srv, &sdk.Tool{ + Name: tool.Name, + Description: tool.Description, + }, func(ctx context.Context, req *sdk.CallToolRequest, args map[string]any) (*sdk.CallToolResult, any, error) { + if ok, gate := Allowed(tool, caller); !ok { + LogDenied(caller, tool.Name, gate) + return nil, nil, toolError(gate, tool) + } + + out, err := tool.Handler(ctx, caller, args) + if err != nil { + return nil, nil, err + } + return nil, out, nil + }) +} + +// toolError explains a refusal in words the model can act on. A transport-level +// failure would be invisible to it; a tool error is something it can read and +// relay to its user. +func toolError(gate string, tool Tool) error { + switch gate { + case GateMCPScope: + if tool.Write { + return errors.New("this token does not hold mcp:write, so it cannot use tools that change anything") + } + return errors.New("this token does not hold mcp:read") + case GateResourceScope: + return errors.New("this token does not hold " + tool.Scope) + default: + return errors.New("refused") + } +} + +// buildVersion is stamped so a user with several instances connected can tell +// them apart in a client. Wire it to whatever the server already uses for its +// version string. +var buildVersion = "dev" + +// SetVersion is called once at boot from main. +func SetVersion(v string) { buildVersion = v } +``` + +Check how the server already exposes its version (grep for `Version` in `cmd/main.go` and `internal/api/health.go`) and call `mcp.SetVersion` from the same place, using the same value. + +- [ ] **Step 3: Mount the route** + +In the route registration in `internal/api/handlers.go`, alongside the other groups: + +```go + // MCP is mounted inside /api so that bearer auth, rate limiting, licence + // activity and RequireScopes all apply from where it lives rather than + // because someone remembered. The route-level scope is a floor: one route + // serves many tools, so per-tool scopes are enforced inside the handler. + mcpGroup := api.Group("/mcp", RequireFeature(license.FeatureMCP)) + mcpGroup.POST("", mcp.Handler()) + mcpGroup.GET("", mcp.Handler()) +``` + +Import `"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/mcp"` and the shared `license` package. Match the existing group registration style — check how `registerWorkflowRoutes` and the status pages group are wired and follow whichever pattern the file uses. + +- [ ] **Step 4: Declare the scopes** + +In `internal/api/scopes.go`, add to `routeScopes`: + +```go + // Both MCP routes require mcp:read as a floor. Individual tools require + // their own resource scope, and write tools additionally require mcp:write, + // enforced inside the handler because one route serves many operations. + "POST /api/mcp": "mcp:read", + "GET /api/mcp": "mcp:read", +``` + +- [ ] **Step 5: Verify boot** + +Run: `go build ./... && go test ./...` +Expected: PASS. `AssertScopeMapComplete` failing here means the route pattern in the map does not match what gin registered — print the registered routes and copy the exact pattern. + +- [ ] **Step 6: Commit** + +```bash +git add go.mod go.sum internal/mcp/transport.go internal/api/handlers.go internal/api/scopes.go +git commit -m "feat: serve the mcp endpoint behind the licence feature" +``` + +--- + +### Task 9: Read tools + +**Files:** +- Create: `vantage-app/server/internal/mcp/tools_fleet.go` +- Create: `vantage-app/server/internal/mcp/tools_health.go` +- Create: `vantage-app/server/internal/mcp/tools_work.go` +- Test: `vantage-app/server/internal/mcp/tools_test.go` + +**Interfaces:** +- Consumes: `mcp.Tool`, `mcp.Caller`, `All()`, `services.ListServersFiltered`, `services.GetServerScoped`, `services.IntersectSelectors`, plus the existing monitor, vulnerability, workflow and audit service functions. +- Produces: sixteen registered read tools, and the projection types `serverSummary` and `serverDetail` consumed by Task 10's tests. + +- [ ] **Step 1: Write the failing test** + +Create `vantage-app/server/internal/mcp/tools_test.go`: + +```go +package mcp + +import ( + "encoding/json" + "testing" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" +) + +func TestReadToolsAreRegistered(t *testing.T) { + want := []string{ + "list_servers", "get_server", "search_fleet", + "list_monitors", "get_monitor_status", "list_incidents", "get_monitor_samples", + "list_pending_updates", "list_vulnerabilities", "get_server_packages", + "list_workflows", "get_workflow", "get_run", "get_run_logs", + "list_audit_events", "list_secret_names", + } + for _, name := range want { + tool, ok := All().Lookup(name) + if !ok { + t.Errorf("tool %q is not registered", name) + continue + } + if tool.Write { + t.Errorf("tool %q is marked as a write", name) + } + } +} + +// Secret plaintext must never be reachable, at any scope. This is the one +// deliberate refusal in the read set and it is worth a test of its own. +func TestNoSecretRevealTool(t *testing.T) { + for _, tool := range All().Tools() { + if tool.Name == "reveal_secret" || tool.Name == "get_secret" { + t.Errorf("tool %q exposes secret plaintext to a model", tool.Name) + } + } +} + +// A fleet listing that costs thousands of tokens degrades every interaction +// and is otherwise invisible until someone reads a bill. +func TestServerSummaryStaysSmall(t *testing.T) { + fleet := make([]serverSummary, 30) + for i := range fleet { + fleet[i] = summariseServer(models.Server{ + ServerID: "srv-000000000000000000000000", + Hostname: "web-server-with-a-longish-name", + OSInfo: "Ubuntu 24.04.1 LTS", + Tags: map[string]string{"env": "prod", "team": "core"}, + }) + } + + out, err := json.Marshal(fleet) + if err != nil { + t.Fatal(err) + } + if len(out) > 8000 { + t.Errorf("30 servers serialise to %d bytes, want at most 8000", len(out)) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/mcp/ -run 'ReadTools|SecretReveal|ServerSummary' -v` +Expected: FAIL — the tools are not registered and `serverSummary` is undefined. + +- [ ] **Step 3: Write the fleet tools** + +Create `vantage-app/server/internal/mcp/tools_fleet.go`: + +```go +package mcp + +import ( + "context" + "fmt" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" +) + +// serverSummary is what a list returns: enough for a model to decide which +// server to ask about next, and nothing else. The full document is an order of +// magnitude larger and listing thirty of them would dominate a context window. +type serverSummary struct { + ID string `json:"id"` + Hostname string `json:"hostname"` + OS string `json:"os"` + Online bool `json:"online"` + Tags map[string]string `json:"tags,omitempty"` +} + +func summariseServer(s models.Server) serverSummary { + return serverSummary{ + ID: s.ServerID, + Hostname: s.Hostname, + OS: s.OSInfo, + Online: s.Online, + Tags: s.Tags, + } +} + +// defaultLimit and maxLimit bound every listing. A model asking for everything +// gets a page and is told the total, which is more useful than a truncated blob +// it cannot tell is truncated. +const ( + defaultLimit = 50 + maxLimit = 200 +) + +func pageLimit(args map[string]any) int { + n, ok := args["limit"].(float64) + if !ok || int(n) <= 0 { + return defaultLimit + } + if int(n) > maxLimit { + return maxLimit + } + return int(n) +} + +func stringArg(args map[string]any, key string) string { + s, _ := args[key].(string) + return s +} + +func tagArg(args map[string]any) map[string]string { + raw, ok := args["tags"].(map[string]any) + if !ok { + return nil + } + out := map[string]string{} + for k, v := range raw { + if s, ok := v.(string); ok { + out[k] = s + } + } + return out +} + +type listServersResult struct { + Servers []serverSummary `json:"servers"` + Total int `json:"total"` + Shown int `json:"shown"` +} + +func init() { + All().Register(Tool{ + Name: "list_servers", + Scope: "servers:read", + Description: "List the servers in this Vantage fleet, optionally filtered by tags. " + + "Returns a compact summary per server; use get_server for full detail on one.", + Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) { + sel, ok := services.IntersectSelectors(c.TokenScope, tagArg(args)) + if !ok { + // The requested tags and the token's restriction can never both + // hold, so the honest answer is an empty fleet. + return listServersResult{Servers: []serverSummary{}}, nil + } + + servers, err := services.ListServersFiltered(c.InstanceID, sel) + if err != nil { + return nil, fmt.Errorf("could not list servers: %w", err) + } + + limit := pageLimit(args) + out := make([]serverSummary, 0, limit) + for _, s := range servers { + if len(out) == limit { + break + } + out = append(out, summariseServer(s)) + } + return listServersResult{Servers: out, Total: len(servers), Shown: len(out)}, nil + }, + }) + + All().Register(Tool{ + Name: "get_server", + Scope: "servers:read", + Description: "Get full detail for one server by ID: OS, agent version, last seen, " + + "tags, and pending update count.", + Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) { + id := stringArg(args, "server_id") + if id == "" { + return nil, fmt.Errorf("server_id is required") + } + srv, err := services.GetServerScoped(c.InstanceID, id, c.TokenScope) + if err != nil { + return nil, fmt.Errorf("no server %q is visible to this token", id) + } + return summariseServer(*srv), nil + }, + }) +} +``` + +`models.Server` field names must be checked before writing this — read `internal/models/server.go` and use the real names for hostname, OS, online state and tags. If `Online` is derived rather than stored, derive it the same way the REST handler does. + +- [ ] **Step 4: Write the monitor tools** + +Create `vantage-app/server/internal/mcp/tools_health.go`. `list_monitors` is the second worked example, because the health tools project differently from the fleet ones — a monitor's state matters more than its configuration: + +```go +package mcp + +import ( + "context" + "fmt" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" +) + +// monitorSummary carries state and identity. A model asking "what is broken" +// needs the state and the name; the target URL, expected status, keyword, +// runner and channel list are configuration it did not ask for. +type monitorSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Group string `json:"group,omitempty"` + Type string `json:"type"` + Enabled bool `json:"enabled"` + State string `json:"state"` + Interval int `json:"interval_sec"` +} + +func summariseMonitor(m models.Monitor) monitorSummary { + return monitorSummary{ + ID: m.MonitorID, + Name: m.Name, + Group: m.Group, + Type: m.Type, + Enabled: m.Enabled, + State: string(m.State.Status), + Interval: m.IntervalSec, + } +} + +type listMonitorsResult struct { + Monitors []monitorSummary `json:"monitors"` + Total int `json:"total"` + Shown int `json:"shown"` + Down int `json:"down"` +} + +func init() { + All().Register(Tool{ + Name: "list_monitors", + Scope: "monitors:read", + Description: "List the monitors on this instance with their current state. " + + "Pass state:\"down\" to see only what is currently failing.", + Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) { + monitors, err := services.ListMonitors(c.InstanceID) + if err != nil { + return nil, fmt.Errorf("could not list monitors: %w", err) + } + + wantState := stringArg(args, "state") + limit := pageLimit(args) + + out := make([]monitorSummary, 0, limit) + down, total := 0, 0 + for _, m := range monitors { + summary := summariseMonitor(m) + if summary.State == "down" { + down++ + } + if wantState != "" && summary.State != wantState { + continue + } + total++ + if len(out) < limit { + out = append(out, summary) + } + } + return listMonitorsResult{Monitors: out, Total: total, Shown: len(out), Down: down}, nil + }, + }) +} +``` + +`models.Monitor.State` is a `MonitorState` struct — read `internal/models/monitor.go` and use its real status field and type rather than the `.Status` guessed above. + +- [ ] **Step 5: Write the remaining read tools** + +Fourteen tools remain, each built exactly like the two worked examples: a projection struct carrying only what a model needs to decide what to do next, a handler that calls one existing service function, and a description that says what the tool answers. Register the monitor ones in `tools_health.go` beside `list_monitors`, and the rest in `tools_work.go`. + +| Tool | Scope | Arguments | Service to call | Projection carries | +| --- | --- | --- | --- | --- | +| `get_monitor_status` | `monitors:read` | `monitor_id` | `GetMonitor` | name, type, state, last check time, last error | +| `list_incidents` | `monitors:read` | `monitor_id` (optional), `limit` | the incidents service the `/monitors/:id/incidents` route uses | incident ID, monitor name, started, resolved, cause | +| `get_monitor_samples` | `monitors:read` | `monitor_id`, `limit` | the samples service behind `/monitors/:id/samples` | timestamp, ok, latency — capped hard, samples are numerous | +| `list_workflows` | `workflows:read` | `limit` | `ListWorkflows` | workflow ID, name, step count, target count, whether scheduled | +| `get_workflow` | `workflows:read` | `workflow_id` | `GetWorkflow` | name, ordered step names and IDs, targets, schedule | +| `get_run` | `workflows:read` | `run_id` | the run fetch behind `/runs/:runId` | run ID, workflow name, status, started, finished, per-server status counts | +| `get_run_logs` | `workflows:read` | `run_id`, `server_id`, `limit` | the log read behind `/runs/:runId/servers/:serverId/logs` | ordered lines, capped at 200 by default | +| `list_pending_updates` | `servers:read` | `server_id` or `tags` | `GetServerScoped` plus the stored update list | per server: hostname, package name, current and new version | +| `list_vulnerabilities` | `vulns:read` | `severity`, `status`, `limit` | the findings service behind `/vulnerabilities` | CVE, severity, package, affected server count, fixed_in | +| `get_server_packages` | `vulns:read` | `server_id`, `name` (optional filter) | the packages service behind `/servers/:id/packages` | package name, version — filtered, never the whole 2000-entry set unfiltered | +| `search_fleet` | `vulns:read` | `name`, `version_below` (optional) | `services` package search in `internal/services/packages.go` | per match: hostname, package, version | +| `list_audit_events` | `settings:read` | `event_type`, `limit` | `ListAuditEvents` | timestamp, type, actor, detail | +| `list_secret_names` | `secrets:read` | none | the secrets list service | group name and key names ONLY | + +Four rules that apply to every one of them: + +- **Scope every server-derived result.** Any tool reaching a server resolves it through `services.GetServerScoped` or `services.ListServersFiltered` with the intersected selector. `list_pending_updates`, `get_server_packages` and `search_fleet` are the three where this is easy to forget, and forgetting it is the bug this whole feature guards against. +- **`get_run_logs` must respect the run's own instance.** Check the run belongs to `c.InstanceID` before returning a line of it. +- **`list_secret_names` must never call the reveal path.** Write a comment saying so at the call site — the next person to touch that file will be tempted. +- **`search_fleet` is the one tool with no REST equivalent.** It answers questions like "which hosts still run OpenSSL 1.1", and its description should say exactly that, because a model will not otherwise guess the tool exists for that purpose. + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `go test ./internal/mcp/ -v` +Expected: PASS, including `TestEveryRegisteredToolDeclaresAKnownScope` from Task 6 now covering sixteen real tools. + +- [ ] **Step 7: Commit** + +```bash +git add internal/mcp/ +git commit -m "feat: add mcp read tools for fleet, health and workflow data" +``` + +--- + +### Task 10: Write tools + +**Files:** +- Create: `vantage-app/server/internal/mcp/tools_write.go` +- Test: `vantage-app/server/internal/mcp/tools_write_test.go` + +**Interfaces:** +- Consumes: `mcp.Tool`, `mcp.CheckFanOut`, `services.ResolveTargetsScoped`, plus existing workflow-run, update and key-assignment service functions. +- Produces: five registered write tools. + +- [ ] **Step 1: Write the failing test** + +Create `vantage-app/server/internal/mcp/tools_write_test.go`: + +```go +package mcp + +import "testing" + +func TestWriteToolsAreMarkedAsWrites(t *testing.T) { + want := []string{"run_workflow", "cancel_run", "apply_updates", "update_agent", "assign_key"} + for _, name := range want { + tool, ok := All().Lookup(name) + if !ok { + t.Errorf("tool %q is not registered", name) + continue + } + if !tool.Write { + t.Errorf("tool %q is not marked as a write, so it would be listed to a read-only agent", name) + } + } +} + +// A tool description is prompt text. A model choosing between tools must be +// told which ones touch real machines. +func TestWriteToolDescriptionsStateBlastRadius(t *testing.T) { + for _, tool := range All().Tools() { + if !tool.Write { + continue + } + if len(tool.Description) < 40 { + t.Errorf("tool %q has a %d-char description; write tools must state what they affect", + tool.Name, len(tool.Description)) + } + } +} + +// A caller holding every resource scope but not mcp:write must still see no +// write tools at all. +func TestWriteToolsHiddenWithoutMCPWrite(t *testing.T) { + c := Caller{Scopes: []string{ + "mcp:read", "servers:write", "workflows:write", "keys:write", + }} + for _, tool := range All().Visible(c) { + if tool.Write { + t.Errorf("write tool %q visible without mcp:write", tool.Name) + } + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/mcp/ -run Write -v` +Expected: FAIL — none of the five tools is registered. + +- [ ] **Step 3: Write the write tools** + +Create `vantage-app/server/internal/mcp/tools_write.go`, with `run_workflow` as the model the other four follow: + +```go +package mcp + +import ( + "context" + "fmt" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" +) + +type runStartedResult struct { + RunID string `json:"run_id"` + Servers int `json:"servers"` + Note string `json:"note"` +} + +func init() { + All().Register(Tool{ + Name: "run_workflow", + Write: true, + Scope: "workflows:write", + Description: "Run a workflow against servers in this fleet. This EXECUTES COMMANDS on " + + "real machines and cannot be undone from here. Returns a run ID immediately; " + + "poll get_run for progress and get_run_logs for output. Targets are the servers " + + "named in server_ids plus any matching tags, always within this token's own limits.", + Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) { + workflowID := stringArg(args, "workflow_id") + if workflowID == "" { + return nil, fmt.Errorf("workflow_id is required") + } + + ids := stringSliceArg(args, "server_ids") + targets, err := services.ResolveTargetsScoped(c.InstanceID, ids, tagArg(args), c.TokenScope) + if err != nil { + return nil, fmt.Errorf("no servers visible to this token matched the request") + } + if err := CheckFanOut(len(targets), args); err != nil { + return nil, err + } + + run, err := services.StartWorkflowRun(c.InstanceID, workflowID, targets) + if err != nil { + return nil, fmt.Errorf("could not start the run: %w", err) + } + + LogCall(c, mustLookup("run_workflow"), args, len(targets)) + + return runStartedResult{ + RunID: run.RunID, + Servers: len(targets), + Note: "The run is in progress. Poll get_run with this run_id; do not assume it succeeded.", + }, nil + }, + }) +} + +func mustLookup(name string) Tool { + t, ok := All().Lookup(name) + if !ok { + panic("mcp: unknown tool " + name) + } + return t +} + +func stringSliceArg(args map[string]any, key string) []string { + raw, ok := args[key].([]any) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, v := range raw { + if s, ok := v.(string); ok { + out = append(out, s) + } + } + return out +} +``` + +`services.StartWorkflowRun` is a placeholder for whatever the REST run handler actually calls — read `internal/api/workflows.go` for the run route and call the same function with the same arguments. Do not reimplement any part of the run path. + +Then add the remaining four the same way: + +- **`cancel_run`** (`workflows:write`) — takes `run_id`, calls the same service the cancel route uses, and verifies the run belongs to the caller's instance. +- **`apply_updates`** (`servers:write`) — takes `server_ids` and/or `tags`, resolves through `ResolveTargetsScoped`, applies `CheckFanOut`, calls the apply-updates service. +- **`update_agent`** (`servers:write`) — same target resolution, calls the update-agent service. +- **`assign_key`** (`keys:write`) — takes `key_id` and `server_ids`, resolves targets scoped, calls the key assignment service. + +Every one calls `LogCall` with the resolved server count before returning, and every one that resolves targets calls `CheckFanOut`. + +- [ ] **Step 4: Audit the read tools too** + +The read tools written in Task 9 do not yet call `LogCall`. Rather than repeating the call in twenty places, add it once in `registerSDKTool` in `transport.go`, after a successful handler return: + +```go + out, err := tool.Handler(ctx, caller, args) + if err != nil { + return nil, nil, err + } + if !tool.Write { + // Write tools log their own call with a resolved server count, + // which this layer cannot know. + LogCall(caller, tool, args, 0) + } + return nil, out, nil +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test ./internal/mcp/ -v` +Expected: PASS. + +- [ ] **Step 6: Run the whole suite** + +Run: `go test ./...` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/mcp/ +git commit -m "feat: add mcp write tools with a fan-out guard" +``` + +--- + +### Task 11: Creation tools + +**Files:** +- Create: `vantage-app/server/internal/mcp/tools_create.go` +- Test: `vantage-app/server/internal/mcp/tools_create_test.go` + +**Interfaces:** +- Consumes: `mcp.Tool`, `mcp.Caller`, `mcp.LogCreated` (added in this task), `services.CreateStep`, `services.CreateWorkflow`, `services.CreateMonitor`, `models.WorkflowStep`, `models.Workflow`, `models.WorkflowStepRef`, `models.Monitor`. +- Produces: three registered write tools — `create_step`, `create_workflow`, `create_monitor` — and `mcp.LogCreated(c Caller, kind, id, name string)`. + +These are the tools that make the surface generative rather than only observational, and the ones most able to surprise someone. Three rules on top of the ordinary write gates, all of them tested below: nothing created is armed, no step may reference a secret, and there is no update or delete counterpart. + +- [ ] **Step 1: Write the failing test** + +Create `vantage-app/server/internal/mcp/tools_create_test.go`: + +```go +package mcp + +import ( + "strings" + "testing" +) + +func TestCreationToolsAreRegisteredAsWrites(t *testing.T) { + for _, name := range []string{"create_step", "create_workflow", "create_monitor"} { + tool, ok := All().Lookup(name) + if !ok { + t.Errorf("tool %q is not registered", name) + continue + } + if !tool.Write { + t.Errorf("tool %q is not marked as a write", name) + } + } +} + +// An agent may add a definition. It may never alter or remove one a human +// wrote, and the cheapest guard against that is the tool simply not existing. +func TestNoUpdateOrDeleteTools(t *testing.T) { + for _, tool := range All().Tools() { + n := tool.Name + if strings.HasPrefix(n, "update_") && n != "update_agent" { + t.Errorf("tool %q edits an existing definition", n) + } + if strings.HasPrefix(n, "delete_") || strings.HasPrefix(n, "remove_") { + t.Errorf("tool %q deletes a definition", n) + } + } +} + +// Composing a script around a secret reference is how a credential ends up +// echoed into a log. +func TestCreateStepRejectsSecretRefs(t *testing.T) { + step, err := buildStep(map[string]any{ + "name": "leaky", + "interpreter": "bash", + "script": "echo hello", + "secret_refs": []any{"prod/db"}, + }) + if err == nil { + t.Fatalf("buildStep accepted secret_refs, got %+v", step) + } + if !strings.Contains(err.Error(), "secret") { + t.Errorf("error %q does not explain the refusal", err) + } +} + +func TestCreateStepRequiresScriptAndInterpreter(t *testing.T) { + if _, err := buildStep(map[string]any{"name": "x", "script": "echo hi"}); err == nil { + t.Error("buildStep accepted a step with no interpreter") + } + if _, err := buildStep(map[string]any{"name": "x", "interpreter": "bash"}); err == nil { + t.Error("buildStep accepted a step with no script") + } +} + +// Steps a model wrote are badged in the UI, so a human can tell at a glance +// what came from an agent. +func TestCreatedStepIsMarkedAgentAuthored(t *testing.T) { + step, err := buildStep(map[string]any{ + "name": "patch", "interpreter": "bash", "script": "apt-get update", + }) + if err != nil { + t.Fatal(err) + } + if step.Source != "mcp" { + t.Errorf("Source = %q, want %q", step.Source, "mcp") + } +} + +// Creating and acting stay two decisions: a created workflow cannot arrive +// already scheduled. +func TestCreateWorkflowRefusesASchedule(t *testing.T) { + _, err := buildWorkflow(map[string]any{ + "name": "nightly", + "step_ids": []any{"step-1"}, + "schedule": map[string]any{"cron": "0 3 * * *"}, + }) + if err == nil { + t.Fatal("buildWorkflow accepted a schedule") + } + if !strings.Contains(err.Error(), "schedule") { + t.Errorf("error %q does not explain the refusal", err) + } +} + +func TestCreateWorkflowRequiresSteps(t *testing.T) { + if _, err := buildWorkflow(map[string]any{"name": "empty"}); err == nil { + t.Error("buildWorkflow accepted a workflow with no steps") + } +} + +// Step order is the whole meaning of a workflow, so it comes from the array +// order rather than from a field a model has to get right. +func TestBuildWorkflowNumbersStepsInOrder(t *testing.T) { + wf, err := buildWorkflow(map[string]any{ + "name": "three", + "step_ids": []any{"a", "b", "c"}, + }) + if err != nil { + t.Fatal(err) + } + if len(wf.Steps) != 3 { + t.Fatalf("got %d steps, want 3", len(wf.Steps)) + } + for i, ref := range wf.Steps { + if ref.Order != i { + t.Errorf("step %d has Order %d", i, ref.Order) + } + } + if wf.Steps[0].StepID != "a" || wf.Steps[2].StepID != "c" { + t.Errorf("step order does not follow the argument order: %+v", wf.Steps) + } +} + +// A monitor that starts enabled would begin alerting real people the moment a +// model invented it. +func TestCreatedMonitorIsDisabled(t *testing.T) { + m, err := buildMonitor(map[string]any{ + "name": "api health", "type": "http", "target": map[string]any{"url": "https://example.com"}, + }) + if err != nil { + t.Fatal(err) + } + if m.Enabled { + t.Error("created monitor is enabled; it must wait for a human") + } +} + +func TestCreateMonitorRequiresNameAndType(t *testing.T) { + if _, err := buildMonitor(map[string]any{"type": "http"}); err == nil { + t.Error("buildMonitor accepted a monitor with no name") + } + if _, err := buildMonitor(map[string]any{"name": "x"}); err == nil { + t.Error("buildMonitor accepted a monitor with no type") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/mcp/ -run 'Creation|Create|BuildWorkflow|NoUpdate' -v` +Expected: FAIL to build — `buildStep`, `buildWorkflow` and `buildMonitor` are undefined. + +- [ ] **Step 3: Write the builders and the tools** + +The builders are pure functions from an argument map to a model, which is what lets every rule above be tested without a database. Create `vantage-app/server/internal/mcp/tools_create.go`: + +```go +package mcp + +import ( + "context" + "fmt" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" +) + +// SourceMCP marks a definition an agent wrote. models.WorkflowStep already +// carries a Source field for exactly this kind of provenance, so the UI can +// badge agent-authored steps without a schema change. +const SourceMCP = "mcp" + +// buildStep validates the arguments and returns the step to create. +// +// It is pure so that every refusal below is testable without a database, and +// separate from the handler so the handler is only plumbing. +func buildStep(args map[string]any) (models.WorkflowStep, error) { + name := stringArg(args, "name") + interpreter := stringArg(args, "interpreter") + script := stringArg(args, "script") + + if name == "" { + return models.WorkflowStep{}, fmt.Errorf("name is required") + } + if interpreter == "" { + return models.WorkflowStep{}, fmt.Errorf("interpreter is required, for example bash or powershell") + } + if script == "" { + return models.WorkflowStep{}, fmt.Errorf("script is required") + } + if len(stringSliceArg(args, "secret_refs")) > 0 { + return models.WorkflowStep{}, fmt.Errorf( + "a step created through MCP cannot reference secrets; " + + "add the secret reference in the Vantage UI after reviewing the script") + } + + return models.WorkflowStep{ + Name: name, + Description: stringArg(args, "description"), + Interpreter: interpreter, + Script: script, + Source: SourceMCP, + }, nil +} + +// buildWorkflow validates the arguments and returns the workflow to create. +func buildWorkflow(args map[string]any) (models.Workflow, error) { + name := stringArg(args, "name") + if name == "" { + return models.Workflow{}, fmt.Errorf("name is required") + } + if _, scheduled := args["schedule"]; scheduled { + return models.Workflow{}, fmt.Errorf( + "a workflow created through MCP cannot be scheduled; " + + "create it, review it, then set a schedule in the Vantage UI") + } + + stepIDs := stringSliceArg(args, "step_ids") + if len(stepIDs) == 0 { + return models.Workflow{}, fmt.Errorf("step_ids must name at least one existing step; create steps first with create_step") + } + + // Order comes from the array order rather than from a field, because step + // order is the whole meaning of a workflow and is not worth asking a model + // to restate correctly. + steps := make([]models.WorkflowStepRef, 0, len(stepIDs)) + for i, id := range stepIDs { + steps = append(steps, models.WorkflowStepRef{ + StepID: id, + Order: i, + OnFailure: "stop", + }) + } + + return models.Workflow{ + Name: name, + TargetServerIDs: stringSliceArg(args, "server_ids"), + TargetTags: tagArg(args), + Steps: steps, + }, nil +} + +// buildMonitor validates the arguments and returns the monitor to create. +func buildMonitor(args map[string]any) (models.Monitor, error) { + name := stringArg(args, "name") + monitorType := stringArg(args, "type") + if name == "" { + return models.Monitor{}, fmt.Errorf("name is required") + } + if monitorType == "" { + return models.Monitor{}, fmt.Errorf("type is required") + } + + interval := 60 + if n, ok := args["interval_sec"].(float64); ok && int(n) > 0 { + interval = int(n) + } + + return models.Monitor{ + Name: name, + Group: stringArg(args, "group"), + Type: monitorType, + IntervalSec: interval, + // Never armed on creation. A monitor that started enabled would begin + // alerting real people the moment a model invented it, and creating + // must stay a separate decision from acting. + Enabled: false, + }, nil +} + +func init() { + All().Register(Tool{ + Name: "create_step", + Write: true, + Scope: "workflows:write", + Description: "Create a reusable workflow step: a named script with an interpreter. " + + "The step is SAVED to this Vantage instance but is not run by creating it — " + + "add it to a workflow with create_workflow, then run that with run_workflow. " + + "Steps created this way cannot reference secrets.", + Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) { + step, err := buildStep(args) + if err != nil { + return nil, err + } + created, err := services.CreateStep(c.InstanceID, step) + if err != nil { + return nil, fmt.Errorf("could not create the step: %w", err) + } + LogCreated(c, "step", created.StepID, created.Name) + return map[string]any{ + "step_id": created.StepID, + "name": created.Name, + "note": "Saved but not run. Reference this step_id from create_workflow.", + }, nil + }, + }) + + All().Register(Tool{ + Name: "create_workflow", + Write: true, + Scope: "workflows:write", + Description: "Create a workflow from existing step IDs, in the order given, targeting " + + "servers by ID or by tags. The workflow is SAVED but not run and cannot be " + + "created with a schedule; run it explicitly with run_workflow.", + Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) { + wf, err := buildWorkflow(args) + if err != nil { + return nil, err + } + + // Targets are stored, not resolved, so a workflow cannot be saved + // pointing at servers this token cannot reach. Resolution at run + // time would scope it to whoever runs it, which is later and less + // obvious than refusing here. + if len(wf.TargetServerIDs) > 0 || len(wf.TargetTags) > 0 { + targets, err := services.ResolveTargetsScoped( + c.InstanceID, wf.TargetServerIDs, wf.TargetTags, c.TokenScope) + if err != nil { + return nil, fmt.Errorf("no servers visible to this token matched the requested targets") + } + if err := CheckFanOut(len(targets), args); err != nil { + return nil, err + } + } + + created, err := services.CreateWorkflow(c.InstanceID, wf) + if err != nil { + return nil, fmt.Errorf("could not create the workflow: %w", err) + } + LogCreated(c, "workflow", created.WorkflowID, created.Name) + return map[string]any{ + "workflow_id": created.WorkflowID, + "name": created.Name, + "steps": len(created.Steps), + "note": "Saved but not run and not scheduled. Call run_workflow to run it.", + }, nil + }, + }) + + All().Register(Tool{ + Name: "create_monitor", + Write: true, + Scope: "monitors:write", + Description: "Create a monitor. It is SAVED DISABLED and will not check anything or " + + "send any alert until a human enables it in the Vantage UI, so proposing a " + + "monitor is safe.", + Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) { + m, err := buildMonitor(args) + if err != nil { + return nil, err + } + created, err := services.CreateMonitor(c.InstanceID, &m) + if err != nil { + return nil, fmt.Errorf("could not create the monitor: %w", err) + } + LogCreated(c, "monitor", created.MonitorID, created.Name) + return map[string]any{ + "monitor_id": created.MonitorID, + "name": created.Name, + "enabled": false, + "note": "Created disabled. Enable it in Vantage to start checking.", + }, nil + }, + }) +} +``` + +Three things must be checked against the real code before this compiles, rather than assumed: + +- `services.CreateStep` takes `models.WorkflowStep` by value and `services.CreateMonitor` takes `*models.Monitor` — confirmed, but check their return signatures and any validation errors worth surfacing verbatim to the model. +- `models.Monitor.Target` is a `MonitorTarget` struct, not a map. Read `internal/models/monitor.go`, and decode the `target` argument into it properly — the test above only asserts that a target is required, so extend `buildMonitor` and its test together once the real shape is in front of you. +- `OnFailure: "stop"` must match whatever value the existing step-ref validation accepts. Read the workflow create route and use its vocabulary. + +- [ ] **Step 4: Add the creation audit event** + +In `internal/mcp/audit.go`, beside `LogCall` and `LogDenied`: + +```go +// LogCreated records a definition an agent added. +// +// It is a distinct event type rather than another mcp.tool_call row because of +// the question a human will actually ask, which is "what has this agent added +// to my instance" — an answer buried among hundreds of read rows is not an +// answer. +func LogCreated(c Caller, kind, id, name string) { + services.LogEvent(c.InstanceID, "mcp.created", c.TokenName, "", "", + fmt.Sprintf("created %s %q (%s)", kind, name, id)) +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test ./internal/mcp/ -v` +Expected: PASS, including the Task 6 registry test now covering all twenty-four tools. + +- [ ] **Step 6: Run the whole suite** + +Run: `go test ./...` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/mcp/ +git commit -m "feat: let an agent create steps, workflows and monitors, inert until a human arms them" +``` + +--- + +### Task 12: Token form and agent access panel + +**Files:** +- Modify: `vantage-app/web/app/(app)/settings/` — the API tokens page and its token creation form +- Modify: `vantage-app/web/app/(app)/settings/license/page.tsx:255-270` +- Modify: `vantage-app/web/lib/` — the API client's token creation call + +**Interfaces:** +- Consumes: the `tag_selector` field on the token create request (Task 4), `license.features.mcp` from the existing licence response. + +- [ ] **Step 1: Add the licence page row** + +In `vantage-app/web/app/(app)/settings/license/page.tsx`, beside the existing feature rows: + +```tsx + +``` + +`licenceResponse.Features` is already a `map[string]bool` built from the licence, so no server change is needed for this to populate. + +- [ ] **Step 2: Add the tag restriction field to the token form** + +Locate the token creation form (grep the settings directory for the scope checkbox list). Add a tag restriction control below the scopes, shown for **every** token regardless of licence — tag scoping is not gated. + +It offers the tag keys and values already in use across servers, which the fleet already exposes via `GET /api/servers/tags`. The workflow target selector already consumes that endpoint; reuse its component if one exists rather than building a second tag picker. + +The field sends `tag_selector` as an object of key/value strings, omitted or `{}` when unrestricted. + +- [ ] **Step 3: Add the MCP scopes and gate them** + +`mcp:read` and `mcp:write` arrive automatically in the scope list from `GET /api/tokens/scopes`, so no hardcoding is needed. Hide or disable those two entries when `license.features.mcp` is false, matching how console-gated UI is handled elsewhere. + +- [ ] **Step 4: Show the restriction in the token list** + +In the token list, render a token's `tag_selector` as a chip beside its scopes, so "what can this credential reach" is answerable at a glance. An unrestricted token shows nothing rather than an empty chip. + +- [ ] **Step 5: Add the agent access panel** + +On the API tokens settings page, add an **Agent access** panel visible only when `license.features.mcp` is true, containing: + +- The endpoint URL for this instance — `${window.location.origin}/api/mcp` — with a copy button. +- A copyable client configuration snippet: + +```json +{ + "mcpServers": { + "vantage": { + "type": "http", + "url": "https://YOUR-INSTANCE/api/mcp", + "headers": { "Authorization": "Bearer vt_your_token_here" } + } + } +} +``` + +- A line explaining that the token needs `mcp:read`, plus `mcp:write` for tools that change anything, and a link to the docs page from Task 15. + +- [ ] **Step 6: Verify in a browser** + +Run the app and check: the licence page shows the new row; the token form shows the tag field for everyone and the MCP scopes only when licensed; a created restricted token shows its chip; the agent access panel appears only when licensed. + +- [ ] **Step 7: Commit** + +```bash +git add web/ +git commit -m "feat: expose mcp connection details and token tag restrictions in the UI" +``` + +--- + +### Task 13: HQ catalogue row and feature label + +**Files:** +- Modify: `vantage-admin/server/internal/models/catalogue.go:119-143` (`seedRows`) +- Modify: `vantage-admin/web/lib/features.ts:10-22` + +**Interfaces:** +- Consumes: `license.FeatureMCP` (Task 1). + +- [ ] **Step 1: Add the catalogue row** + +In `vantage-admin/server/internal/models/catalogue.go`, add to the shared feature loop in `seedRows`: + +```go + for _, f := range []string{ + license.FeatureConsole, + license.FeatureOIDC, + license.FeatureVulnScanning, + license.FeatureStatusPages, + license.FeatureMCP, + } { +``` + +- [ ] **Step 2: Update the row count comment** + +The comment above `seedRows` says "Nine rows, down from twenty-four" and explicitly asks the next person to keep the number deliberate. It is now ten. Update the sentence and the `SeedCatalogue` doc comment below it, which says "the nine rows the four paid plans need". + +- [ ] **Step 3: Add the HQ label** + +In `vantage-admin/web/lib/features.ts`, add to both maps: + +```ts + mcp: "Agent access (MCP)", +``` + +```ts + mcp: "Let AI agents query and act on your fleet through the Model Context Protocol, under a scoped token you control", +``` + +This makes it appear in the purchase form, the staff pricing page and the account detail view, all of which render from that map. + +- [ ] **Step 4: Verify** + +Run from `vantage-admin/server`: `go build ./... && go test ./...` +Expected: PASS. + +Start the admin service against a development database and confirm the new row appears empty on the staff pricing page, with no price IDs. + +- [ ] **Step 5: Commit** + +```bash +cd vantage-admin +git add server/internal/models/catalogue.go web/lib/features.ts +git commit -m "feat: sell agent access (mcp) as a shared catalogue add-on" +``` + +--- + +### Task 14: Paddle sandbox product and prices + +**Files:** +- None in git. This task writes to the Paddle sandbox account and to the catalogue row through the HQ staff UI. + +**Interfaces:** +- Consumes: the catalogue row from Task 13. +- Produces: two sandbox price IDs recorded in `price_ids.sandbox` on the MCP catalogue row. + +- [ ] **Step 1: Confirm the payload before creating anything** + +Show the user the exact product payload and wait for a yes. This writes to a real billing account, even a sandbox one: + +```json +{ + "name": "Vantage — Agent Access (MCP)", + "description": "AI agent access to a Vantage instance over the Model Context Protocol", + "tax_category": "standard" +} +``` + +- [ ] **Step 2: Create the product** + +Use the connected `paddle-sandbox` MCP server. Record the returned `pro_…` product ID. + +- [ ] **Step 3: Confirm and create the monthly price** + +```json +{ + "product_id": "pro_… from step 2", + "description": "Agent Access (MCP) — monthly", + "unit_price": { "amount": "900", "currency_code": "GBP" }, + "billing_cycle": { "interval": "month", "frequency": 1 } +} +``` + +Amounts are in minor units: `"900"` is £9.00. Record the returned `pri_…`. + +- [ ] **Step 4: Confirm and create the annual price** + +```json +{ + "product_id": "pro_… from step 2", + "description": "Agent Access (MCP) — annual", + "unit_price": { "amount": "9000", "currency_code": "GBP" }, + "billing_cycle": { "interval": "year", "frequency": 1 } +} +``` + +`"9000"` is £90.00 — ten months' money for twelve, matching the convention the other add-on rows use. Record the returned `pri_…`. + +- [ ] **Step 5: Record the price IDs through the staff UI** + +Open the HQ staff pricing page, find the Agent access (MCP) row, and paste the two sandbox price IDs into the monthly and annual fields for the sandbox environment. + +Do this through the UI and not with a script or a migration: that page is the only place price IDs are meant to be entered, and a second writer would be a second source of truth for the one thing in this system that moves money. + +- [ ] **Step 6: Verify a checkout builds** + +In HQ, build a checkout for a Professional plan with the MCP feature selected and confirm `catalogue.LineItems` produces three items: the base row, any server overage, and the MCP feature at quantity 1. + +- [ ] **Step 7: Record what was created** + +Post the product ID and both price IDs in the session so they are recoverable, and note that production prices are still outstanding — they are created by hand in the Paddle dashboard at ship time and pasted into `price_ids.production` the same way. + +--- + +### Task 15: Documentation + +**Files:** +- Create: `vantage-docs/docs/vantage/mcp.md` +- Modify: `vantage-docs/docs/reference/api-tokens.md` +- Modify: `vantage-docs/sidebars.ts` + +- [ ] **Step 1: Write the MCP page** + +Create `vantage-docs/docs/vantage/mcp.md` with front matter matching the sibling pages in that directory (`id`, `title`, `sidebar_label`), covering: + +- What MCP is, in two sentences, and what connecting Vantage to an agent gets you. +- Minting a suitable token: which scopes, what `mcp:read` versus `mcp:write` means, and that write tools are invisible without `mcp:write`. +- Restricting a token by tag, with a worked example of a staging-only agent token. +- Connecting Claude and other clients, with the JSON configuration block from Task 11. +- The full tool list in a table: name, what it does, scope required. +- A short "what an agent can create" section: steps, workflows and monitors, + and that nothing it creates is armed — a created workflow has no schedule, a + created monitor is disabled, and neither runs or alerts until a human says so. + Note that agent-authored steps are badged in the UI and that an agent can + never edit or delete an existing definition. +- **What an agent cannot do** — reveal secret plaintext, open a console or shell, exceed its tag restriction, act at all without `mcp:write`, or touch more than 25 servers without explicit confirmation. This section is the reason a cautious reader will turn the feature on, so give it real prominence rather than a footnote. +- That every tool call is recorded in the audit log, reads included. + +- [ ] **Step 2: Document the tag restriction** + +In `vantage-docs/docs/reference/api-tokens.md`, add the tag restriction field: what it does, that it is immutable, that it applies to every API token and not only MCP ones, and that a token cannot be created reaching further than its creator. + +- [ ] **Step 3: Add it to the sidebar** + +Add the page to `vantage-docs/sidebars.ts` in the Vantage section, next to `browser-console`. + +- [ ] **Step 4: Verify the site builds** + +Run from `vantage-docs`: `npm run build` +Expected: build succeeds with no broken-link warnings for the new page. + +- [ ] **Step 5: Commit** + +```bash +cd vantage-docs +git add docs/vantage/mcp.md docs/reference/api-tokens.md sidebars.ts +git commit -m "docs: document the mcp server and token tag restrictions" +``` + +--- + +## Verification + +Before calling this done, from `vantage-app/server`: + +- [ ] `go build ./... && go test ./...` passes. +- [ ] The server boots — both completeness assertions pass, which is the real check that no route was missed. +- [ ] A token with only `servers:read` gets 403 at `/api/mcp`. +- [ ] A token with `mcp:read` lists read tools and no write tools. +- [ ] A token with `mcp:write` lists both. +- [ ] With the licence feature absent, `/api/mcp` answers `feature_unavailable` and the token form refuses to mint a token with MCP scopes. +- [ ] A token restricted to `env=staging` sees only staging hosts from `list_servers`, gets not-found for a prod host by ID, and running a workflow naming a prod host targets nothing. +- [ ] A created workflow arrives with no schedule and a created monitor arrives disabled. +- [ ] `create_step` refuses a step declaring `secret_refs`. +- [ ] The audit log shows an `mcp.created` row per created definition, and `mcp.tool_call` rows for reads as well as writes, and an `mcp.tool_denied` row naming the gate after a refused call. +- [ ] Connect a real MCP client end to end and ask it a fleet question. diff --git a/docs/superpowers/specs/2026-09-08-mcp-server-design.md b/docs/superpowers/specs/2026-09-08-mcp-server-design.md index a8e9c4a..0089d82 100644 --- a/docs/superpowers/specs/2026-09-08-mcp-server-design.md +++ b/docs/superpowers/specs/2026-09-08-mcp-server-design.md @@ -30,8 +30,13 @@ Out of scope, deliberately: - **An approval queue.** Writes are gated by an explicit scope, not by a human-in-the-loop workflow. A pending-action subsystem is a real feature and would roughly double this one. -- **Agent-authored workflows.** Generating and saving a workflow from natural - language is compelling and separable. Not here. +- **Editing or deleting existing workflows, steps and monitors.** Creation is + in scope; changing or removing something a human already made is not. An agent + that can only add leaves every existing definition intact, and an unwanted new + one is deleted in a click. +- **Secret-referencing steps.** A created step may not declare `secret_refs`. + Composing a script around a secret reference is how a credential ends up + echoed into a log, and the human step editor already does this safely. ## Current state @@ -224,6 +229,9 @@ write. | `apply_updates` | `servers:write` | yes | | `update_agent` | `servers:write` | yes | | `assign_key` | `keys:write` | yes | +| `create_step` | `workflows:write` | yes | +| `create_workflow` | `workflows:write` | yes | +| `create_monitor` | `monitors:write` | yes | Rules every tool follows: @@ -242,12 +250,41 @@ Rules every tool follows: many it would have touched. Cheap insurance against a mis-parsed selector reaching the whole fleet. +### Creation tools + +`create_step`, `create_workflow` and `create_monitor` let an agent build the +thing it is about to propose, rather than describing a script in prose that a +human then retypes. They are the tools that make the surface generative instead +of merely observational, and they are also the ones most able to surprise +someone, so they carry extra rules on top of the ordinary write gates: + +- **Creation only.** No update and no delete tool exists. An agent may add a + definition; it may never alter or remove one a human wrote. +- **Nothing is armed on creation.** `create_workflow` refuses a `schedule`, and + `create_monitor` sets `enabled` false. A created definition sits inert until a + human enables it, so creating and acting stay two decisions. An agent that + wants to run what it just made calls `run_workflow`, which is separately + gated, separately audited, and subject to the fan-out guard. +- **No secret references.** `create_step` rejects a non-empty `secret_refs`. +- **Marked as agent-authored.** `models.WorkflowStep` already carries a `Source` + field; created steps set it to `mcp`, so the UI can badge them and a human can + tell at a glance what a model wrote. Workflows and monitors get the same + treatment through their audit event rather than a new field. +- **Script validation.** `create_step` runs the same parse and scan the existing + step-create route runs (`services.CreateStep` already does this) — an agent + gets no laxer a path than the UI. + ## Audit Every tool call writes an audit event through `services.LogEvent`, reads included. The point of an agent-facing surface is being able to reconstruct afterwards what the agent looked at, not only what it changed. +Creation tools log a distinct event type, `mcp.created`, naming what was made +and its ID. A generic tool-call row buried among reads is not enough for the +question a human will actually ask, which is "what has this agent added to my +instance". + Event type `mcp.tool_call`; actor is the token name, as REST token actions already record; detail is the tool name, a compact argument summary, and the number of servers affected. Failures record `mcp.tool_denied` with the gate that