mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
727cd80927
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
194 lines
5.7 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|