Files
pad/internal/mcp/dispatch.go
T
xarmian a28927c51e feat(mcp): add typed schema param to pad_collection.create (TASK-1335) (#483)
* feat(mcp): add typed schema param to pad_collection.create (TASK-1335)

TASK-1334 shipped the CLI --schema flag for BUG-1284. This wires the
same capability through the MCP surface so agents calling pad_collection
get a structured object parameter rather than a stringified DSL —
matching how every other Pad-native data structure (items, comments,
etc.) flows through MCP.

Three coordinated changes:

  1. internal/mcp/catalog.go — add "object" Type to ParamDef, mapped to
     mcp-go's WithObject. Generic addition; the schema param is the first
     consumer.

  2. internal/mcp/catalog_collection.go — declare schema (object) param
     on pad_collection.create alongside the existing fields (string)
     param. Tool description now points agents at schema for
     terminal_options, defaults, computed fields, suffixes, and relation
     collections (everything the DSL cannot express), and notes the
     fields/schema mutual exclusion.

  3a. internal/mcp/dispatch.go — BuildCLIArgs now JSON-encodes
      non-string structured values via a new encodeFlagValue helper. The
      ExecDispatcher path (`pad mcp serve`) hands MCP input through to
      the CLI's --schema flag as inline JSON; previously fmt.Sprint(map)
      produced garbage Go-syntax. Generic — applies to any future flag
      that accepts JSON.

  3b. internal/mcp/dispatch_http_routes.go — mapCollectionCreate accepts
      `schema` (object or stringified JSON) alongside `fields`, errors
      if both are set, and backfills missing field labels using
      titleCaseLabel so HTTPHandlerDispatcher MCP clients see the same
      rendering as CLI users.

Tests cover: object→JSON encoding in BuildCLIArgs, array→JSON encoding,
structured schema input round-trips through mapCollectionCreate with
terminal_options preserved, missing-label backfill, stringified-JSON
fallback shape, both-flags rejection, malformed-JSON rejection.

Parent: PLAN-1333.

* fix(mcp): normalize empty/null schema input to absent per Codex round 1

Codex flagged a transport mismatch: the CLI treats `--schema ""` as
absent (falls through to --fields), but the HTTP dispatcher treated
schema=null or schema="" as set, triggering the mutually-exclusive
guard against fields or an invalid-JSON error from unmarshal.

Fix: in mapCollectionCreate, normalize nil and empty/whitespace strings
to "schema absent" before the mutex check. Keeps both transports
symmetric so MCP clients sending optional-arg defaults (null/empty) get
identical behavior to CLI users passing --schema "".

Test: TestMapCollectionCreate_EmptySchemaFallsThroughToFields covers
nil, "", and whitespace-only inputs in subtests, asserting the request
body uses the --fields-parsed schema.

Parent: PLAN-1333 / TASK-1335.
2026-05-10 21:35:55 -04:00

583 lines
21 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
}
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).
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)
}