Files
pad/internal/mcp/tool_surface_test.go
T
xarmian 727cd80927 fix(mcp): explicit tool annotations from catalog write-shape knowledge (BUG-2302) (#1121)
mcp-go's NewTool injects default annotations on every tool —
ReadOnlyHint:false, DestructiveHint:true, OpenWorldHint:true — and
buildToolFromDef never overrode them, so every Pad tool advertised
itself as destructive, including pure reads like pad_search and
pad_project. Hosts use destructiveHint to decide whether to prompt;
mislabeling reads trains users to click through prompts.

Derive the block in buildToolFromDef from the catalog's own knowledge
(readOnlyActions — the same single source the tool-surface serializer
uses — plus a new sibling additiveWriteActions allowlist):

- every action read-only → ReadOnlyHint:true, DestructiveHint:false,
  IdempotentHint:true (pad_search, pad_project, pad_attachment,
  pad_meta, pad_playbook);
- writes all purely ADDITIVE → ReadOnlyHint:false,
  DestructiveHint:false (pad_workspace: invite/create/claim/restore;
  pad_library: activate — codex round 1: marking additive writes
  destructive reintroduces the prompt-training harm at tool level);
- any overwrite/delete-capable action → the conservative
  ReadOnlyHint:false, DestructiveHint:true (pad_item, pad_collection,
  pad_role) — unchanged on the wire from the old defaults;
- OpenWorldHint:false everywhere (pad tools are closed-world).

pad_set_workspace gets a hand-written block (write, non-destructive,
idempotent, closed-world).

Also adds the missing pad_item.history entry to readOnlyActions —
documented read-only since v0.14 but reported read_only:false on the
tool-surface descriptor.

ToolSurfaceVersion 0.19 → 0.20 (behavior bump, v0.9/v0.16 precedent):
no tool names, action enums, or param shapes changed. instructions.md
and README headings retitled per the drift tests. The changelog entry
describes only this change; BUG-2305 appends to it if it ships in the
same window (one bump total).

Tests: TestCatalogTools_AnnotationsExplicit pins a literal per-tool
read/additive/destructive table (deliberate second enumeration — a new
tool, or a write action added to an all-read or all-additive tool,
fails loudly until someone decides its class);
TestAdditiveWriteActions_NoStaleEntries guards the new allowlist
(real catalog pairs only, never overlapping readOnlyActions);
TestSetWorkspaceTool_AnnotationsExplicit covers both deployment
variants; pad_item.history joins the read spot-checks.
Mutation-verified both directions: destructive-polarity flip fails 10
tools; always-destructive fails the two additive rows.

Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM
2026-08-16 13:49:00 -04:00

194 lines
5.7 KiB
Go

package mcp
import (
"encoding/json"
"testing"
)
// toolSurfaceDoc is the decoded shape of ToolSurfaceJSON's output —
// just the fields these tests assert on.
type toolSurfaceDoc struct {
ToolSurfaceVersion string `json:"tool_surface_version"`
RolloutStatus string `json:"rollout_status"`
Tools []struct {
Name string `json:"name"`
Workspace bool `json:"workspace"`
Actions []struct {
Name string `json:"name"`
ReadOnly bool `json:"read_only"`
} `json:"actions"`
Params []struct {
Name string `json:"name"`
Type string `json:"type"`
Enum []string `json:"enum,omitempty"`
} `json:"params"`
} `json:"tools"`
}
func decodeToolSurface(t *testing.T) toolSurfaceDoc {
t.Helper()
body, err := ToolSurfaceJSON()
if err != nil {
t.Fatalf("ToolSurfaceJSON: %v", err)
}
var doc toolSurfaceDoc
if err := json.Unmarshal(body, &doc); err != nil {
t.Fatalf("unmarshal: %v\nbody: %s", err, body)
}
return doc
}
// TestToolSurfaceJSON_MirrorsCatalog locks the cycle-free serializer to
// the package-global Catalog: one entry per tool, version stamped,
// pad_set_workspace excluded (it's not in Catalog).
func TestToolSurfaceJSON_MirrorsCatalog(t *testing.T) {
doc := decodeToolSurface(t)
if doc.ToolSurfaceVersion != ToolSurfaceVersion {
t.Errorf("tool_surface_version = %q, want %q", doc.ToolSurfaceVersion, ToolSurfaceVersion)
}
if len(doc.Tools) != len(Catalog) {
t.Fatalf("tools length = %d, want %d (one per Catalog entry)", len(doc.Tools), len(Catalog))
}
for _, tl := range doc.Tools {
if tl.Name == "pad_set_workspace" {
t.Errorf("pad_set_workspace must not appear (registered separately, not in Catalog)")
}
}
// Every Catalog entry must be present by name.
byName := map[string]bool{}
for _, tl := range doc.Tools {
byName[tl.Name] = true
}
for _, def := range Catalog {
if !byName[def.Name] {
t.Errorf("Catalog tool %q missing from ToolSurfaceJSON output", def.Name)
}
}
}
// TestToolSurfaceJSON_ReadOnlyFlags asserts each action carries a
// read_only bool that matches the readOnlyActions allowlist — and
// spot-checks a few known read vs write actions so the set can't drift
// silently. Mixed tools (pad_item) must expose BOTH a read-only=true and
// a read-only=false action so the Phase 3 browser layer correctly
// withholds the readOnlyHint.
func TestToolSurfaceJSON_ReadOnlyFlags(t *testing.T) {
doc := decodeToolSurface(t)
// Build (tool, action) → read_only from the serialized output.
got := map[[2]string]bool{}
for _, tl := range doc.Tools {
for _, a := range tl.Actions {
got[[2]string{tl.Name, a.Name}] = a.ReadOnly
// Every action's read_only must equal the allowlist lookup —
// proves the serializer consults readOnlyActions, not some
// other (drifting) source.
if want := isReadOnlyAction(tl.Name, a.Name); a.ReadOnly != want {
t.Errorf("%s.%s read_only = %v, want %v (per readOnlyActions)",
tl.Name, a.Name, a.ReadOnly, want)
}
}
}
// Spot-check known reads.
reads := [][2]string{
{"pad_item", "get"},
{"pad_item", "list"},
{"pad_item", "backlinks"},
{"pad_item", "export"},
// BUG-2302: history was documented read-only since v0.14 but
// missing from readOnlyActions — pin the fix.
{"pad_item", "history"},
{"pad_project", "dashboard"},
{"pad_project", "activity"},
{"pad_search", "query"},
{"pad_meta", "tool-surface"},
{"pad_library", "get"},
{"pad_playbook", "run"},
}
for _, k := range reads {
v, ok := got[k]
if !ok {
t.Errorf("%s.%s absent from surface", k[0], k[1])
continue
}
if !v {
t.Errorf("%s.%s expected read_only=true", k[0], k[1])
}
}
// Spot-check known writes.
writes := [][2]string{
{"pad_item", "create"},
{"pad_item", "delete"},
{"pad_item", "import"},
{"pad_collection", "create"},
{"pad_workspace", "invite"},
{"pad_library", "activate"},
}
for _, k := range writes {
v, ok := got[k]
if !ok {
t.Errorf("%s.%s absent from surface", k[0], k[1])
continue
}
if v {
t.Errorf("%s.%s expected read_only=false", k[0], k[1])
}
}
// pad_item is a mixed tool — it must carry at least one read AND one
// write so DR-2's "all-read ⇒ readOnlyHint" derivation withholds the
// hint for it.
var sawRead, sawWrite bool
for _, tl := range doc.Tools {
if tl.Name != "pad_item" {
continue
}
for _, a := range tl.Actions {
if a.ReadOnly {
sawRead = true
} else {
sawWrite = true
}
}
}
if !sawRead || !sawWrite {
t.Errorf("pad_item should be mixed: sawRead=%v sawWrite=%v", sawRead, sawWrite)
}
}
// TestReadOnlyActions_NoStaleEntries guards against drift: readOnlyActions
// is an allowlist (only reads listed; absence ⇒ write), so we can't
// require every action to appear. We CAN require the reverse — every
// (tool, action) listed as read-only must still exist in the live
// Catalog, so a removed/renamed read action doesn't leave a stale
// entry silently advertising a non-existent action as read-only.
func TestReadOnlyActions_NoStaleEntries(t *testing.T) {
live := map[[2]string]bool{}
knownTool := map[string]bool{}
for _, def := range Catalog {
knownTool[def.Name] = true
for action := range def.Actions {
live[[2]string{def.Name, action}] = true
}
}
for tool, set := range readOnlyActions {
if !knownTool[tool] {
t.Errorf("readOnlyActions has tool %q not in Catalog (stale entry)", tool)
continue
}
for action, ro := range set {
if !ro {
t.Errorf("readOnlyActions[%s][%s] is false — the allowlist should only list reads (omit writes)",
tool, action)
}
if !live[[2]string{tool, action}] {
t.Errorf("readOnlyActions[%s][%s] has no matching Catalog action (stale entry)",
tool, action)
}
}
}
}