Files
David Barkhausen 6e7d34c6d1 fix(mcp): accept a fields object on pad_item create/update; reject undeclared input keys (#1066) (#1159)
* fix(mcp): accept a fields object on pad_item create/update; reject undeclared input keys (#1066)

Reads return fields as a native object (BUG-991 normalization), so
writing that shape back is the obvious call — and it was a silent
no-op: not a declared param, no additionalProperties, accepted, never
mapped by BuildCLIArgs, dropped while the PATCH still bumped
updated_at.

Two halves, one contract change (ToolSurfaceVersion 0.21 -> 0.22):

- pad_item create/update fold a fields OBJECT into the same path as
  field: ["key=value"] / the dedicated params, at the catalog layer so
  both transports get it. The same key in two places with conflicting
  values is refused with a structured error; equal duplicates collapse.
  Non-writer actions refuse a fields param loudly rather than letting
  the now-declared key be dropped at dispatch.
- The fan-out handler rejects undeclared top-level keys across all
  catalog tools with a structured validation_failed naming them —
  closing the silent-drop mechanism for every future variant. Compat
  carve-out: pad_item's documented v0.16 assigned_user_id /
  agent_role_id remote clear form stays accepted.

Docs updated in lockstep per the TASK-2005 drift guards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): review round 1 — export output passes the strict gate; empty fields keys refused; limitations documented

Per the PR #1159 round-1 review:

- Bug 1: add output to pad_item's compat allowlist with a paper-trail
  comment — actionItemExport exists to override that key to '-', so
  the strict gate was killing agent export calls before the override
  ran. New test drives the REAL fan-out dispatch path so the gate and
  handler are tested together. The analogous import/file key stays
  rejected deliberately: the schema steers agents to artifact, and
  the rejection hint names it.
- Bug 2: refuse empty keys in a fields object — {"": "v"} previously
  passed the '='-in-key check and emitted a malformed field entry.
- Scope note: non-scalar round-trip limitation named in the merge
  contract and the fields param description; array/JSON encoding
  stays a follow-up.
- Intent question: the promoted-key shadowing trade-off is accepted
  and now written into the merge-contract comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: xarmian <xarmian@gmail.com>
2026-08-20 07:54:21 -04:00

543 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package mcp
import (
"context"
"fmt"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/PerpetualSoftware/pad/internal/cmdhelp"
)
// ─────────────────────────────────────────────────────────────────────
// MCP tool catalog (v0.2) — the hand-curated resource × action surface
// that replaces PLAN-942's cmdhelp-derived 1:1 verb tools.
//
// Architecture (DOC-978 / TASK-970):
//
// - Catalog declares ~7 ToolDefs (pad_item, pad_workspace, ...).
// - Each ToolDef has a map of action names → ActionFn.
// - The registered handler for a ToolDef is a thin fan-out shim: it
// reads `action` from the input, looks up the handler, and invokes
// it. Most ActionFns delegate to env.Dispatch(cmdPath, input) which
// forwards through the existing Dispatcher (ExecDispatcher /
// HTTPHandlerDispatcher) — so the dispatch contract below the
// registry boundary is unchanged.
//
// - cmdhelp remains the source of truth for individual CLI command
// schemas (args, flags, types). env.Dispatch uses opts.Doc to look
// up the cmdInfo for a cmdPath and then calls BuildCLIArgs. The
// catalog merely chooses which cmdPath to invoke for each action.
//
// - A few ToolDefs (notably pad_meta) handle their actions inline
// without dispatching to a CLI cmdPath. Those just return a
// CallToolResult directly from the ActionFn.
//
// Why a fan-out layer instead of replacing the dispatcher: TASK-965
// + TASK-967 + TASK-968 stabilized the dispatcher and route table for
// HTTP transport. Reshaping the surface in the registry without
// touching dispatch keeps that investment intact and gives ExecDispatcher
// + HTTPHandlerDispatcher the v0.2 surface for free.
// ─────────────────────────────────────────────────────────────────────
// ToolDef is one v0.2 tool: name, description, schema, and the actions
// it supports. Hand-curated; one ToolDef per catalog_<resource>.go file.
type ToolDef struct {
// Name is the MCP tool name as advertised in tools/list. Stable
// across versions — a rename is a ToolSurfaceVersion bump.
Name string
// Description shows up in tools/list and in Claude Desktop's tool
// picker. Should enumerate each action's purpose + required params
// inline, since the schema below uses a flat union of all
// parameters across actions and relies on the description for
// per-action discoverability.
Description string
// Schema is the union of parameters across all actions. Only
// `action` is required at the schema level; per-action constraints
// (e.g. "create requires title") are enforced inside the action
// handler and surface as structured validation errors.
Schema ToolSchema
// Actions maps the `action` enum value to its handler. Snake_case
// for verb-only ("list", "create"); kebab-case where the verb is
// compound ("list-comments", "tool-surface", "blocked-by"). The
// distinction matches CLI subcommand naming so agents can mentally
// map `pad_item.action: create` ↔ `pad item create`.
Actions map[string]ActionFn
}
// ToolSchema is a structured description of the tool's input shape.
// Compiled to mcp.ToolOption slices when registering with mcp-go.
//
// Workspace, when true, adds an optional `workspace` string to the
// schema. Most tools need it; pad_meta does not.
type ToolSchema struct {
Workspace bool
Params []ParamDef
}
// ParamDef declares one parameter on a tool's input schema. Type maps
// to mcp-go's helper functions (WithString, WithNumber, WithBoolean,
// WithArray, WithObject). Enum, when non-empty, constrains string parameters.
type ParamDef struct {
Name string
Type string // "string" | "number" | "bool" | "array<string>" | "object"
Description string
Enum []string
}
// ActionFn handles one tool action. Receives the call context, the raw
// MCP input map, and a per-tool execution environment. Returns the
// CallToolResult that gets surfaced to the agent.
//
// Two patterns:
//
// 1. Fan-out (most actions): call env.Dispatch(ctx, cmdPath, input).
// For the common case of "this action is just a CLI subcommand,
// forward the input verbatim", use the passThrough helper.
//
// 2. Inline (pad_meta + similar): build the CallToolResult directly
// from in-memory state (ToolSurfaceVersion, the catalog itself,
// etc.) without dispatching anywhere.
type ActionFn func(ctx context.Context, input map[string]any, env ActionEnv) (*mcp.CallToolResult, error)
// ActionEnv carries everything an action handler needs to either
// dispatch to a CLI cmdPath or build a result inline. Wired up once
// per RegisterCatalog call; passed by value into every action handler
// (small struct; copy is cheap; immutable in practice).
type ActionEnv struct {
// Doc is the cmdhelp document — source of truth for CLI command
// schemas consumed by BuildCLIArgs.
Doc *cmdhelp.Document
// Workspace is the session workspace state. Read by env.Dispatch
// (and by inline handlers that care about default workspace).
Workspace *WorkspaceState
// Dispatcher executes CLI cmdPaths. Same instance used by the
// v0.1 cmdhelp-walk path; both surfaces share dispatch.
Dispatcher Dispatcher
// RootFlags are startup-captured persistent flags (e.g. --url)
// forwarded to every dispatched call.
RootFlags map[string]string
// PadVersion is the runtime pad binary version. Threaded through
// for pad_meta.action: server-info / version.
PadVersion string
// Catalog is the live catalog slice — supplied so pad_meta.action:
// tool-surface can introspect the catalog without an import cycle.
Catalog []ToolDef
}
// Dispatch is the helper most fan-out actions use. Looks up cmdInfo
// in env.Doc, builds CLI args, attaches the dispatch input to ctx,
// forwards to env.Dispatcher.
//
// Unknown cmdPath produces a "registry bug" error rather than panicking
// — catches catalog/cmdhelp drift early in tests. A clean run on
// startup ensures every action's cmdPath maps to a real CLI command.
func (env ActionEnv) Dispatch(ctx context.Context, cmdPath []string, input map[string]any) (*mcp.CallToolResult, error) {
if input == nil {
input = map[string]any{}
}
pathStr := strings.Join(cmdPath, " ")
cmdInfo, ok := env.Doc.Commands[pathStr]
if !ok {
return NewErrorResult(ErrorPayload{
Code: ErrServerError,
Message: fmt.Sprintf("action mapped to unknown cmdPath %q (catalog/cmdhelp drift)", pathStr),
Hint: "Catalog action's cmdPath doesn't appear in the cmdhelp Doc — programmer error in the catalog. File a bug.",
}), nil
}
cliArgs, err := BuildCLIArgs(cmdInfo, input, env.Workspace.ResolveDefault(), env.RootFlags)
if err != nil {
// BUG-987 bug 12: BuildCLIArgs returns plain Go errors for
// missing required args / bad types. Previously those came
// out as bare-text MCP results, breaking the structured
// envelope contract that every other error path follows.
// Wrap as validation_failed so agents see a consistent
// shape across the surface and can branch on error.code.
return validationFailedFromBuildErr(pathStr, err), nil
}
ctx = WithDispatchInput(ctx, mergeDispatchInput(input, env.Workspace.ResolveDefault(), env.RootFlags))
return env.Dispatcher.Dispatch(ctx, cmdPath, cliArgs)
}
// passThrough returns an ActionFn that forwards the input verbatim to
// the given cmdPath via env.Dispatch. The vast majority of catalog
// actions use this — a custom handler is only needed when the action
// reshapes the input or dispatches on a parameter (see actionItemLink
// for the link_type → cmdPath dispatch pattern).
func passThrough(cmdPath []string) ActionFn {
return func(ctx context.Context, input map[string]any, env ActionEnv) (*mcp.CallToolResult, error) {
return env.Dispatch(ctx, cmdPath, input)
}
}
// Catalog is the live v0.2 tool catalog. Populated by per-tool init()
// functions in catalog_<resource>.go via appendToCatalog so each tool
// definition lives in its own file without a central registration list
// drifting out of sync.
var Catalog []ToolDef
// appendToCatalog inserts def into Catalog. Called from
// catalog_<resource>.go init() functions. Keep this small + simple;
// validation happens at RegisterCatalog time (where errors surface
// once at startup rather than per-init).
func appendToCatalog(def ToolDef) {
Catalog = append(Catalog, def)
}
// CatalogOptions configures RegisterCatalog. Mirrors RegistryOptions
// for the cmdhelp-walk path but adds PadVersion (needed by pad_meta).
type CatalogOptions struct {
Doc *cmdhelp.Document
Workspace *WorkspaceState
Dispatcher Dispatcher
RootFlags map[string]string
PadVersion string
}
// RegisterCatalog installs the v0.2 catalog tools on srv. Returns the
// count of tools registered (excluding pad_set_workspace, which the
// cmdhelp-walk path in Register() owns).
//
// Called alongside Register() from cmd/pad/mcp.go during the parallel-
// rollout phase of TASK-970. Once TASK-981 lands, Register() collapses
// into RegisterCatalog and the cmdhelp leaf walker goes away.
func RegisterCatalog(srv *server.MCPServer, opts CatalogOptions) (int, error) {
if err := validateCatalogOptions(opts); err != nil {
return 0, err
}
env := ActionEnv{
Doc: opts.Doc,
Workspace: opts.Workspace,
Dispatcher: opts.Dispatcher,
RootFlags: opts.RootFlags,
PadVersion: opts.PadVersion,
Catalog: Catalog,
}
count := 0
for _, def := range Catalog {
tool := buildToolFromDef(def)
handler := makeFanOutHandler(def, env)
srv.AddTool(tool, handler)
count++
}
return count, nil
}
func validateCatalogOptions(opts CatalogOptions) error {
if opts.Doc == nil {
return errCatalogMissing("Doc")
}
if opts.Workspace == nil {
return errCatalogMissing("Workspace")
}
if opts.Dispatcher == nil {
return errCatalogMissing("Dispatcher")
}
return nil
}
// errCatalogMissing keeps the validation error messages in one place
// so the surface is consistent across the three required fields.
func errCatalogMissing(field string) error {
return &catalogError{msg: "CatalogOptions." + field + " is required"}
}
type catalogError struct{ msg string }
func (e *catalogError) Error() string { return e.msg }
// annotationForDef derives the MCP tool annotation block from the
// catalog's own read-only knowledge (readOnlyActions in tool_surface.go
// — the single source of truth; BUG-2302). Without an explicit block,
// mcp-go's NewTool injects ReadOnlyHint:false + DestructiveHint:true +
// OpenWorldHint:true on EVERY tool, so clients were told that reading a
// dashboard is destructive — which both trains users to click through
// confirmation prompts and misrepresents the surface.
//
// Annotations are per-tool but Pad's tools are resource × action, so a
// single tool can cover both reads and writes. The policy, decided
// deliberately rather than inherited:
//
// - Every action read-only → ReadOnlyHint:true, DestructiveHint:false,
// IdempotentHint:true (repeating a read changes nothing).
// - Any write action → ReadOnlyHint:false, IdempotentHint:false.
// DestructiveHint then depends on WHAT the writes can do:
// false when every write is purely additive (additiveWriteActions
// in tool_surface.go — pad_workspace, pad_library; codex round 1:
// invite/create/claim/restore/activate never overwrite or remove,
// so marking them destructive would reintroduce the
// prompt-training harm at tool level), true when any action can
// overwrite or delete (pad_item, pad_collection, pad_role) — the
// conservative tool-level truth and the same wire values mcp-go
// defaulted to for exactly those tools.
// - OpenWorldHint:false for all tools: every pad tool operates on the
// pad server's own workspace data, a closed world — nothing here
// reaches external entities the way e.g. a web search does.
func annotationForDef(def ToolDef) mcp.ToolAnnotation {
allReadOnly := true
destructive := false
for action := range def.Actions {
if isReadOnlyAction(def.Name, action) {
continue
}
allReadOnly = false
if !isAdditiveWriteAction(def.Name, action) {
destructive = true
}
}
return mcp.ToolAnnotation{
ReadOnlyHint: mcp.ToBoolPtr(allReadOnly),
DestructiveHint: mcp.ToBoolPtr(destructive),
IdempotentHint: mcp.ToBoolPtr(allReadOnly),
OpenWorldHint: mcp.ToBoolPtr(false),
}
}
// buildToolFromDef compiles a ToolDef into the mcp.Tool shape mcp-go
// expects. The schema is flat: `action` is required, every other
// parameter is optional at the schema level (per-action validation
// runs in the action handler). This matches DOC-978's design — JSON
// Schema discriminated unions are awkward in mcp-go's helpers, and
// the description carries the action × params matrix anyway.
func buildToolFromDef(def ToolDef) mcp.Tool {
opts := []mcp.ToolOption{
mcp.WithDescription(def.Description),
mcp.WithToolAnnotation(annotationForDef(def)),
}
// `action` is always required and always enumerated. Sorted for
// deterministic schema output across builds.
actions := sortedActionNames(def)
opts = append(opts,
mcp.WithString("action",
mcp.Description("The action to perform. See tool description for required parameters per action."),
mcp.Required(),
mcp.Enum(actions...),
),
)
if def.Schema.Workspace {
opts = append(opts,
mcp.WithString("workspace",
mcp.Description(
"Workspace slug to target for this call. An explicit value here "+
"ALWAYS wins. Otherwise resolution depends on the server: a "+
"single-user local server falls back to the session default set "+
"via pad_set_workspace, then the CWD-linked workspace from "+
".pad.toml. A multi-user/remote server does NOT persist a session "+
"default, so you must pass workspace explicitly on every call.",
),
),
)
}
for _, p := range def.Schema.Params {
opts = append(opts, paramDefToToolOption(p))
}
return mcp.NewTool(def.Name, opts...)
}
// paramDefToToolOption maps a ParamDef to the matching mcp-go helper.
// Unknown Type strings fall through to WithString — same defensive
// default the cmdhelp-walk path uses.
func paramDefToToolOption(p ParamDef) mcp.ToolOption {
propOpts := make([]mcp.PropertyOption, 0, 2)
if p.Description != "" {
propOpts = append(propOpts, mcp.Description(p.Description))
}
if len(p.Enum) > 0 {
propOpts = append(propOpts, mcp.Enum(p.Enum...))
}
switch p.Type {
case "number":
return mcp.WithNumber(p.Name, propOpts...)
case "bool":
return mcp.WithBoolean(p.Name, propOpts...)
case "array<string>":
return mcp.WithArray(p.Name, append(propOpts, mcp.WithStringItems())...)
case "object":
// Structured JSON parameter. The MCP host sees a generic object;
// per-tool semantics are encoded in the Description. Callers
// constructing the value can pass a native JSON object —
// BuildCLIArgs json-encodes it before handing it to the CLI as
// a string flag value (which the CLI's --schema flag accepts as
// inline JSON).
return mcp.WithObject(p.Name, propOpts...)
default:
return mcp.WithString(p.Name, propOpts...)
}
}
// makeFanOutHandler returns the ToolHandlerFunc that mcp-go invokes
// when the tool is called. Reads `action` from the input, looks up the
// handler, and forwards. Unknown / missing action returns a structured
// error result so the agent sees the valid options and can recover.
//
// The catalog's `action` key is stripped from the input before the
// action handler runs. It's the fan-out routing field — its value
// names the handler we just dispatched to, so the handler doesn't
// need it. More importantly, leaving it in would shadow any CLI flag
// named `--action` when the handler eventually dispatches: e.g.
// `pad workspace audit-log --action item.created` reuses the same
// flag name as our routing key, and BuildCLIArgs would emit
// `--action audit-log` (the catalog action name) instead of the
// user's filter. Stripping at the boundary keeps action handlers
// free to talk to the dispatcher in CLI-flag terms without watching
// for that collision.
func makeFanOutHandler(def ToolDef, env ActionEnv) server.ToolHandlerFunc {
declared := declaredInputKeys(def)
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
input := req.GetArguments()
if input == nil {
input = map[string]any{}
}
action, _ := input["action"].(string)
if action == "" {
return errMissingAction(def), nil
}
handler, ok := def.Actions[action]
if !ok {
return errUnknownAction(def, action), nil
}
if errRes := rejectUndeclaredKeys(def, declared, input); errRes != nil {
return errRes, nil
}
// Clone before stripping so we don't mutate req.Params.Arguments
// — defensive against any future mcp-go change that re-reads the
// request map after dispatch.
stripped := make(map[string]any, len(input))
for k, v := range input {
if k == "action" {
continue
}
stripped[k] = v
}
return handler(ctx, stripped, env)
}
}
// compatAcceptedInputKeys lists input keys that are ACCEPTED without
// being schema-declared — documented compatibility forms that predate
// strict input validation (#1066 / ToolSurfaceVersion 0.24). Additions
// here should be rare and carry a paper trail; the whole point of
// strict validation is that an undeclared key fails loudly instead of
// being silently dropped.
//
// - pad_item assigned_user_id / agent_role_id: the v0.16 remote-
// transport clear form (empty string clears the assignment).
// Documented, undeprecated, and deliberately never schema-declared
// (v0.18's booleans are the discoverable form) — rejecting them
// would break working v0.16 consumers for no safety gain.
// - pad_item output: agents mirror the CLI's `item export -o <path>`
// flag, and actionItemExport exists to OVERRIDE that key to `-`
// (the MCP host can't see a local path) — its doc comment says
// "agents send it" outright. Rejecting it at the gate would kill
// those calls before the override runs (PR #1159 round-1 review,
// bug 1). Not schema-declared because advertising a param the
// handler always overwrites would invite more of them. The
// analogous `file` key on action=import is deliberately NOT
// listed: the schema points agents at `artifact`, and a rejection
// naming the declared params is the correct steer there.
var compatAcceptedInputKeys = map[string]map[string]bool{
"pad_item": {
"assigned_user_id": true,
"agent_role_id": true,
"output": true,
},
}
// declaredInputKeys builds the set of input keys a tool accepts: the
// routing `action`, the implicit `workspace` (when the schema opts in),
// every declared ParamDef, and any compat carve-outs. Computed once per
// handler registration.
func declaredInputKeys(def ToolDef) map[string]bool {
allowed := map[string]bool{"action": true}
if def.Schema.Workspace {
allowed["workspace"] = true
}
for _, p := range def.Schema.Params {
allowed[p.Name] = true
}
for k := range compatAcceptedInputKeys[def.Name] {
allowed[k] = true
}
return allowed
}
// rejectUndeclaredKeys refuses any input key outside the tool's
// declared schema (#1066, ToolSurfaceVersion 0.24). Before this,
// nothing set additionalProperties and BuildCLIArgs dropped unmapped
// keys, so a typo'd or misplaced param was accepted, did nothing, and
// still returned success — the worst version of wrong. Nil result
// means the input is clean.
func rejectUndeclaredKeys(def ToolDef, declared map[string]bool, input map[string]any) *mcp.CallToolResult {
var unknown []string
for k := range input {
if !declared[k] {
unknown = append(unknown, k)
}
}
if len(unknown) == 0 {
return nil
}
sort.Strings(unknown)
declaredNames := make([]string, 0, len(declared))
for k := range declared {
declaredNames = append(declaredNames, k)
}
sort.Strings(declaredNames)
return NewErrorResult(ErrorPayload{
Code: ErrValidationFailed,
Message: fmt.Sprintf("%s: unknown parameter(s): %s", def.Name, strings.Join(unknown, ", ")),
Hint: "Undeclared keys are rejected rather than silently dropped (ToolSurfaceVersion 0.24). Declared parameters: " +
strings.Join(declaredNames, ", "),
})
}
// sortedActionNames returns def.Actions' keys in lexical order. Used
// for both the schema's action enum (so the ordering is stable across
// builds and reproducible in tests) and the error helpers below (so
// users see actions alphabetized rather than in map-iteration order).
func sortedActionNames(def ToolDef) []string {
out := make([]string, 0, len(def.Actions))
for name := range def.Actions {
out = append(out, name)
}
sort.Strings(out)
return out
}
// errMissingAction is the structured result returned when the input
// has no `action` field. Lists the valid actions inline so agents can
// retry with a correct value.
func errMissingAction(def ToolDef) *mcp.CallToolResult {
return NewErrorResult(ErrorPayload{
Code: ErrValidationFailed,
Message: fmt.Sprintf("%s: missing required field 'action'", def.Name),
Hint: "Pass `action=<verb>`. Valid actions: " + strings.Join(sortedActionNames(def), ", "),
})
}
// errUnknownAction is the structured result returned when `action` is
// set but not in the tool's action table. Same listing as
// errMissingAction so agents can self-correct.
func errUnknownAction(def ToolDef, action string) *mcp.CallToolResult {
return NewErrorResult(ErrorPayload{
Code: ErrValidationFailed,
Message: fmt.Sprintf("%s: unknown action %q", def.Name, action),
Hint: "Valid actions: " + strings.Join(sortedActionNames(def), ", "),
})
}