@thehammer/danx-dashboard-mcp 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/handlers.js CHANGED
@@ -1,3 +1,28 @@
1
+ /**
2
+ * Per-tool handlers for the dashboard MCP server.
3
+ *
4
+ * Each handler is a pure async function: validate args (Zod at the MCP
5
+ * boundary), build the HTTP request the corresponding `/api/issues/*`
6
+ * route expects, and pass the envelope back to the caller verbatim.
7
+ *
8
+ * BOARD-ONLY (DX-1171): board is the first-level concept; repo is
9
+ * DERIVED from board server-side, never passed. Every handler accepts an
10
+ * optional `board` arg — a qualified board id (`<repo>:<slug>`). When
11
+ * set, it overrides the dispatch's configured board for that single call
12
+ * (forwarded as `?board=<board>` — and into `body.board` for create — so
13
+ * an agent can target any board). Omitted → falls back to the dispatch's
14
+ * env-derived board. The dashboard route validates the board (owning
15
+ * board→repo) and 404s on unknown; the MCP forwards the refusal envelope
16
+ * verbatim, never duplicates validation.
17
+ *
18
+ * Zero transformation of the server response. Refusal payloads carry
19
+ * structured detail (`failed_gate`, `non_terminal_phases`,
20
+ * `offending_keys`, `received_reason`, etc.) that the agent uses to
21
+ * decide its next move; swallowing any of it would force the agent to
22
+ * guess.
23
+ */
24
+ import { readFile } from "node:fs/promises";
25
+ import { basename, extname, isAbsolute } from "node:path";
1
26
  export async function issueList(client, args) {
2
27
  // status_derived is display-only on the server but the route accepts
3
28
  // it as a projection filter — passthrough verbatim. parent_id=null is
@@ -180,6 +205,111 @@ export async function issueDependency(client, args) {
180
205
  board,
181
206
  });
182
207
  }
208
+ /**
209
+ * DX-1362 — targeted checklist CUD. Action-dispatched onto the
210
+ * `/api/issues/:id/checklists[/:cid[/items[/:iid]]]` route family so an agent
211
+ * mutates ONE checklist or item without the wholesale `issue_edit({checklists})`
212
+ * replace (which silently drops any omitted checklist + churns every item id).
213
+ * A missing required arg for the chosen action throws at this boundary (no
214
+ * round-trip). The dashboard's structured error envelope passes through verbatim.
215
+ */
216
+ export async function issueChecklist(client, args) {
217
+ const idEnc = encodeURIComponent(args.id);
218
+ const board = args.board;
219
+ switch (args.action) {
220
+ case "add_list": {
221
+ if (typeof args.name !== "string") {
222
+ throw new Error("issue_checklist action=add_list requires name");
223
+ }
224
+ const body = { name: args.name };
225
+ if (args.items !== undefined)
226
+ body.items = args.items;
227
+ return client.request({
228
+ method: "POST",
229
+ path: `/${idEnc}/checklists`,
230
+ body,
231
+ board,
232
+ });
233
+ }
234
+ case "update_list": {
235
+ if (args.checklist_id === undefined) {
236
+ throw new Error("issue_checklist action=update_list requires checklist_id");
237
+ }
238
+ if (typeof args.name !== "string") {
239
+ throw new Error("issue_checklist action=update_list requires name");
240
+ }
241
+ return client.request({
242
+ method: "PATCH",
243
+ path: `/${idEnc}/checklists/${args.checklist_id}`,
244
+ body: { name: args.name },
245
+ board,
246
+ });
247
+ }
248
+ case "remove_list": {
249
+ if (args.checklist_id === undefined) {
250
+ throw new Error("issue_checklist action=remove_list requires checklist_id");
251
+ }
252
+ return client.request({
253
+ method: "DELETE",
254
+ path: `/${idEnc}/checklists/${args.checklist_id}`,
255
+ board,
256
+ });
257
+ }
258
+ case "add_item": {
259
+ if (args.checklist_id === undefined) {
260
+ throw new Error("issue_checklist action=add_item requires checklist_id");
261
+ }
262
+ if (typeof args.label !== "string") {
263
+ throw new Error("issue_checklist action=add_item requires label");
264
+ }
265
+ const body = { label: args.label };
266
+ if (args.detail !== undefined)
267
+ body.detail = args.detail;
268
+ if (args.status !== undefined)
269
+ body.status = args.status;
270
+ return client.request({
271
+ method: "POST",
272
+ path: `/${idEnc}/checklists/${args.checklist_id}/items`,
273
+ body,
274
+ board,
275
+ });
276
+ }
277
+ case "update_item": {
278
+ if (args.checklist_id === undefined) {
279
+ throw new Error("issue_checklist action=update_item requires checklist_id");
280
+ }
281
+ if (args.item_id === undefined) {
282
+ throw new Error("issue_checklist action=update_item requires item_id");
283
+ }
284
+ const body = {};
285
+ if (args.label !== undefined)
286
+ body.label = args.label;
287
+ if (args.detail !== undefined)
288
+ body.detail = args.detail;
289
+ if (args.status !== undefined)
290
+ body.status = args.status;
291
+ return client.request({
292
+ method: "PATCH",
293
+ path: `/${idEnc}/checklists/${args.checklist_id}/items/${args.item_id}`,
294
+ body,
295
+ board,
296
+ });
297
+ }
298
+ case "remove_item": {
299
+ if (args.checklist_id === undefined) {
300
+ throw new Error("issue_checklist action=remove_item requires checklist_id");
301
+ }
302
+ if (args.item_id === undefined) {
303
+ throw new Error("issue_checklist action=remove_item requires item_id");
304
+ }
305
+ return client.request({
306
+ method: "DELETE",
307
+ path: `/${idEnc}/checklists/${args.checklist_id}/items/${args.item_id}`,
308
+ board,
309
+ });
310
+ }
311
+ }
312
+ }
183
313
  export async function issueRequiresHuman(client, args) {
184
314
  const idEnc = encodeURIComponent(args.id);
185
315
  const board = args.board;
@@ -212,3 +342,70 @@ export async function issueRetro(client, args) {
212
342
  board,
213
343
  });
214
344
  }
345
+ /**
346
+ * Extension → MIME map for the agent-attached file types that matter for
347
+ * downstream rendering (image previews in the dashboard drawer, Trello card,
348
+ * and Slack thread all key on the persisted `mime`). This package is a
349
+ * standalone published artifact and cannot import the backend's helpers, so
350
+ * the small map lives here. An unknown extension resolves to the generic
351
+ * octet-stream — a sensible default for "we don't know the type", NOT a
352
+ * silenced error (the bytes still upload + the row is still written).
353
+ */
354
+ const MIME_BY_EXT = {
355
+ ".png": "image/png",
356
+ ".jpg": "image/jpeg",
357
+ ".jpeg": "image/jpeg",
358
+ ".gif": "image/gif",
359
+ ".webp": "image/webp",
360
+ ".svg": "image/svg+xml",
361
+ ".pdf": "application/pdf",
362
+ ".csv": "text/csv",
363
+ ".txt": "text/plain",
364
+ ".log": "text/plain",
365
+ ".md": "text/markdown",
366
+ ".json": "application/json",
367
+ ".html": "text/html",
368
+ ".zip": "application/zip",
369
+ ".gz": "application/gzip",
370
+ ".tgz": "application/gzip",
371
+ };
372
+ export function inferAttachmentMime(filename) {
373
+ return MIME_BY_EXT[extname(filename).toLowerCase()] ?? "application/octet-stream";
374
+ }
375
+ /**
376
+ * Attach a local file to an issue card. Reads the bytes from the dispatch's
377
+ * shared filesystem (this MCP server runs co-located with the agent), then
378
+ * forwards to the dashboard's `POST /api/issues/:id/attachments` route — the
379
+ * SAME ingest the dashboard UI upload uses, which uploads to S3, inserts ONE
380
+ * danxbot-origin `issue_attachments` row (`source_type=''`), and publishes
381
+ * `issue:updated` so the DX-1122 outbound projection mirrors the file to both
382
+ * the linked Trello card and the Slack card-view thread.
383
+ *
384
+ * Fail-loud (`base:fail-loudly`): the path is validated (non-empty + absolute)
385
+ * and the file is read BEFORE any HTTP call, so a bad/missing/unreadable path
386
+ * throws here and NEVER reaches S3, the DB row, or the projection. An S3 /
387
+ * server fault on the route surfaces as a 5xx, which the HTTP client throws.
388
+ */
389
+ export async function issueAttach(client, args, deps = {}) {
390
+ if (typeof args.file_path !== "string" || args.file_path.trim() === "") {
391
+ throw new Error("issue_attach: file_path must be a non-empty string");
392
+ }
393
+ if (!isAbsolute(args.file_path)) {
394
+ throw new Error(`issue_attach: file_path must be an absolute path (got "${args.file_path}")`);
395
+ }
396
+ const read = deps.readFile ?? readFile;
397
+ // A missing / unreadable file rejects here — no HTTP call is made, so no
398
+ // S3 upload, no row insert, and no projection are ever triggered.
399
+ const bytes = await read(args.file_path);
400
+ const filename = basename(args.file_path);
401
+ return client.request({
402
+ method: "POST",
403
+ path: `/${encodeURIComponent(args.id)}/attachments`,
404
+ body: {
405
+ filename,
406
+ contentType: inferAttachmentMime(filename),
407
+ base64: bytes.toString("base64"),
408
+ },
409
+ board: args.board,
410
+ });
411
+ }
package/dist/index.js CHANGED
@@ -18,9 +18,11 @@
18
18
  * - issue_transition POST /api/issues/:id/transition
19
19
  * - issue_triage POST /api/issues/:id/triage
20
20
  * - issue_comment POST/PATCH/DELETE /api/issues/:id/comments[/:cid]
21
+ * - issue_checklist POST/PATCH/DELETE /api/issues/:id/checklists[/:cid[/items[/:iid]]]
21
22
  * - issue_dependency POST/DELETE /api/issues/:id/dependencies[/:did]
22
23
  * - issue_requires_human POST/DELETE /api/issues/:id/requires-human
23
24
  * - issue_retro PUT /api/issues/:id/retro
25
+ * - issue_attach POST /api/issues/:id/attachments (reads a local file)
24
26
  *
25
27
  * BOARD-ONLY (DX-1171): board is the first-level concept; repo is
26
28
  * DERIVED from board server-side, never passed. The package composes the
@@ -49,7 +51,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
49
51
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
50
52
  import { z } from "zod";
51
53
  import { DashboardHttpClient } from "./http-client.js";
52
- import { issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueRequiresHuman, issueRetro, issueTransition, issueTriage, } from "./handlers.js";
54
+ import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueRequiresHuman, issueRetro, issueTransition, issueTriage, } from "./handlers.js";
53
55
  function readEnvOrDie(name) {
54
56
  const v = process.env[name];
55
57
  if (typeof v !== "string" || v === "") {
@@ -228,6 +230,31 @@ server.tool("issue_comment", "Comment CRUD via /api/issues/:id/comments[/:cid].
228
230
  text: z.string().min(1).optional(),
229
231
  ...boardField,
230
232
  }, async (args) => jsonResult(await issueComment(client, args)));
233
+ // ---------------- issue_checklist ----------------
234
+ const CHECKLIST_ITEM_INPUT = z.object({
235
+ label: z.string().min(1),
236
+ detail: z.string().optional(),
237
+ status: z.enum(CHECKLIST_ITEM_STATUSES).optional(),
238
+ });
239
+ server.tool("issue_checklist", "Targeted checklist CUD via /api/issues/:id/checklists[/:cid[/items[/:iid]]] (DX-1362). Mutates ONE checklist or item WITHOUT the wholesale `issue_edit({checklists})` replace — use this for the common case (flip an item's status, add/rename a checklist, add/edit/remove an item); the wholesale path silently DROPS any checklist you omit and churns every item id (orphaning its Trello mirror), so prefer this for single-item changes. Action-dispatched: add_list (POST :id/checklists {name, items?}) — create a named checklist, optionally with initial items; update_list (PATCH :id/checklists/:cid {name}) — rename; remove_list (DELETE :id/checklists/:cid) — soft-delete the checklist (audit trail preserved); add_item (POST :id/checklists/:cid/items {label, detail?, status?}) — append an item (status defaults `incomplete`); update_item (PATCH :id/checklists/:cid/items/:iid {label?, detail?, status?}) — change ONLY the fields you pass, in place (the item keeps its id + Trello linkage; at least one field required); remove_item (DELETE :id/checklists/:cid/items/:iid) — soft-delete one item. 4-state status: incomplete|failing|passing|cancelled (terminal = passing|cancelled). checklist_id is required for every action except add_list; item_id for update_item/remove_item. Each returns the {ok,status,body} envelope; unknown card/checklist/item → 404, invalid status → 400. ADDITIVE — the wholesale `issue_edit({checklists})` path stays for bulk authoring.", {
240
+ id: z.string().min(1),
241
+ action: z.enum([
242
+ "add_list",
243
+ "update_list",
244
+ "remove_list",
245
+ "add_item",
246
+ "update_item",
247
+ "remove_item",
248
+ ]),
249
+ checklist_id: z.number().int().positive().optional(),
250
+ item_id: z.number().int().positive().optional(),
251
+ name: z.string().min(1).optional(),
252
+ items: z.array(CHECKLIST_ITEM_INPUT).optional(),
253
+ label: z.string().min(1).optional(),
254
+ detail: z.string().optional(),
255
+ status: z.enum(CHECKLIST_ITEM_STATUSES).optional(),
256
+ ...boardField,
257
+ }, async (args) => jsonResult(await issueChecklist(client, args)));
231
258
  // ---------------- issue_dependency ----------------
232
259
  server.tool("issue_dependency", "Dependency CRUD via /api/issues/:id/dependencies[/:did]. action=add → POST {kind, target_id, reason} where kind ∈ {depends_on, conflict_on}. depends_on adds are CYCLE-CHECKED (BFS from target back to source — 409 if loop). Idempotent: re-adding a live triple returns the existing id. Self-loops refuse 409. action=remove → DELETE /:did. The server REQUIRES the literal reason=\"recorded_in_error\" on removal (encodes \"removal means NOT related, never satisfied\") — this MCP boundary hardcodes it, so callers do not pass reason on remove.", {
233
260
  id: z.string().min(1),
@@ -258,6 +285,19 @@ server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retr
258
285
  })),
259
286
  ...boardField,
260
287
  }, async (args) => jsonResult(await issueRetro(client, args)));
288
+ // ---------------- issue_attach ----------------
289
+ // The "25 MB" ceiling quoted in the description below tracks the backend
290
+ // route's MAX_DECODED_BYTES (src/issues/write/attachments.ts). This package is
291
+ // a separate published artifact and cannot import that constant, so the number
292
+ // is restated here as prose — keep the two in sync if the backend ceiling moves.
293
+ server.tool("issue_attach", "Attach a LOCAL file to an issue card via POST /api/issues/:id/attachments. Pass `id` (the card) and `file_path` (an ABSOLUTE path to a file on the dispatch's shared filesystem — e.g. a screenshot, exported CSV, or diagram you wrote). This MCP server reads the bytes, infers the MIME type from the extension, and uploads through the dashboard, which: stores the bytes in S3, inserts ONE danxbot-origin issue_attachments row, and auto-mirrors the file to the card's linked Trello card AND its Slack card-view thread (DX-1122 outbound projection) — no extra step needed. Board-scoped; defaults to the dispatch's board. Pass `board` (a qualified id `<repo>:<slug>`) to attach on another board (unknown board → 404). Fail-loud: a relative/empty path is rejected at the MCP boundary, and a missing/unreadable file throws BEFORE any upload (no partial S3 object, no row). 25 MB decoded ceiling (413). Returns the hydrated issue plus the new attachment id.", {
294
+ id: z.string().min(1),
295
+ file_path: z
296
+ .string()
297
+ .min(1)
298
+ .describe("Absolute path to a local file on the dispatch's shared filesystem (must start with `/`)."),
299
+ ...boardField,
300
+ }, async (args) => jsonResult(await issueAttach(client, args)));
261
301
  // ---------------- main ----------------
262
302
  async function main() {
263
303
  const transport = new StdioServerTransport();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Stdio MCP server wrapping danxbot's dashboard /api/issues/* normalized DB-backed HTTP routes for dispatched agents (DX-704 Phase 2).",
5
5
  "license": "MIT",
6
6
  "type": "module",