mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
6e7d34c6d1
* 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>
543 lines
22 KiB
Go
543 lines
22 KiB
Go
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), ", "),
|
||
})
|
||
}
|