mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 22:10:25 +00:00
fix(mcp): stdio's item patch gives line_hint remote's rule through one shared helper (BUG-4082)
stdio's pad_item patch was passThrough, so edits reached the CLI unread:
its JSON decode into bodypatch.Edit.LineHint (an int) refused a string
hint that remote coerces ("5"), and took a JSON number of 2^53 or more,
which mcp-go's float64 decode may already have rounded, as a line number.
Remote has validated each edit's line_hint since BUG-4065, inline in
mapItemPatch.
That loop is now editsWithLineHints, called unchanged by mapItemPatch and
by a new stdio action, actionItemPatch. On stdio:
- a valid hint is forwarded as its int64;
- a refusal is validation_failed with remote's message, naming the edit
("(in edits[1])");
- null is absent, and a non-object edit keeps its path.
Neither transport modifies the caller's map.
Tests (lead ruling 86931478): one table, bug4082Cases, runs every input
through both transports and requires the same forwarded edits or the same
refusal (C1). Remote's BUG-4065 rows stay green unchanged. A row on each
transport pins the caller's map (C2). Red on main: stdio forwards "010",
1.5, 2^53, true and {} raw while every remote half passes.
ToolSurfaceVersion 0.105 -> 0.106 (C3), with the changelog in version.go
and docs/mcp.md; the README and the instructions.md heading follow. The
wire goldens follow in the next commit.
Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
This commit is contained in:
@@ -402,7 +402,7 @@ directory for `claude-code`, and an `[mcp_servers.pad]` table in
|
||||
project-scoped, it's install-on-request only — `--all` and `pad mcp status` cover
|
||||
the per-user clients (including Codex) and skip it.
|
||||
|
||||
**Tool catalog (v0.105)** — eleven resource × action tools plus `pad_set_workspace`. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server:
|
||||
**Tool catalog (v0.106)** — eleven resource × action tools plus `pad_set_workspace`. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server:
|
||||
|
||||
| Tool | Actions |
|
||||
|---|---|
|
||||
@@ -427,7 +427,7 @@ Plus resources at `pad://workspaces`, `pad://workspace/{ws}/dashboard`,
|
||||
and `pad://_meta/version`.
|
||||
|
||||
**Stability contract.** The catalog is versioned
|
||||
(`tool_surface_version: "0.105"`) and advertised in the initialize handshake and at
|
||||
(`tool_surface_version: "0.106"`) and advertised in the initialize handshake and at
|
||||
`pad://_meta/version`, so external agents can pin against it; the per-version
|
||||
changelog lives in [`internal/mcp/version.go`](internal/mcp/version.go). Errors
|
||||
come back as structured envelopes (`{error: {code, message, hint, ...}}`) from a
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,160 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// BUG-4082: pad_item patch's edits[].line_hint gets one rule on both
|
||||
// transports. Remote's mapItemPatch had it inline (BUG-4065); stdio forwarded
|
||||
// edits to the CLI unread, so a string hint was refused there where remote
|
||||
// coerced it, and 2^53 or more reached bodypatch as a line number. Both now
|
||||
// call editsWithLineHints. Lead ruling 86931478: C1, one table drives both
|
||||
// transports, so a change to the helper turns both red and stdio == remote is
|
||||
// pinned, not asserted; C2, neither transport modifies the caller's map.
|
||||
|
||||
// bug4082Cases is the shared table. edits is the JSON the caller sends; want
|
||||
// is the edits JSON each transport must forward (after a decode and re-encode,
|
||||
// so key order and number spelling do not matter); refuse, when set, is the
|
||||
// refusal both must give instead, with nothing forwarded.
|
||||
var bug4082Cases = []struct {
|
||||
name, edits, want, refuse string
|
||||
}{
|
||||
{"string hint coerced", `[{"old":"a","new":"b","line_hint":"5"}]`, `[{"line_hint":5,"new":"b","old":"a"}]`, ""},
|
||||
{"number hint kept", `[{"old":"a","new":"b","line_hint":5}]`, `[{"line_hint":5,"new":"b","old":"a"}]`, ""},
|
||||
{"null hint absent", `[{"old":"a","new":"b","line_hint":null}]`, `[{"line_hint":null,"new":"b","old":"a"}]`, ""},
|
||||
{"no hint", `[{"old":"a","new":"b"}]`, `[{"new":"b","old":"a"}]`, ""},
|
||||
{"largest safe hint", `[{"old":"a","new":"b","line_hint":9007199254740991}]`, `[{"line_hint":9007199254740991,"new":"b","old":"a"}]`, ""},
|
||||
{"non-object edit left alone", `["x",{"old":"a","new":"b","line_hint":"2"}]`, `["x",{"line_hint":2,"new":"b","old":"a"}]`, ""},
|
||||
{"leading zero", `[{"old":"a","new":"b","line_hint":"010"}]`, "", `line_hint must be a whole number, got the string \"010\" (in edits[0])`},
|
||||
{"fraction", `[{"old":"a","new":"b","line_hint":1.5}]`, "", "line_hint must be a whole number, got 1.5 (in edits[0])"},
|
||||
{"2^53", `[{"old":"a","new":"b","line_hint":9007199254740992}]`, "", "line_hint is beyond the exact integer range of a JSON number, got 9.007199254740992e+15 (in edits[0])"},
|
||||
{"bool", `[{"old":"a","new":"b","line_hint":true}]`, "", "line_hint must be a number, got a boolean (in edits[0])"},
|
||||
{"object", `[{"old":"a","new":"b","line_hint":{}}]`, "", "line_hint must be a number, got an object (in edits[0])"},
|
||||
{"second edit names its index", `[{"old":"a","new":"b","line_hint":1},{"old":"c","new":"d","line_hint":"0x10"}]`, "", "(in edits[1])"},
|
||||
}
|
||||
|
||||
func canonicalJSON(t *testing.T, s string) string {
|
||||
t.Helper()
|
||||
var v any
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
t.Fatalf("decode %s: %v", s, err)
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func bug4082Input(t *testing.T, edits string) map[string]any {
|
||||
t.Helper()
|
||||
var in map[string]any
|
||||
if err := json.Unmarshal([]byte(`{"workspace":"docapp","ref":"TASK-1","edits":`+edits+`}`), &in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// remoteLineHints runs the remote mapper and returns the forwarded edits JSON
|
||||
// or the refusal text.
|
||||
func remoteLineHints(t *testing.T, edits string) (forwarded, refusal string) {
|
||||
t.Helper()
|
||||
_, _, body, err := mapItemPatch(bug4082Input(t, edits))
|
||||
if err != nil {
|
||||
return "", err.Error()
|
||||
}
|
||||
var b map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &b); err != nil {
|
||||
t.Fatalf("remote body %s: %v", body, err)
|
||||
}
|
||||
return canonicalJSON(t, string(b["edits"])), ""
|
||||
}
|
||||
|
||||
// stdioLineHints runs the stdio fan-out handler and returns the --edits JSON
|
||||
// forwarded to the CLI or the refusal text.
|
||||
func stdioLineHints(t *testing.T, edits string) (forwarded, refusal string) {
|
||||
t.Helper()
|
||||
in := bug4082Input(t, edits)
|
||||
in["action"] = "patch"
|
||||
d := &fakeDispatcher{}
|
||||
env := ActionEnv{Doc: liveCmdhelpDoc(t), Workspace: NewWorkspaceState("docapp"), Dispatcher: d}
|
||||
res, err := makeFanOutHandler(padItemTool, env)(context.Background(), callToolRequest(in))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res != nil && res.IsError {
|
||||
if d.gotPath != nil {
|
||||
t.Fatalf("refused but dispatched: %v", d.gotArgs)
|
||||
}
|
||||
return "", textOf(res)
|
||||
}
|
||||
for i := 0; i+1 < len(d.gotArgs); i++ {
|
||||
if d.gotArgs[i] == "--edits" {
|
||||
return canonicalJSON(t, d.gotArgs[i+1]), ""
|
||||
}
|
||||
}
|
||||
t.Fatalf("no --edits forwarded: %v", d.gotArgs)
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func TestBUG4082_LineHintSameOnBothTransports(t *testing.T) {
|
||||
for _, tc := range bug4082Cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rFwd, rRef := remoteLineHints(t, tc.edits)
|
||||
sFwd, sRef := stdioLineHints(t, tc.edits)
|
||||
if tc.refuse != "" {
|
||||
// The JSON envelope escapes quotes, so compare stdio against the
|
||||
// escaped form of remote's error text.
|
||||
escaped, _ := json.Marshal(rRef)
|
||||
if !strings.Contains(rRef, strings.ReplaceAll(tc.refuse, `\"`, `"`)) {
|
||||
t.Errorf("remote: want refusal containing %q, got %q (forwarded %s)", tc.refuse, rRef, rFwd)
|
||||
}
|
||||
if !strings.Contains(sRef, string(ErrValidationFailed)) ||
|
||||
!strings.Contains(sRef, "item patch: "+strings.Trim(string(escaped), `"`)) {
|
||||
t.Errorf("stdio: want validation_failed with remote's message %q, got %q (forwarded %s)", rRef, sRef, sFwd)
|
||||
}
|
||||
return
|
||||
}
|
||||
want := canonicalJSON(t, tc.want)
|
||||
if rRef != "" || rFwd != want {
|
||||
t.Errorf("remote: want edits %s, got %s (refusal %q)", want, rFwd, rRef)
|
||||
}
|
||||
if sRef != "" || sFwd != want {
|
||||
t.Errorf("stdio: want edits %s, got %s (refusal %q)", want, sFwd, sRef)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// C2: neither transport modifies the caller's map, its edits slice or an
|
||||
// edit in it, on a coerced hint or on a refusal.
|
||||
func TestBUG4082_CallerMapUnmodified(t *testing.T) {
|
||||
for _, edits := range []string{
|
||||
`[{"old":"a","new":"b","line_hint":"5"},"x"]`,
|
||||
`[{"old":"a","new":"b","line_hint":2},{"old":"c","new":"d","line_hint":"010"}]`,
|
||||
} {
|
||||
t.Run("remote "+edits, func(t *testing.T) {
|
||||
in, before := bug4082Input(t, edits), bug4082Input(t, edits)
|
||||
_, _, _, _ = mapItemPatch(in)
|
||||
if !reflect.DeepEqual(in, before) {
|
||||
t.Fatalf("caller's map modified: %v, was %v", in, before)
|
||||
}
|
||||
})
|
||||
t.Run("stdio "+edits, func(t *testing.T) {
|
||||
in, before := bug4082Input(t, edits), bug4082Input(t, edits)
|
||||
in["action"], before["action"] = "patch", "patch"
|
||||
env := ActionEnv{Doc: liveCmdhelpDoc(t), Workspace: NewWorkspaceState("docapp"), Dispatcher: &fakeDispatcher{}}
|
||||
req := callToolRequest(in)
|
||||
if _, err := makeFanOutHandler(padItemTool, env)(context.Background(), req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(in, before) {
|
||||
t.Fatalf("caller's map modified: %v, was %v", in, before)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,8 @@ var padItemTool = ToolDef{
|
||||
"get": actionItemGet,
|
||||
|
||||
// PLAN-3578: content-anchored body edits, answered with the diff.
|
||||
"patch": passThrough([]string{"item", "patch"}),
|
||||
// CUSTOM (BUG-4082) so each edit's line_hint gets remote's rule.
|
||||
"patch": actionItemPatch,
|
||||
// list is CUSTOM (not passThrough) so a bare agent list stays
|
||||
// bounded: it injects a default limit and clamps an oversized one
|
||||
// before dispatch, mirroring the backlinks default/max. See
|
||||
@@ -770,6 +771,31 @@ const (
|
||||
mcpItemListMaxLimit = 300
|
||||
)
|
||||
|
||||
// actionItemPatch handles pad_item.action=patch on stdio. It gives each edit's
|
||||
// line_hint remote's rule (editsWithLineHints, shared with mapItemPatch) and
|
||||
// then forwards to `item patch` as passThrough did (BUG-4082). edits used to
|
||||
// reach the CLI unread: its JSON decode into an int refused a string hint that
|
||||
// remote coerces, and took a JSON number of 2^53 or more, which the decoder
|
||||
// may already have rounded, as a line number. A refusal is validation_failed
|
||||
// with remote's message. The caller's map is not modified.
|
||||
func actionItemPatch(ctx context.Context, input map[string]any, env ActionEnv) (*CallToolResult, error) {
|
||||
edits, isList := input["edits"].([]any)
|
||||
if !isList {
|
||||
return env.Dispatch(ctx, []string{"item", "patch"}, input)
|
||||
}
|
||||
out, err := editsWithLineHints(edits)
|
||||
if err != nil {
|
||||
return validationFailedResult("item patch", err.Error(),
|
||||
"Pass each line_hint as a whole JSON number below 2^53, or a string of decimal digits with no leading zero."), nil
|
||||
}
|
||||
in := make(map[string]any, len(input))
|
||||
for k, v := range input {
|
||||
in[k] = v
|
||||
}
|
||||
in["edits"] = out
|
||||
return env.Dispatch(ctx, []string{"item", "patch"}, in)
|
||||
}
|
||||
|
||||
// actionItemList handles pad_item.action=list. It forwards to `item list`
|
||||
// but first applies a default limit (when the agent didn't ask for one) and
|
||||
// clamps an oversized one, so a bare agent list can't dump the whole
|
||||
|
||||
@@ -886,31 +886,52 @@ func mapItemPatch(input map[string]any) (string, string, []byte, error) {
|
||||
in["expected_seq"] = seq
|
||||
}
|
||||
if edits, isList := input["edits"].([]any); isList {
|
||||
out := make([]any, len(edits))
|
||||
for i, e := range edits {
|
||||
out[i] = e
|
||||
edit, isObj := e.(map[string]any)
|
||||
if !isObj {
|
||||
continue
|
||||
}
|
||||
hint, present, err := intParam(edit, "line_hint")
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("%w (in edits[%d])", err, i)
|
||||
}
|
||||
if present {
|
||||
copied := make(map[string]any, len(edit))
|
||||
for k, v := range edit {
|
||||
copied[k] = v
|
||||
}
|
||||
copied["line_hint"] = hint
|
||||
out[i] = copied
|
||||
}
|
||||
out, err := editsWithLineHints(edits)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
in["edits"] = out
|
||||
}
|
||||
return itemPatchRoute(in)
|
||||
}
|
||||
|
||||
// editsWithLineHints returns a copy of a patch's edits with each object edit's
|
||||
// line_hint read through intParam (numericInput's rule) and replaced by its
|
||||
// int64. A refusal names the edit, e.g. `line_hint must be a whole number, got
|
||||
// the string "010" (in edits[0])`. An edit that is not an object is left as it
|
||||
// is, for the server or the CLI to refuse, since its shape is theirs to
|
||||
// define; a null line_hint is absent and left as it is. Neither edits nor any
|
||||
// edit in it is modified: a changed edit is a copy.
|
||||
//
|
||||
// Both transports call it (BUG-4082): remote's mapItemPatch, where the loop
|
||||
// was inline (BUG-4065), and stdio's patch action, which used to forward edits
|
||||
// to the CLI unread, so a string hint was refused there where remote coerced
|
||||
// it, and a JSON number of 2^53 or more, already rounded by the decoder,
|
||||
// reached bodypatch as a line number.
|
||||
func editsWithLineHints(edits []any) ([]any, error) {
|
||||
out := make([]any, len(edits))
|
||||
for i, e := range edits {
|
||||
out[i] = e
|
||||
edit, isObj := e.(map[string]any)
|
||||
if !isObj {
|
||||
continue
|
||||
}
|
||||
hint, present, err := intParam(edit, "line_hint")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w (in edits[%d])", err, i)
|
||||
}
|
||||
if present {
|
||||
copied := make(map[string]any, len(edit))
|
||||
for k, v := range edit {
|
||||
copied[k] = v
|
||||
}
|
||||
copied["line_hint"] = hint
|
||||
out[i] = copied
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// mapItemClaim dispatches `pad item claim <ref> [--holder] [--ttl]`.
|
||||
//
|
||||
// POST /api/v1/workspaces/{ws}/items/{ref}/claim with an optional body
|
||||
|
||||
@@ -6,7 +6,7 @@ Pad is a project tracker for developers and AI agents — issues (TASK, BUG), pl
|
||||
|
||||
If the user is asking general code questions with no project-management thread, you don't need this server.
|
||||
|
||||
## Tool surface (v0.105)
|
||||
## Tool surface (v0.106)
|
||||
|
||||
Eleven resource × action tools, plus `pad_set_workspace`. Twelve tools total.
|
||||
|
||||
|
||||
+12
-1
@@ -2284,7 +2284,18 @@ const CmdhelpVersion = "0.1"
|
||||
// bool, an array or an object; everywhere else the CLI's own parse refuses
|
||||
// all of them. The nested pad_item.patch edits[].line_hint is BUG-4082.
|
||||
// Behaviour only; the schemas are unchanged, as for v0.103 and v0.104.
|
||||
const ToolSurfaceVersion = "0.105"
|
||||
//
|
||||
// v0.106 — BUG-4082: stdio's pad_item.patch gives each edit's line_hint
|
||||
// remote's rule (v0.104), through the one helper both transports now call.
|
||||
// stdio used to forward edits to the CLI unread, so its JSON decode refused a
|
||||
// string hint that remote coerces ("5" is now 5 on stdio too), and took 2^53
|
||||
// or more, which the decoder may already have rounded, as a line number. Now
|
||||
// a hint that is not a whole number below 2^53 (a fraction, "010", a bool, an
|
||||
// object) is refused on stdio with remote's validation_failed, naming the
|
||||
// edit ("(in edits[1])"); null is absent, and an edit that is not an object
|
||||
// keeps its path. Remote is unchanged. Behaviour only; the schemas are
|
||||
// unchanged.
|
||||
const ToolSurfaceVersion = "0.106"
|
||||
|
||||
// MetaVersionURI is the canonical URI of the queryable version document.
|
||||
// Lives outside the pad://workspace/{ws}/... namespace because it's a
|
||||
|
||||
Reference in New Issue
Block a user