Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.90)** — ten 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.91)** — ten 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 |
|---|---|
Expand All @@ -426,7 +426,7 @@ Plus resources at `pad://workspaces`, `pad://workspace/{ws}/dashboard`,
and `pad://_meta/version`.

**Stability contract.** The catalog is versioned
(`tool_surface_version: "0.90"`) and advertised in the initialize handshake and at
(`tool_surface_version: "0.91"`) 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
Expand Down
29 changes: 19 additions & 10 deletions cmd/pad/cmd_item.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"github.com/PerpetualSoftware/pad/internal/bodypatch"
"github.com/google/uuid"
"io"
"math"
"net/url"
Expand Down Expand Up @@ -189,16 +190,17 @@ func parseFieldFlag(schema models.CollectionSchema, key, raw string) any {

func createCmd() *cobra.Command {
var (
content string
useStdin bool
priority string
status string
assignee string
roleFlag string
category string
parentSlug string
tags string
fieldFlags []string
createOpKey string
content string
useStdin bool
priority string
status string
assignee string
roleFlag string
category string
parentSlug string
tags string
fieldFlags []string
)

cmd := &cobra.Command{
Expand Down Expand Up @@ -344,6 +346,12 @@ Run with --help-collections to see available collections and their status values
input.AgentRoleID = &role.ID
}

// TASK-3585: one idempotency key per invocation, so a retry of a
// create whose answer was lost returns the item instead of a twin.
if createOpKey == "" {
createOpKey = uuid.NewString()
}
input.OpKey = createOpKey
item, err := cli.WithCollectionAliasFallback(rawSlug, client.CollectionNotFoundIsAuthoritative, func(slug string) (*models.Item, error) {
return client.CreateItem(ws, slug, input)
})
Expand Down Expand Up @@ -414,6 +422,7 @@ Run with --help-collections to see available collections and their status values
printAvailableCollections()
})

cmd.Flags().StringVar(&createOpKey, "op-key", "", "idempotency key: retrying the SAME create with the same key returns the item it created instead of a second one (default: a new key per invocation)")
return cmd
}

Expand Down
104 changes: 104 additions & 0 deletions cmd/pad/task3585_create_retry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package main

import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)

// createServer answers capabilities (idempotency_keys = honorsKeys) and
// scripts the statuses of the create POSTs it receives.
func createServer(t *testing.T, honorsKeys bool, statuses ...int) *[]map[string]any {
t.Helper()
var bodies []map[string]any
setupFormatRoutingTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/capabilities") {
_ = json.NewEncoder(w).Encode(map[string]any{"item_patch": true, "idempotency_keys": honorsKeys, "collection_resolution": true})
return
}
if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/items") {
_ = json.NewEncoder(w).Encode(map[string]any{})
return
}
raw, _ := io.ReadAll(r.Body)
var b map[string]any
_ = json.Unmarshal(raw, &b)
bodies = append(bodies, b)
status := http.StatusCreated
if n := len(bodies) - 1; n < len(statuses) {
status = statuses[n]
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if status >= 400 {
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"code": "x", "message": "m"}})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": "i1", "slug": "s", "title": "t", "collection_slug": "tasks", "ref": "TASK-5"})
}))
formatFlag = "table"
return &bodies
}

func runCreate(t *testing.T, args ...string) error {
t.Helper()
cmd := createCmd()
cmd.SetArgs(append([]string{"tasks", "Keyed"}, args...))
var err error
_ = captureStdout(t, func() { err = cmd.Execute() })
return err
}

func TestTASK3585_CLICreateRetriesOnceWithTheSameKey(t *testing.T) {
bodies := createServer(t, true, http.StatusBadGateway)
if err := runCreate(t); err != nil {
t.Fatalf("a 502 then a 201 must succeed: %v", err)
}
if len(*bodies) != 2 {
t.Fatalf("want exactly one retry, got %d attempts", len(*bodies))
}
k1, _ := (*bodies)[0]["op_key"].(string)
k2, _ := (*bodies)[1]["op_key"].(string)
if k1 == "" || k1 != k2 {
t.Fatalf("the retry must reuse the key: %q vs %q", k1, k2)
}
}

// A server without idempotency_keys ignores op_key, so a retried create
// there would make a second item: no capability, no retry.
func TestTASK3585_CLICreateNoRetryWithoutTheCapability(t *testing.T) {
bodies := createServer(t, false, http.StatusBadGateway)
if err := runCreate(t); err == nil {
t.Fatal("a 502 with no retry must fail")
}
if len(*bodies) != 1 {
t.Fatalf("retried a create against a server that ignores keys: %d attempts", len(*bodies))
}
}

func TestTASK3585_CLICreateDoesNotRetryA4xx(t *testing.T) {
bodies := createServer(t, true, http.StatusConflict)
if err := runCreate(t); err == nil {
t.Fatal("a 409 must fail")
}
if len(*bodies) != 1 {
t.Fatalf("a 4xx was retried: %d attempts", len(*bodies))
}
}

func TestTASK3585_CLICreateKeyIsGivenOrFresh(t *testing.T) {
bodies := createServer(t, true)
_ = runCreate(t)
_ = runCreate(t)
_ = runCreate(t, "--op-key", "mine")
if len(*bodies) != 3 {
t.Fatalf("attempts = %d", len(*bodies))
}
k0, _ := (*bodies)[0]["op_key"].(string)
k1, _ := (*bodies)[1]["op_key"].(string)
if k0 == "" || k0 == k1 || (*bodies)[2]["op_key"] != "mine" {
t.Fatalf("keys = %q %q %v", k0, k1, (*bodies)[2]["op_key"])
}
}
4 changes: 2 additions & 2 deletions cmd/pad/testdata/mcp_wire/remote_initialize.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion cmd/pad/testdata/mcp_wire/remote_tools_list.json
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@
"type": "number"
},
"op_key": {
"description": "patch: an idempotency key you choose (e.g. a UUID). Retrying the SAME request with the same key returns the first answer instead of applying it again; use it whenever a retry could follow an unclear outcome. A key reused for a different request is refused (idempotency_key_reused).",
"description": "patch, create: an idempotency key you choose (e.g. a UUID). Retrying the SAME request with the same key returns the first answer instead of applying it again; use it whenever a retry could follow an unclear outcome. A key reused for a different request is refused (idempotency_key_reused).",
"type": "string"
},
"overwrite_pending_edits": {
Expand Down
4 changes: 2 additions & 2 deletions cmd/pad/testdata/mcp_wire/stdio_initialize.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion cmd/pad/testdata/mcp_wire/stdio_tools_list.json
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@
"type": "number"
},
"op_key": {
"description": "patch: an idempotency key you choose (e.g. a UUID). Retrying the SAME request with the same key returns the first answer instead of applying it again; use it whenever a retry could follow an unclear outcome. A key reused for a different request is refused (idempotency_key_reused).",
"description": "patch, create: an idempotency key you choose (e.g. a UUID). Retrying the SAME request with the same key returns the first answer instead of applying it again; use it whenever a retry could follow an unclear outcome. A key reused for a different request is refused (idempotency_key_reused).",
"type": "string"
},
"overwrite_pending_edits": {
Expand Down
11 changes: 10 additions & 1 deletion internal/cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,16 @@ func (c *Client) ListCollectionItemsReport(wsSlug, collSlug string, params url.V

func (c *Client) CreateItem(wsSlug, collSlug string, input models.ItemCreate) (*models.Item, error) {
var result models.Item
return &result, c.post("/workspaces/"+wsSlug+"/collections/"+collSlug+"/items", input, &result)
path := "/workspaces/" + wsSlug + "/collections/" + collSlug + "/items"
err := c.post(path, input, &result)
// TASK-3585: with a key, a create whose outcome the transport lost is
// retried ONCE; a server that honors keys answers the item the first
// attempt created instead of creating another.
if err != nil && input.OpKey != "" && retryableWithKey(err) && c.serverHonorsKeys() {
result = models.Item{}
err = c.post(path, input, &result)
}
return &result, err
}

// CollectionNotFoundIsAuthoritative reports whether a collection-not-found from
Expand Down
2 changes: 1 addition & 1 deletion internal/mcp/catalog_item.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ var padItemSchemaParams = []ParamDef{
},
"required": []any{"old", "new"},
}, Description: "patch: the edits, applied in order, all or nothing. Required for: patch."},
{Name: "op_key", Type: "string", Description: "patch: an idempotency key you choose (e.g. a UUID). Retrying the SAME request with the same key returns the first answer instead of applying it again; use it whenever a retry could follow an unclear outcome. A key reused for a different request is refused (idempotency_key_reused)."},
{Name: "op_key", Type: "string", Description: "patch, create: an idempotency key you choose (e.g. a UUID). Retrying the SAME request with the same key returns the first answer instead of applying it again; use it whenever a retry could follow an unclear outcome. A key reused for a different request is refused (idempotency_key_reused)."},
{Name: "undo", Type: "string", Description: "patch: invert an earlier patch on this item by its patch_id (instead of edits). Refused, writing nothing (patch_anchor_missing), when the text it changed has changed since; never reverts someone else's later edit. The undo is itself a patch."},
{Name: "lines", Type: "string", Description: "get: return only these body lines, numbered (\"A-B\", \"A-\" or \"A\"; 1-based), with seq and content_state, for targeting a patch. Line numbers are for reading; a patch anchors on text."},
// PLAN-3555: the deliberate-overwrite opt-out. Phase 1 changes no outcome;
Expand Down
3 changes: 2 additions & 1 deletion internal/mcp/dispatch_http.go
Original file line number Diff line number Diff line change
Expand Up @@ -852,7 +852,8 @@ func mapItemCreate(input map[string]any) (method, path string, body []byte, err
}

payload := map[string]any{}
for _, key := range []string{"title", "content", "slug"} {
// TASK-3585: op_key is the create's idempotency key.
for _, key := range []string{"title", "content", "slug", "op_key"} {
if v, ok := input[key]; ok {
payload[key] = v
}
Expand Down
2 changes: 1 addition & 1 deletion internal/mcp/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.90)
## Tool surface (v0.91)

Ten resource × action tools, plus `pad_set_workspace`. Eleven tools total.

Expand Down
28 changes: 28 additions & 0 deletions internal/mcp/task3585_op_key_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"testing"

"github.com/PerpetualSoftware/pad/internal/cli"
"github.com/PerpetualSoftware/pad/internal/models"
)

// TASK-3585 at the MCP doors: op_key replays over the remote transport, a
Expand Down Expand Up @@ -54,3 +55,30 @@ func TestTASK3585_CLIAndMCPAgreeOnHints(t *testing.T) {
}
}
}

// TASK-3585: op_key on create rides to the server, so a retried create
// answers the item it made instead of a twin.
func TestTASK3585_MCPCreateOpKey(t *testing.T) {
f := newParentFixture(t)
in := map[string]any{"workspace": f.workspace.Slug, "collection": "tasks", "title": "Keyed via MCP", "op_key": "mcp-c"}
first := f.dispatchResult2(t, []string{"item", "create"}, in)
second := f.dispatchResult2(t, []string{"item", "create"}, in)
m1, _ := first.StructuredContent.(map[string]any)
m2, _ := second.StructuredContent.(map[string]any)
if first.IsError || second.IsError || m1["id"] == nil || m1["id"] != m2["id"] {
t.Fatalf("replay: %s / %s", textOf(first), textOf(second))
}
items, err := f.store.ListItems(f.workspace.ID, models.ItemListParams{})
if err != nil {
t.Fatal(err)
}
n := 0
for _, it := range items {
if it.Title == "Keyed via MCP" {
n++
}
}
if n != 1 {
t.Fatalf("%d items; the retried create made a twin", n)
}
}
14 changes: 13 additions & 1 deletion internal/mcp/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -1738,6 +1738,18 @@ const CmdhelpVersion = "0.1"
// caller cannot read embeds nothing; a failed fetch stays non-fatal,
// as on stdio. Stdio is unchanged.
//
// 0.91 — TASK-3585, create. ADDITIVE bump on the v0.53 grounds:
// `pad_item.action=create` accepts `op_key` too. Retrying the SAME
// create with the same key answers the item the first attempt created
// (as it stands now) instead of creating a second one; the answer is
// recorded in the create's own transaction. A key reused for a
// different create is refused `idempotency_key_reused`; one still in
// flight answers `idempotency_in_flight` (both codes already exist, and
// are structured on both transports for any route). A replay whose item
// has since been deleted answers HTTP 410 `idempotency_target_gone`,
// which is NOT on the structured allow-list and reaches MCP as a generic
// error. No name, enum or param moved.
//
// 0.90 — TASK-3585. ADDITIVE bump on the v0.53 grounds:
// `pad_item.action=patch` gains `op_key`, an idempotency key: retrying
// the SAME request with the same key returns the first answer (marked by
Expand Down Expand Up @@ -2095,7 +2107,7 @@ const CmdhelpVersion = "0.1"
// this surface can receive it; the entry exists so a future action does
// not collapse it to permission_denied. When an action that can reach
// it is added, that addition is the contract change and owns the bump.
const ToolSurfaceVersion = "0.90"
const ToolSurfaceVersion = "0.91"

// MetaVersionURI is the canonical URI of the queryable version document.
// Lives outside the pad://workspace/{ws}/... namespace because it's a
Expand Down
30 changes: 19 additions & 11 deletions internal/models/item.go
Original file line number Diff line number Diff line change
Expand Up @@ -1263,17 +1263,25 @@ func uniqueStrings(values []string) []string {
type ItemCreate struct {
// ActorUserID is the request's user, stamped on the create's version row
// (PLAN-2348 U2). Internal-only.
ActorUserID string `json:"-"`
Title string `json:"title"`
Content string `json:"content,omitempty"`
Fields string `json:"fields,omitempty"`
Tags string `json:"tags,omitempty"`
Pinned bool `json:"pinned,omitempty"`
ParentID *string `json:"parent_id,omitempty"`
AssignedUserID *string `json:"assigned_user_id,omitempty"`
AgentRoleID *string `json:"agent_role_id,omitempty"`
CreatedBy string `json:"created_by,omitempty"`
Source string `json:"source,omitempty"`
ActorUserID string `json:"-"`
// OpKey is the idempotency key in the body (TASK-3585), for callers that
// cannot set the Idempotency-Key header. Not part of the request hash;
// the store ignores it.
OpKey string `json:"op_key,omitempty"`
// AfterWriteTx, when set, runs inside the create's transaction after the
// row is inserted and re-read, before COMMIT (TASK-3585: the idempotency
// key's answer). An error rolls the create back. Internal.
AfterWriteTx func(tx *sql.Tx, created *Item) error `json:"-"`
Title string `json:"title"`
Content string `json:"content,omitempty"`
Fields string `json:"fields,omitempty"`
Tags string `json:"tags,omitempty"`
Pinned bool `json:"pinned,omitempty"`
ParentID *string `json:"parent_id,omitempty"`
AssignedUserID *string `json:"assigned_user_id,omitempty"`
AgentRoleID *string `json:"agent_role_id,omitempty"`
CreatedBy string `json:"created_by,omitempty"`
Source string `json:"source,omitempty"`
// Convention is the typed door for the `convention` system metadata key
// (BUG-3163). Create's `fields` refuses every reserved key, so this is how
// convention activation writes it: the handler validates the object and
Expand Down
36 changes: 36 additions & 0 deletions internal/server/handlers_items.go
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,42 @@ func (s *Server) handleCreateItem(w http.ResponseWriter, r *http.Request) {
}
}

// TASK-3585: an idempotency key, claimed before the write. A replay
// answers with the item the first request created, as it stands now.
key, kerr := idempotencyKey(r, input.OpKey)
if kerr != nil {
writeError(w, http.StatusBadRequest, "bad_request", kerr.Error())
return
}
canon := input
canon.OpKey = ""
hold, ok := s.claimKey(w, r, workspaceID, idempotencyRouteCreate, coll.ID, canon, key, func(status int, stored string) {
var a createdAnswer
if json.Unmarshal([]byte(stored), &a) != nil {
writeInternalError(w, fmt.Errorf("stored idempotent answer does not decode"))
return
}
created, gerr := s.store.GetItem(a.ItemID)
if gerr != nil || created == nil {
writeError2(w, http.StatusGone, "idempotency_target_gone", "the item this key created no longer exists", map[string]interface{}{"item_id": a.ItemID})
return
}
if err := s.enrichItemForResponse(r, created, visibleIDs); err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, status, created)
})
if !ok {
return
}
defer s.release(hold)
if hold != nil {
input.AfterWriteTx = func(tx *sql.Tx, created *models.Item) error {
return s.completeTx(hold, http.StatusCreated, createdAnswer{ItemID: created.ID})(tx)
}
}

item, cerr := s.createItemChecked(r, workspaceID, coll, schema, input, fieldMap, parentValue, relationsRefuse)
if cerr != nil {
cerr.write(w, r)
Expand Down
Loading
Loading