Files
xarmian 2cf9f0035a fix(mcp,server,cli): three codex round-2 findings (BUG-2850)
1. [P1] The structured-value refusal was in the wrong place and killed the
   fix. It went into BuildCLIArgs, which env.Dispatch runs for BOTH
   transports before handing off to whichever Dispatcher is configured — so
   it blocked the remote /mcp door too, and the native-field handling that
   is the whole point of this change was never reached.

   Moved into ExecDispatcher, which IS the stdio door. My own test could not
   see this: it called mapItemCreate directly, so it vouched for the mapper
   and not for the path that reaches it — CONVE-19's exact shape, in a unit
   where I had already written binding tests for the other half.

   The tests are now split along the two claims the first version conflated:
   nested values REACH the dispatcher (the remote door is unblocked), and
   refuseStructuredFieldsOverCLI refuses them at the CLI door naming the
   transport.

2. [P2] The CLI warning sat after the `--format json` early return, so the
   caller most likely to have sent a mistyped key — one piping stdout into a
   parser — was the one caller who never saw it. Moved above the return, and
   out of the `ref != ""` branch it was also trapped in. Still stderr.

3. [P2] A nil value in fields_patch DELETES the key (store/items.go), so
   reporting it as an undeclared field told the caller a field was stored
   that the same request removed. Filtered at the patch site, not inside
   UndeclaredFieldKeys, because nil means "store JSON null" on the
   full-fields path where reporting it is correct.

Gates: gofmt clean, go vet clean, go test ./... 29 packages ok.

Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo
2026-09-03 01:07:45 +00:00

627 lines
23 KiB
Go

package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/PerpetualSoftware/pad/internal/cmdhelp"
)
// Dispatcher executes a pad CLI command on behalf of a tool call. It
// always returns a *mcp.CallToolResult — error paths set IsError on
// the result so MCP clients see structured stderr without having to
// distinguish protocol errors from tool errors.
//
// Two implementations ship:
//
// - ExecDispatcher (this file) — shells out to the pad binary with
// cliArgs. Used by `pad mcp serve` for local stdio MCP, where the
// subprocess inherits the user's credentials from `~/.pad/credentials.json`.
// - HTTPHandlerDispatcher (dispatch_http.go) — calls pad-cloud's
// HTTP handlers in-process with the requesting user attached to
// the request context. Used by the future `/mcp` endpoint
// (PLAN-943 TASK-950) where the dispatcher serves multiple OAuth
// users from a single process and can't safely shell out.
//
// Both consume the same cliArgs (built by BuildCLIArgs) so the
// registry stays transport-agnostic. The HTTP dispatcher additionally
// reads the original JSON input map via DispatchInputFromContext so it
// doesn't have to reverse-parse cliArgs back to typed values; the
// registry attaches that input via WithDispatchInput before calling
// Dispatch.
type Dispatcher interface {
Dispatch(ctx context.Context, cmdPath []string, cliArgs []string) (*mcp.CallToolResult, error)
}
// dispatchInputKey is the unexported context-key type used to forward
// the original MCP tool-call JSON arguments from the registry to a
// dispatcher implementation. Unexported so callers can only set/read
// it via WithDispatchInput / DispatchInputFromContext, which keeps the
// type discipline obvious.
type dispatchInputKey struct{}
// WithDispatchInput returns ctx decorated with the original MCP tool-
// call JSON input map. Called from the registry's tool handler before
// invoking Dispatch. ExecDispatcher ignores it (it dispatches via
// cliArgs); HTTPHandlerDispatcher reads it to build a structured HTTP
// body without reverse-parsing CLI flags.
func WithDispatchInput(ctx context.Context, input map[string]any) context.Context {
if input == nil {
return ctx
}
return context.WithValue(ctx, dispatchInputKey{}, input)
}
// DispatchInputFromContext returns the original MCP tool-call JSON
// input attached by WithDispatchInput, or nil if none. Safe to call
// on any context.
func DispatchInputFromContext(ctx context.Context) map[string]any {
v, _ := ctx.Value(dispatchInputKey{}).(map[string]any)
return v
}
// mergeDispatchInput returns a copy of the user-supplied MCP input
// augmented with the same default injections BuildCLIArgs applies —
// session workspace, root flags — so the dispatcher receives the
// effective input rather than a partial map. Pure function (no
// mutation of `input`); safe to call with a nil input map.
//
// HTTPHandlerDispatcher reads the merged map via
// DispatchInputFromContext to build a structured request body without
// reverse-parsing CLI flags. ExecDispatcher ignores it (it dispatches
// via cliArgs).
func mergeDispatchInput(input map[string]any, sessionWorkspace string, rootFlags map[string]string) map[string]any {
out := make(map[string]any, len(input)+len(rootFlags)+1)
for k, v := range input {
out[k] = v
}
if _, ok := out["workspace"]; !ok && sessionWorkspace != "" {
out["workspace"] = sessionWorkspace
}
for k, v := range rootFlags {
if v == "" {
continue
}
// MCP property names are snake_case (TASK-964) — translate
// rootFlags' kebab-case keys before checking collision.
propName := MCPPropertyName(k)
if _, ok := out[propName]; ok {
continue
}
out[propName] = v
}
return out
}
// ExecDispatcher shells out to the pad binary at Binary. stdout is
// returned as Text content; if it parses as JSON the result is also
// surfaced via StructuredContent so MCP clients can consume it
// natively. Non-zero exit returns an IsError-flagged result with a
// structured ErrorEnvelope (TASK-973) — stderr is classified into a
// closed-set ErrorCode so agents can branch on the code rather than
// parsing free-form text.
type ExecDispatcher struct {
// Binary is the path to the pad executable. Required.
Binary string
// RootArgs are pre-formatted root-flag tokens (e.g. ["--url", X])
// to forward to every spawned subprocess. Same data as the
// rootFlags map carried by RegistryOptions but pre-flattened so
// the dispatcher doesn't re-iterate it on every Dispatch call.
// Used by the WorkspaceLister side-channel — when classifyExecError
// needs to populate available_workspaces, it spawns
// `pad workspace list` with these flags so it hits the same
// endpoint as the original failed call.
RootArgs []string
}
// Dispatch runs `<Binary> <cmdPath...> <cliArgs...>` and packages the
// output for an MCP client.
func (d *ExecDispatcher) Dispatch(ctx context.Context, cmdPath []string, cliArgs []string) (*mcp.CallToolResult, error) {
if d.Binary == "" {
return mcp.NewToolResultError("dispatcher: binary path not configured"), nil
}
// THIS dispatcher is the stdio door, and it cannot express a structured
// field value: it runs the CLI with `--field key=value` arguments, and a
// nested object or array has no such encoding (BUG-2850).
//
// The check lives HERE, not in BuildCLIArgs, because BuildCLIArgs runs for
// BOTH transports — env.Dispatch calls it before handing off to whichever
// Dispatcher is configured — so a refusal there also blocked the remote
// door, whose whole point in this change is that it CAN carry structures
// (codex round 2 [P1]). Refuse loudly rather than dropping: cliArgs simply
// would not contain the key, which is the silent discard this bug is about.
if err := refuseStructuredFieldsOverCLI(DispatchInputFromContext(ctx)); err != nil {
return validationFailedFromBuildErr(strings.Join(cmdPath, " "), err), nil
}
full := append(append([]string{}, cmdPath...), cliArgs...)
cmd := exec.CommandContext(ctx, d.Binary, full...)
var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
// Classify into the structured envelope. WorkspaceLister
// uses the same dispatcher (recursive in spirit but a fresh
// subprocess) to enrich no_workspace / unknown_workspace
// errors with available_workspaces.
return classifyExecError(ctx, cmdPath, err, stderr.String(), d), nil
}
out := stdout.String()
// If stdout is JSON, surface it as structured content alongside
// the text fallback. This is what `--format json` emits for most
// pad commands; clients that parse structured content (Claude
// Desktop, Cursor) get richer rendering.
return packageJSONResult(out), nil
}
// packageJSONResult converts a CLI stdout string into an MCP tool
// result. JSON bodies surface as structuredContent; non-JSON falls
// back to text. Top-level arrays are wrapped in `{items: [...]}` so
// MCP host validators (Claude Desktop) accept the structured shape —
// without the wrap they reject the tool call with "expected: record"
// (BUG-985 bug 3).
//
// Item-shaped objects also get their `fields` and `tags` properties
// parsed from JSON-encoded strings into native objects/arrays
// (BUG-991, path A). Server / web / CLI keep their existing
// stringified contract; the MCP boundary normalizes for agents.
//
// Shared between ExecDispatcher and HTTPHandlerDispatcher's
// packageHTTPResponse so both transports produce the same wire
// shape.
func packageJSONResult(out string) *mcp.CallToolResult {
trimmed := strings.TrimSpace(out)
if !strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[") {
return mcp.NewToolResultText(out)
}
var parsed any
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
return mcp.NewToolResultText(out)
}
parsed = normalizeStringEncodedJSONFields(parsed)
if arr, ok := parsed.([]any); ok {
// Wrap top-level arrays. MCP host validators (Claude Desktop)
// require `structuredContent` to be an object/record, not an
// array. The original JSON text is still returned as the text
// fallback so clients that don't parse structured content keep
// seeing the raw shape they used to.
return mcp.NewToolResultStructured(map[string]any{"items": arr}, out)
}
return mcp.NewToolResultStructured(parsed, out)
}
// normalizeStringEncodedJSONFields recursively walks a parsed JSON
// value, finds string-typed `fields` and `tags` properties, parses
// them as embedded JSON, and substitutes the native object/array.
// Additionally drops `implementation_notes` and `decision_log` from
// the parsed fields object — those entries are duplicates of the
// top-level arrays the server hydrates onto each item (BUG-992).
//
// Why: the server-side `models.Item` carries `Fields` and `Tags` as
// JSON-string columns (a SQLite-era choice that propagated through
// the API contract). For agents going through MCP this means a
// double-encode every read — the agent has to JSON.parse the field
// before doing anything useful. Normalizing at the MCP boundary
// gives agents clean JSON without coordinating a server / web / CLI
// migration (which is the bigger BUG-991 path B).
//
// The walk handles every common item shape:
// - Single-item responses (top-level item object).
// - Item arrays (item list, dashboard.active_items, comment lists).
// - Nested items (dashboard.recent_activity[].item, parent_*).
//
// Strings that don't parse as JSON pass through unchanged — covers
// hand-written field values that happen to share a key name.
func normalizeStringEncodedJSONFields(v any) any {
switch x := v.(type) {
case map[string]any:
for k, val := range x {
if s, ok := val.(string); ok && (k == "fields" || k == "tags") {
if parsed, ok := tryParseEmbeddedJSON(s); ok {
if k == "fields" {
parsed = stripDuplicatedFieldsKeys(parsed)
}
x[k] = parsed
continue
}
}
x[k] = normalizeStringEncodedJSONFields(val)
}
return x
case []any:
for i, val := range x {
x[i] = normalizeStringEncodedJSONFields(val)
}
return x
default:
return v
}
}
// stripDuplicatedFieldsKeys removes implementation_notes and
// decision_log from a parsed fields object. The server hydrates both
// onto items as top-level arrays (item.ImplementationNotes,
// item.DecisionLog) by extracting them from the fields blob — so
// every response that carries the fields blob ALSO carries the
// arrays as top-level. Surfacing them inside fields too is a
// duplicate that bloats payloads and forces agents to dedupe.
//
// Per BUG-992 the top-level arrays are the canonical shape; the
// fields embed is a write-side artifact of how AppendImplementation*
// stores data. Stripping at the MCP boundary keeps the wire shape
// clean without coordinating a write-side migration.
//
// Returns the input unchanged when it isn't an object — defensive
// against hand-written fields values that aren't shaped as objects
// (rare but plausible if someone stuffs a primitive into the column).
func stripDuplicatedFieldsKeys(parsed any) any {
m, ok := parsed.(map[string]any)
if !ok {
return parsed
}
for _, dup := range []string{"implementation_notes", "decision_log"} {
delete(m, dup)
}
return m
}
// tryParseEmbeddedJSON attempts to parse s as JSON, returning the
// native value when it succeeds. Returns (nil, false) when the
// string is empty, doesn't start with `{` or `[`, or fails to
// unmarshal — leaves the caller free to keep the original string.
//
// We require the leading-brace check before attempting Unmarshal so
// hand-written values that happen to be quoted-but-numeric or
// quoted-but-bare strings don't get spuriously coerced into Go
// values agents weren't expecting.
func tryParseEmbeddedJSON(s string) (any, bool) {
s = strings.TrimSpace(s)
if s == "" {
return nil, false
}
if !strings.HasPrefix(s, "{") && !strings.HasPrefix(s, "[") {
return nil, false
}
var out any
if err := json.Unmarshal([]byte(s), &out); err != nil {
return nil, false
}
return out, true
}
// ListWorkspaces satisfies WorkspaceLister so error helpers can
// populate available_workspaces hints. Best-effort: if listing fails
// (no auth, network down, etc.) the caller treats an error as "no
// listing available" and surfaces the bare error envelope.
//
// Implementation note: spawns a fresh `pad workspace list --format
// json` subprocess. Adds one extra exec per error path that needs the
// hint — acceptable cost given how rare these errors are. Honors
// RootArgs so the listing hits the same server endpoint as the
// originally-failed call (e.g. --url for non-default servers).
func (d *ExecDispatcher) ListWorkspaces(ctx context.Context) ([]WorkspaceHint, error) {
if d.Binary == "" {
return nil, errors.New("dispatcher: binary path not configured")
}
args := []string{"workspace", "list", "--format", "json"}
args = append(args, d.RootArgs...)
cmd := exec.CommandContext(ctx, d.Binary, args...)
var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("workspace list: %s", strings.TrimSpace(stderr.String()))
}
return parseWorkspaceListJSON(stdout.String())
}
// parseWorkspaceListJSON decodes the JSON shape `pad workspace list
// --format json` emits into the WorkspaceHint slice. Lenient about
// optional fields — only `slug` is required; everything else gets
// zero values when absent.
func parseWorkspaceListJSON(body string) ([]WorkspaceHint, error) {
body = strings.TrimSpace(body)
if body == "" || body == "null" {
return nil, nil
}
var raw []map[string]any
if err := json.Unmarshal([]byte(body), &raw); err != nil {
return nil, fmt.Errorf("decode workspace list: %w", err)
}
out := make([]WorkspaceHint, 0, len(raw))
for _, w := range raw {
slug, _ := w["slug"].(string)
if slug == "" {
continue
}
name, _ := w["name"].(string)
isDefault, _ := w["default"].(bool)
out = append(out, WorkspaceHint{Slug: slug, Name: name, Default: isDefault})
}
return out, nil
}
// BuildCLIArgs translates an MCP tool call's JSON arguments into the
// CLI argument list that should be appended after the command path.
// Pure function — no side effects, no subprocess — so it tests cleanly.
//
// Behaviour:
// - cmdInfo.Args are emitted as positional arguments in their
// declared order. Required args missing from input return an error.
// - Repeatable args expand each element into a separate positional.
// - cmdInfo.Flags are emitted as `--flag value` pairs in
// alphabetical order (deterministic for tests). Bool flags are
// presence-only (`--flag`, never `--flag=true`).
// - Repeatable / slice-typed flags expand to repeated `--flag value`
// pairs.
// - Flags listed in flagsHiddenFromMCP (e.g. `stdin`) are dropped
// defensively — they reference subprocess stdin which the MCP
// transport doesn't pipe through.
// - sessionWorkspace is appended as `--workspace <ws>` if the
// command does NOT already define a `workspace` flag in the
// input map AND sessionWorkspace is non-empty.
// - rootFlags (e.g. `--url` captured at server startup) are
// appended when the input doesn't already supply them. Empty
// values in rootFlags are skipped.
// - `--format json` is appended unless the input explicitly sets a
// format flag (e.g. an agent specifically requests markdown).
//
// refuseStructuredFieldsOverCLI reports an error naming any `fields` keys whose
// value is a nested object or array — values the `--field key=value` CLI
// encoding cannot carry (BUG-2850).
//
// The message names the TRANSPORT rather than the value on purpose: the
// reporter's agent read PR #1159's "no defined write semantics" as a verdict
// on its data and rewrote seven playbooks' argument specs. This door cannot
// express the value; Pad can store it.
func refuseStructuredFieldsOverCLI(input map[string]any) error {
native, ok := input[fieldsNativeKey].(map[string]any)
if !ok {
return nil
}
var keys []string
for k, v := range native {
switch v.(type) {
case map[string]any, []any:
keys = append(keys, k)
}
}
if len(keys) == 0 {
return nil
}
sort.Strings(keys)
return fmt.Errorf(
"fields.%s: a structured value cannot be written over the stdio transport, which sends --field key=value; "+
"use the remote transport, or write the field with the CLI directly",
strings.Join(keys, ", fields."))
}
func BuildCLIArgs(
cmdInfo cmdhelp.Command,
input map[string]any,
sessionWorkspace string,
rootFlags map[string]string,
) ([]string, error) {
if input == nil {
input = map[string]any{}
}
var positionals []string
for _, arg := range cmdInfo.Args {
// MCP property names are snake_case (TASK-964) — look up the
// translated form so kebab-case CLI args (rare for positionals
// but possible in custom commands) round-trip correctly.
propName := MCPPropertyName(arg.Name)
v, ok := input[propName]
if !ok {
if arg.Required {
return nil, fmt.Errorf("missing required argument %q", arg.Name)
}
continue
}
if arg.Repeatable {
items, err := toStringSlice(v)
if err != nil {
return nil, fmt.Errorf("argument %q: %w", arg.Name, err)
}
positionals = append(positionals, items...)
} else {
positionals = append(positionals, fmt.Sprint(v))
}
}
// Sorted flag order makes test assertions stable.
flagNames := make([]string, 0, len(cmdInfo.Flags))
for name := range cmdInfo.Flags {
flagNames = append(flagNames, name)
}
sort.Strings(flagNames)
formatProvided := false
workspaceProvided := false
var flagArgs []string
for _, name := range flagNames {
// Defensive: even if an agent's stale schema cache passes
// stdin=true, drop it here — buildTool already hides it from
// new schemas, and the running subprocess can't read agent stdin.
if _, hidden := flagsHiddenFromMCP[name]; hidden {
continue
}
flag := cmdInfo.Flags[name]
// Input map keys are MCP property names (snake_case per TASK-964),
// while `name` is the CLI flag name (kebab-case for compound
// flags like --due-date). Translate when reading the input;
// continue to emit the kebab-case form on the CLI.
propName := MCPPropertyName(name)
val, ok := input[propName]
if !ok {
continue
}
if name == "format" {
formatProvided = true
}
if name == "workspace" {
workspaceProvided = true
}
switch flag.Type {
case "bool":
b, err := toBool(val)
if err != nil {
return nil, fmt.Errorf("flag %q: %w", name, err)
}
if b {
flagArgs = append(flagArgs, "--"+name)
}
default:
if isSliceFlag(flag) {
items, err := toStringSlice(val)
if err != nil {
return nil, fmt.Errorf("flag %q: %w", name, err)
}
for _, item := range items {
flagArgs = append(flagArgs, "--"+name, item)
}
continue
}
encoded, err := encodeFlagValue(val)
if err != nil {
return nil, fmt.Errorf("flag %q: %w", name, err)
}
flagArgs = append(flagArgs, "--"+name, encoded)
}
}
out := append(positionals, flagArgs...)
// Inject root-level flags (e.g. --url) when the input didn't
// already supply them. Sorted for deterministic test output.
// rootFlags keys are CLI form (kebab-case if compound); input keys
// are MCP form (snake_case per TASK-964) — translate before the
// collision check so we don't double-emit a flag the agent already
// passed.
if len(rootFlags) > 0 {
rootNames := make([]string, 0, len(rootFlags))
for name := range rootFlags {
rootNames = append(rootNames, name)
}
sort.Strings(rootNames)
for _, name := range rootNames {
val := rootFlags[name]
if val == "" {
continue
}
if _, alreadyInInput := input[MCPPropertyName(name)]; alreadyInInput {
continue
}
out = append(out, "--"+name, val)
}
}
// `--workspace` is a persistent root flag on cobra (registered
// globally via rootCmd, not on individual leaf commands), so it
// never appears in cmdInfo.Flags and the per-flag iteration above
// can't see it. Honor it here directly: prefer explicit
// input["workspace"], fall back to sessionWorkspace.
//
// Without this branch, BUG-985 reproduced: agents passing
// `workspace=docapp` explicitly got no_workspace errors because
// the flag was silently dropped — only the session-default
// fallback below ever ran, which itself only fires when no
// explicit value is set.
if !workspaceProvided {
if explicit, _ := input["workspace"].(string); explicit != "" {
out = append(out, "--workspace", explicit)
} else if sessionWorkspace != "" {
out = append(out, "--workspace", sessionWorkspace)
}
}
if !formatProvided {
out = append(out, "--format", "json")
}
return out, nil
}
// isSliceFlag is true when a flag should be emitted as repeated
// `--flag value` pairs rather than a single `--flag value`. Covers
// both pflag's slice types and cmdhelp's `repeatable` annotation.
func isSliceFlag(f cmdhelp.Flag) bool {
if f.Repeatable {
return true
}
switch f.Type {
case "[]string", "stringSlice", "[]int", "intSlice", "stringArray":
return true
}
return false
}
func toStringSlice(v any) ([]string, error) {
switch t := v.(type) {
case []string:
return t, nil
case []any:
out := make([]string, 0, len(t))
for _, e := range t {
out = append(out, fmt.Sprint(e))
}
return out, nil
case string:
return []string{t}, nil
}
return nil, fmt.Errorf("expected array or string, got %T", v)
}
// encodeFlagValue stringifies an MCP input value for inclusion as a CLI
// flag argument. Strings and primitives pass through fmt.Sprint as before;
// structured values (maps, slices) are JSON-encoded so that flags accepting
// JSON literals — like `pad collection create --schema '<json>'` — receive
// a valid JSON string rather than Go's map-print syntax.
//
// exec.CommandContext takes []string args directly, so no shell-quoting is
// involved; the JSON string travels intact to the subprocess and is parsed
// by the CLI flag's own decoder.
func encodeFlagValue(v any) (string, error) {
switch t := v.(type) {
case nil:
return "", nil
case string:
return t, nil
case bool, int, int32, int64, uint, uint32, uint64, float32, float64, json.Number:
return fmt.Sprint(v), nil
default:
// map[string]any, []any, or any other structured type: JSON-encode.
// Compact JSON keeps the CLI process-arg list small and avoids any
// embedded-newline weirdness.
b, err := json.Marshal(v)
if err != nil {
return "", fmt.Errorf("encode value: %w", err)
}
return string(b), nil
}
}
func toBool(v any) (bool, error) {
switch t := v.(type) {
case bool:
return t, nil
case string:
switch t {
case "true", "1", "yes":
return true, nil
case "false", "0", "no", "":
return false, nil
}
case json.Number:
s := t.String()
return s != "" && s != "0", nil
}
return false, fmt.Errorf("expected bool, got %T", v)
}