Files
pad/internal/mcp/tool_surface.go
T
xarmian fbba819eb8 feat(cli,mcp): pad item context and pad_item action=context (PLAN-3598 slice (c)) (#2099)
The CLI and MCP doors onto GET /items/{ref}/context (slice (b), #2094):
one deterministic brief for working an item.

- `pad item context <ref> [--intent] [--role] [--budget]`: --format json
  prints the server's body verbatim; the default markdown render lists the
  item, parents with parsed acceptance criteria, conventions, playbooks,
  dependencies, decisions, links, conflicts and what was omitted for size.
- client.GetItemContext.
- MCP `pad_item.action=context`, read-only: passThrough to the CLI on
  stdio, and the HTTP route with intent / role / budget on the query.
  New `intent` and `budget` params; `role` is now also read by it.
- Every registry guard: field-conflict exclusions, read-only cmdPath and
  cmdhelp, ChatGPT exclusion, compact description header, instructions.md
  (cut to stay under the initialize budget), the README table.
- ToolSurfaceVersion 0.96 (additive), after TASK-3611's 0.93-0.95; docs/mcp.md entry; golden wire
  files regenerated.

Tests: the markdown render (every section, nearest-parent body only, an
empty brief renders no sections) and the MCP route mapping (goes red when
budget is dropped from the route's queryParams).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-12 05:56:59 -04:00

332 lines
12 KiB
Go

package mcp
import "encoding/json"
// tool_surface.go — the cycle-free catalog→JSON serializer (PLAN-1888 /
// TASK-1891, DR-3). Builds the same tool-surface descriptor blob that
// pad_meta.action: tool-surface emits, PLUS a per-action `read_only`
// bool, directly from the package-global Catalog. No server types, no
// ActionEnv — so cmd/pad/main.go can build an http.Handler from this
// and inject it into *server.Server without internal/server having to
// import internal/mcp (which would close the import cycle —
// internal/mcp already imports internal/server via dispatch_http.go).
//
// Why a per-action read_only flag (DR-2): WebMCP's readOnlyHint is
// per-TOOL, but the fat catalog tools straddle read+write (pad_item
// mixes show/list AND create/update/delete). The browser layer
// (Phase 3) derives readOnlyHint=true only when EVERY action a tool
// exposes is a read. Emitting the per-action bool here keeps that
// derivation a lookup rather than a re-derivation of the read set in
// TypeScript. The read set lives in Go (readOnlyActions below) as the
// single source of truth.
// toolSurfaceActionSummary is one action entry in the serialized
// surface. Adds `read_only` to the existing {name} shape — additive,
// so any consumer of pad_meta.action: tool-surface that ignores unknown
// keys is unaffected (DR-7: no ToolSurfaceVersion bump).
type toolSurfaceActionSummary struct {
Name string `json:"name"`
ReadOnly bool `json:"read_only"`
}
type toolSurfaceParamSummary struct {
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description,omitempty"`
Enum []string `json:"enum,omitempty"`
}
type toolSurfaceToolSummary struct {
Name string `json:"name"`
Description string `json:"description"`
Workspace bool `json:"workspace"`
Actions []toolSurfaceActionSummary `json:"actions"`
Params []toolSurfaceParamSummary `json:"params"`
}
// readOnlyActions is the allowlist of (tool, action) pairs that perform
// no mutation — pure reads / introspection. Co-located with the catalog
// (DR-2) and consulted by the serializer to stamp each action's
// `read_only` bool. Keyed by tool name → set of read-only action names.
//
// Anything NOT listed here is treated as a write (the safe default —
// a missing entry produces read_only=false, so a forgotten mutating
// action never silently advertises as read-only). Confirmed against the
// catalog_*.go ToolDefs:
//
// - pad_item: get/list/deps/starred/list-comments/backlinks/export read;
// create/update/delete/move/restore/link/unlink/star/unstar/
// comment/bulk-update/note/decide/import write.
// - pad_workspace: list/members/storage/audit-log/deleted read;
// invite/create/claim/restore write.
// - pad_collection: list read; create/update/delete write.
// - pad_project: all read (dashboard/next/ready/stale/standup/
// changelog/report/activity).
// - pad_role: list read; create/update/delete write.
// - pad_search: query read.
// - pad_playbook: list/get read; run is side-effect-free (returns the
// body + bound args for the agent to execute) → read; match is a
// read-only provider query, nothing stored → read.
// - pad_library: list/get read; activate mutates workspace state → write.
// - pad_attachment: list/show read (metadata only; no upload/download).
// - pad_meta: server-info/version/tool-surface/bootstrap all read.
var readOnlyActions = map[string]map[string]bool{
"pad_item": {
"reminders": true,
"get": true,
"list": true,
"deps": true,
"context": true,
"starred": true,
"list-comments": true,
"backlinks": true,
"export": true,
// BUG-2302: was missing since v0.14 added it — history is
// documented read-only in catalog_item.go (version metadata,
// no content body, no mutation).
"history": true,
},
"pad_workspace": {
"list": true,
"members": true,
"storage": true,
"audit-log": true,
// TASK-1973: restore is a write; deleted is read-only.
"deleted": true,
},
"pad_collection": {
"list": true,
// TASK-2189: restore is a write; list-archived is read-only.
"list-archived": true,
},
"pad_project": {
"dashboard": true,
"next": true,
"ready": true,
"stale": true,
"standup": true,
"changelog": true,
"report": true,
"activity": true,
},
"pad_role": {
"list": true,
},
"pad_search": {
"query": true,
},
"pad_playbook": {
"list": true,
"get": true,
"run": true,
"match": true,
},
"pad_library": {
"list": true,
"get": true,
},
"pad_attachment": {
"list": true,
"show": true,
},
"pad_meta": {
"server-info": true,
"version": true,
"tool-surface": true,
"bootstrap": true,
},
}
// additiveWriteActions is the allowlist of WRITE actions that are
// purely ADDITIVE — they create or attach state, never overwrite,
// remove, or destroy it (BUG-2302, codex round 1). Used by
// annotationForDef: a tool advertises DestructiveHint:false only when
// every action is either read-only or listed here. Absence ⇒ assume
// destructive — the conservative direction, same shape as
// readOnlyActions above.
//
// Only tools whose ENTIRE write set is additive need complete entries
// (that's what flips their tool-level hint); tools with any
// overwrite/delete action (pad_item.update/delete,
// pad_collection.update/delete, pad_role.update/delete) stay
// destructive at tool level regardless, so their additive writes are
// deliberately not enumerated — one judgment per load-bearing line.
//
// - pad_workspace: create (new workspace), claim (redeems an invite
// code — joins), restore (un-soft-delete; the catalog itself
// documents it "mutating but non-destructive", TASK-1973). NOT
// invite (TASK-3321 U0b): it sends an email to whatever address the
// caller names, and a send cannot be taken back, which is exactly
// what OpenAI's tool review (and the MCP spec's destructiveHint)
// means by destructive. With invite unlisted the tool's hint is true.
// - pad_library: activate copies a library entry into the workspace
// as a new item.
var additiveWriteActions = map[string]map[string]bool{
"pad_workspace": {
"create": true,
"claim": true,
"restore": true,
},
"pad_library": {
"activate": true,
},
}
// openWorldActions lists the actions that reach entities OUTSIDE the pad
// server's own workspace data (TASK-3321 U0b): a tool carrying any of them
// advertises OpenWorldHint:true. Everything else operates on the caller's
// bounded workspace, a closed world.
//
// - pad_workspace.invite emails an arbitrary address.
// - pad_playbook.match sends the caller's free text to the configured
// decision provider, a third-party model API (internal/decision).
// It is a READ: open-world is about where data goes, not whether
// anything is written, so a read can carry it (codex review).
var openWorldActions = map[string]map[string]bool{
"pad_workspace": {
"invite": true,
},
"pad_playbook": {
"match": true,
},
}
// isOpenWorldAction reports whether (toolName, action) reaches outside the
// pad server's own data. Defaults to false.
func isOpenWorldAction(toolName, action string) bool {
return openWorldActions[toolName][action]
}
// isAdditiveWriteAction reports whether (toolName, action) is a
// purely-additive write. Defaults to false (assume destructive) for
// any pair not in the allowlist.
func isAdditiveWriteAction(toolName, action string) bool {
actions, ok := additiveWriteActions[toolName]
if !ok {
return false
}
return actions[action]
}
// isReadOnlyAction reports whether (toolName, action) performs no
// mutation. Defaults to false (write) for any pair not in the
// allowlist — the conservative direction.
func isReadOnlyAction(toolName, action string) bool {
actions, ok := readOnlyActions[toolName]
if !ok {
return false
}
return actions[action]
}
// CallNeedsWriteScope reports whether a remote /mcp tools/call of
// (toolName, action) is a WRITE: an action the catalog declares that is
// not in readOnlyActions. An unknown tool or action is not one, so the
// server passes it on to be answered as unknown. Backs the /mcp
// insufficient_scope 403 (TASK-2308); pinned against what the
// dispatcher actually refuses by
// TestScopePopulation_WriteActionsAreRefusedForReadTokens.
func CallNeedsWriteScope(toolName, action string) bool {
for _, def := range Catalog {
if def.Name != toolName {
continue
}
_, declared := def.Actions[action]
return declared && !isReadOnlyAction(toolName, action)
}
return false
}
// buildToolSurfaceTools projects a catalog slice into the serialized
// per-tool summaries. Shared by ToolSurfaceJSON (the REST/browser path)
// and actionMetaToolSurface (the MCP path) so the two surfaces can't
// drift. The synthesized param list mirrors buildToolFromDef's
// implicit-param logic (action enum first, optional workspace, then the
// declared ParamDefs) so the dump is self-contained for consumers.
func buildToolSurfaceTools(catalog []ToolDef) []toolSurfaceToolSummary {
tools := make([]toolSurfaceToolSummary, 0, len(catalog))
for _, def := range catalog {
actionNames := sortedActionNames(def)
actions := make([]toolSurfaceActionSummary, 0, len(actionNames))
for _, name := range actionNames {
actions = append(actions, toolSurfaceActionSummary{
Name: name,
ReadOnly: isReadOnlyAction(def.Name, name),
})
}
// `action` is always present and always required — buildToolFromDef
// injects it into the schema. Synthesize it here so consumers get
// the full param picture. Enum mirrors sortedActionNames.
params := []toolSurfaceParamSummary{{
Name: "action",
Type: "string",
Description: "The action to perform. Required.",
Enum: actionNames,
}}
if def.Schema.Workspace {
params = append(params, toolSurfaceParamSummary{
Name: "workspace",
Type: "string",
Description: "Workspace slug. An explicit value always wins. A " +
"single-user local server else uses the pad_set_workspace " +
"session default, then the .pad.toml workspace; a multi-user/" +
"remote server requires an explicit workspace per call.",
})
}
for _, p := range def.Schema.Params {
params = append(params, toolSurfaceParamSummary{
Name: p.Name,
Type: p.Type,
Description: p.Description,
Enum: p.Enum,
})
}
tools = append(tools, toolSurfaceToolSummary{
Name: def.Name,
Description: def.Description, // the source text, structure intact: docs are generated from it (TASK-3536)
Workspace: def.Schema.Workspace,
Actions: actions,
Params: params,
})
}
return tools
}
// buildToolSurfacePayload assembles the full tool-surface document
// (version + rollout status + tools) from a catalog slice. The payload
// shape is identical to what actionMetaToolSurface emitted pre-TASK-1891
// except every action now carries `read_only`.
func buildToolSurfacePayload(catalog []ToolDef) map[string]any {
// rollout_status mirrors the historical actionMetaToolSurface logic:
// "complete" once the cmdhelp leaf walker retired (any version past
// the initial 0.1). Kept for backwards compatibility with consumers
// that branched on it during the v0.1→v0.2 retirement.
rolloutStatus := "in-progress"
if ToolSurfaceVersion != "0.1" {
rolloutStatus = "complete"
}
return map[string]any{
"tool_surface_version": ToolSurfaceVersion,
"rollout_status": rolloutStatus,
"tools": buildToolSurfaceTools(catalog),
}
}
// ToolSurfaceJSON serializes the package-global Catalog into the
// tool-surface descriptor JSON — the same blob pad_meta.action:
// tool-surface emits, plus a per-action `read_only` bool. Cycle-free:
// reads only the in-package Catalog + readOnlyActions, no server types.
//
// cmd/pad/main.go builds an http.Handler from this and injects it into
// *server.Server (the SetMCPTransport pattern) so the
// GET /api/v1/mcp/tool-surface endpoint can serve it without
// internal/server importing internal/mcp.
//
// Scope is the nine env.Catalog tools (pad_item, pad_workspace,
// pad_collection, pad_project, pad_role, pad_search, pad_playbook,
// pad_meta, pad_library). pad_set_workspace is registered separately
// (registry.go) and is NOT in Catalog, so it's naturally excluded.
func ToolSurfaceJSON() ([]byte, error) {
return json.Marshal(buildToolSurfacePayload(Catalog))
}