From 47ab2d8a10e685cb26fa19a078632ab66671335b Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Sat, 9 May 2026 13:52:01 -0700 Subject: [PATCH] feat(cues): add cueapi_bulk_delete_cues tool (cueapi #650 / cli #46 parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds cueapi_bulk_delete_cues — wraps POST /v1/cues/bulk-delete (cueapi PR #650; cli ported via PR #46). Closes (in part) Backlog row cmousydyn (mistitled 'messages' → corrected to 'cues' by cue-pm 2026-05-09 ~20:45Z). Surface: - Schema: bulkDeleteCuesSchema accepts ids: array(string).min(1).max(100) - Tool: cueapi_bulk_delete_cues with handler that POSTs to /v1/cues/bulk-delete with body {ids: [...]} + extraHeaders {"X-Confirm-Destructive": "true"} (server requires it). - Returns server's flat shape: {deleted: [...], skipped: [...]}. Schema validation is enforced at MCP boundary (Zod) before any HTTP call — empty array rejected, > 100 rejected, exactly-100 accepted. Tests: 4 new in tools.test.ts — registered name, empty rejection, overflow rejection, exactly-100 boundary, handler HTTP contract pin (method/path/body/headers). All 117 tests pass (was 113). Parity-manifest updated: added POST /v1/cues/bulk-delete row mapping to the new tool, between existing DELETE /v1/cues/{id} and POST /v1/cues/{id}/fire entries. Companion ports: - cueapi-python PR #37 (merged 2026-05-09 20:49Z) — same row, SDK piece - cueapi-action PR (forthcoming) — same row, action.yml piece Co-Authored-By: Claude Opus 4.7 (1M context) --- parity-manifest.json | 1 + src/tools.ts | 25 +++++++++++++++++++++++ tests/tools.test.ts | 47 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/parity-manifest.json b/parity-manifest.json index d11c638..5be1fb6 100644 --- a/parity-manifest.json +++ b/parity-manifest.json @@ -17,6 +17,7 @@ "GET /v1/cues/{id}": {"tool": "cueapi_get_cue"}, "PATCH /v1/cues/{id}": {"tool": "cueapi_update_cue, cueapi_pause_cue, cueapi_resume_cue (pause/resume are PATCH variants with status field, not separate endpoints)"}, "DELETE /v1/cues/{id}": {"tool": "cueapi_delete_cue"}, + "POST /v1/cues/bulk-delete": {"tool": "cueapi_bulk_delete_cues — wraps cueapi #650 / cli #46. Per-ID atomic, max 100 per call. Sends X-Confirm-Destructive: true automatically."}, "POST /v1/cues/{id}/fire": {"tool": "cueapi_fire_cue"}, "GET /v1/executions": {"tool": "cueapi_list_executions"}, "GET /v1/executions/{id}": {"tool": "cueapi_get_execution"}, diff --git a/src/tools.ts b/src/tools.ts index d21ccc2..04f9d91 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -74,6 +74,16 @@ const cueIdSchema = z.object({ cue_id: z.string().describe("CueAPI cue ID (e.g. 'cue_...')"), }); +const bulkDeleteCuesSchema = z.object({ + ids: z + .array(z.string()) + .min(1) + .max(100) + .describe( + "Cue IDs to delete (1-100 per call). Per-ID atomic, NOT batch atomic — IDs that don't exist OR aren't owned by the caller land in the response's 'skipped' array (silent skip on miss; no info leak about other tenants' cues)." + ), +}); + const updateCueSchema = z.object({ cue_id: z.string().describe("CueAPI cue ID to update"), name: z.string().min(1).optional().describe("New cue name"), @@ -405,6 +415,21 @@ export const tools: ToolDefinition[] = [ `/v1/cues/${encodeURIComponent(args.cue_id)}` ), }, + { + name: "cueapi_bulk_delete_cues", + description: + "Delete multiple cues in a single call (max 100). Returns {deleted, skipped} — per-ID atomic, not batch atomic. IDs that don't exist or aren't owned by the caller land in 'skipped' (silent skip on miss; no info leak about other tenants' cues). Cascade FK handles executions + dispatch_outbox cleanup. Sends X-Confirm-Destructive: true automatically (server requires it for any bulk-destructive endpoint).", + schema: bulkDeleteCuesSchema, + handler: async (client, args) => + client.request( + "POST", + "/v1/cues/bulk-delete", + { ids: args.ids }, + undefined, + undefined, + { "X-Confirm-Destructive": "true" } + ), + }, { name: "cueapi_list_executions", description: diff --git a/tests/tools.test.ts b/tests/tools.test.ts index bfc739c..254b5e8 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -1227,3 +1227,50 @@ describe("cueapi_send_message — HTTP contract (PR #619 BCC-light)", () => { }); }); }); + +describe("cueapi_bulk_delete_cues — schema + HTTP contract", () => { + it("registers the tool with the cueapi_* naming convention", () => { + const tool = tools.find((t) => t.name === "cueapi_bulk_delete_cues"); + expect(tool).toBeDefined(); + expect(tool!.description.length).toBeGreaterThan(20); + }); + + it("schema rejects empty ids array", () => { + const tool = tools.find((t) => t.name === "cueapi_bulk_delete_cues")!; + expect(() => tool.schema.parse({ ids: [] })).toThrow(); + }); + + it("schema rejects more than 100 ids", () => { + const tool = tools.find((t) => t.name === "cueapi_bulk_delete_cues")!; + const tooMany = Array.from({ length: 101 }, (_, i) => `cue_${i}`); + expect(() => tool.schema.parse({ ids: tooMany })).toThrow(); + }); + + it("schema accepts exactly 100 ids (boundary)", () => { + const tool = tools.find((t) => t.name === "cueapi_bulk_delete_cues")!; + const exactlyMax = Array.from({ length: 100 }, (_, i) => `cue_${i}`); + expect(() => tool.schema.parse({ ids: exactlyMax })).not.toThrow(); + }); + + it("handler POSTs to /v1/cues/bulk-delete with X-Confirm-Destructive header", async () => { + const tool = tools.find((t) => t.name === "cueapi_bulk_delete_cues")!; + const calls: unknown[] = []; + const mockClient = { + request: vi.fn(async (...args: unknown[]) => { + calls.push(args); + return { deleted: ["cue_a"], skipped: [] }; + }), + } as unknown as CueAPIClient; + + await tool.handler(mockClient, { ids: ["cue_a"] }); + + expect(calls).toHaveLength(1); + const [method, path, body, query, apiKey, headers] = calls[0] as unknown[]; + expect(method).toBe("POST"); + expect(path).toBe("/v1/cues/bulk-delete"); + expect(body).toEqual({ ids: ["cue_a"] }); + expect(query).toBeUndefined(); + expect(apiKey).toBeUndefined(); + expect(headers).toEqual({ "X-Confirm-Destructive": "true" }); + }); +});