@thehammer/danx-dashboard-mcp 0.1.16 → 0.1.18
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 +92 -0
- package/dist/index.js +34 -3
- package/package.json +1 -1
- package/dist/handlers.test.js +0 -51
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
|
|
@@ -212,3 +237,70 @@ export async function issueRetro(client, args) {
|
|
|
212
237
|
board,
|
|
213
238
|
});
|
|
214
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Extension → MIME map for the agent-attached file types that matter for
|
|
242
|
+
* downstream rendering (image previews in the dashboard drawer, Trello card,
|
|
243
|
+
* and Slack thread all key on the persisted `mime`). This package is a
|
|
244
|
+
* standalone published artifact and cannot import the backend's helpers, so
|
|
245
|
+
* the small map lives here. An unknown extension resolves to the generic
|
|
246
|
+
* octet-stream — a sensible default for "we don't know the type", NOT a
|
|
247
|
+
* silenced error (the bytes still upload + the row is still written).
|
|
248
|
+
*/
|
|
249
|
+
const MIME_BY_EXT = {
|
|
250
|
+
".png": "image/png",
|
|
251
|
+
".jpg": "image/jpeg",
|
|
252
|
+
".jpeg": "image/jpeg",
|
|
253
|
+
".gif": "image/gif",
|
|
254
|
+
".webp": "image/webp",
|
|
255
|
+
".svg": "image/svg+xml",
|
|
256
|
+
".pdf": "application/pdf",
|
|
257
|
+
".csv": "text/csv",
|
|
258
|
+
".txt": "text/plain",
|
|
259
|
+
".log": "text/plain",
|
|
260
|
+
".md": "text/markdown",
|
|
261
|
+
".json": "application/json",
|
|
262
|
+
".html": "text/html",
|
|
263
|
+
".zip": "application/zip",
|
|
264
|
+
".gz": "application/gzip",
|
|
265
|
+
".tgz": "application/gzip",
|
|
266
|
+
};
|
|
267
|
+
export function inferAttachmentMime(filename) {
|
|
268
|
+
return MIME_BY_EXT[extname(filename).toLowerCase()] ?? "application/octet-stream";
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Attach a local file to an issue card. Reads the bytes from the dispatch's
|
|
272
|
+
* shared filesystem (this MCP server runs co-located with the agent), then
|
|
273
|
+
* forwards to the dashboard's `POST /api/issues/:id/attachments` route — the
|
|
274
|
+
* SAME ingest the dashboard UI upload uses, which uploads to S3, inserts ONE
|
|
275
|
+
* danxbot-origin `issue_attachments` row (`source_type=''`), and publishes
|
|
276
|
+
* `issue:updated` so the DX-1122 outbound projection mirrors the file to both
|
|
277
|
+
* the linked Trello card and the Slack card-view thread.
|
|
278
|
+
*
|
|
279
|
+
* Fail-loud (`base:fail-loudly`): the path is validated (non-empty + absolute)
|
|
280
|
+
* and the file is read BEFORE any HTTP call, so a bad/missing/unreadable path
|
|
281
|
+
* throws here and NEVER reaches S3, the DB row, or the projection. An S3 /
|
|
282
|
+
* server fault on the route surfaces as a 5xx, which the HTTP client throws.
|
|
283
|
+
*/
|
|
284
|
+
export async function issueAttach(client, args, deps = {}) {
|
|
285
|
+
if (typeof args.file_path !== "string" || args.file_path.trim() === "") {
|
|
286
|
+
throw new Error("issue_attach: file_path must be a non-empty string");
|
|
287
|
+
}
|
|
288
|
+
if (!isAbsolute(args.file_path)) {
|
|
289
|
+
throw new Error(`issue_attach: file_path must be an absolute path (got "${args.file_path}")`);
|
|
290
|
+
}
|
|
291
|
+
const read = deps.readFile ?? readFile;
|
|
292
|
+
// A missing / unreadable file rejects here — no HTTP call is made, so no
|
|
293
|
+
// S3 upload, no row insert, and no projection are ever triggered.
|
|
294
|
+
const bytes = await read(args.file_path);
|
|
295
|
+
const filename = basename(args.file_path);
|
|
296
|
+
return client.request({
|
|
297
|
+
method: "POST",
|
|
298
|
+
path: `/${encodeURIComponent(args.id)}/attachments`,
|
|
299
|
+
body: {
|
|
300
|
+
filename,
|
|
301
|
+
contentType: inferAttachmentMime(filename),
|
|
302
|
+
base64: bytes.toString("base64"),
|
|
303
|
+
},
|
|
304
|
+
board: args.board,
|
|
305
|
+
});
|
|
306
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* - issue_dependency POST/DELETE /api/issues/:id/dependencies[/:did]
|
|
22
22
|
* - issue_requires_human POST/DELETE /api/issues/:id/requires-human
|
|
23
23
|
* - issue_retro PUT /api/issues/:id/retro
|
|
24
|
+
* - issue_attach POST /api/issues/:id/attachments (reads a local file)
|
|
24
25
|
*
|
|
25
26
|
* BOARD-ONLY (DX-1171): board is the first-level concept; repo is
|
|
26
27
|
* DERIVED from board server-side, never passed. The package composes the
|
|
@@ -49,7 +50,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
49
50
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
50
51
|
import { z } from "zod";
|
|
51
52
|
import { DashboardHttpClient } from "./http-client.js";
|
|
52
|
-
import { issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueRequiresHuman, issueRetro, issueTransition, issueTriage, } from "./handlers.js";
|
|
53
|
+
import { issueAttach, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueRequiresHuman, issueRetro, issueTransition, issueTriage, } from "./handlers.js";
|
|
53
54
|
function readEnvOrDie(name) {
|
|
54
55
|
const v = process.env[name];
|
|
55
56
|
if (typeof v !== "string" || v === "") {
|
|
@@ -93,6 +94,13 @@ const EFFORT_VALUES = [
|
|
|
93
94
|
];
|
|
94
95
|
const ISSUE_TYPES = ["Epic", "Bug", "Feature", "Story", "Chore"];
|
|
95
96
|
const NON_EPIC_TYPES = ["Bug", "Feature", "Story", "Chore"];
|
|
97
|
+
// DX-1290 — the uniform 4-state checklist-item status. Terminal = passing|cancelled.
|
|
98
|
+
const CHECKLIST_ITEM_STATUSES = [
|
|
99
|
+
"incomplete",
|
|
100
|
+
"failing",
|
|
101
|
+
"passing",
|
|
102
|
+
"cancelled",
|
|
103
|
+
];
|
|
96
104
|
const TRANSITION_ACTIONS = [
|
|
97
105
|
"ready",
|
|
98
106
|
"pickup",
|
|
@@ -134,7 +142,7 @@ server.tool("issue_list", "List issues for the dispatch's board by default via G
|
|
|
134
142
|
...boardField,
|
|
135
143
|
}, async (args) => jsonResult(await issueList(client, args)));
|
|
136
144
|
// ---------------- issue_get ----------------
|
|
137
|
-
server.tool("issue_get", "Fetch a single hydrated issue via GET /api/issues/:id. Board-scoped; defaults to the dispatch's board. Issue ids are globally unique, so this resolves from any dispatch regardless of `board`. Returns the full card (every joined child collection: ac, comments, dependencies, requires_human steps, retro action items + commits, triage history, quality_gates — DX-1177: one row per registered quality gate {gate, required, status pending|pass|fail, completed_at, message}; a required PRE gate not yet `pass` pre-empts the work dispatch with the gate reviewer, and `issue_transition complete` refuses while a required POST gate row != pass) plus the ancestor chain walked via parent_id. 404 envelope on unknown id.", {
|
|
145
|
+
server.tool("issue_get", "Fetch a single hydrated issue via GET /api/issues/:id. Board-scoped; defaults to the dispatch's board. Issue ids are globally unique, so this resolves from any dispatch regardless of `board`. Returns the full card (every joined child collection: ac [the 2-state facade onto the default \"Acceptance Criteria\" checklist] + checklists [DX-1290: the full named-checklist model — each {name, items:[{label, detail, status: incomplete|failing|passing|cancelled}]}], comments, dependencies, requires_human steps, retro action items + commits, triage history, quality_gates — DX-1177: one row per registered quality gate {gate, required, status pending|pass|fail, completed_at, message}; a required PRE gate not yet `pass` pre-empts the work dispatch with the gate reviewer, and `issue_transition complete` refuses while a required POST gate row != pass) plus the ancestor chain walked via parent_id. 404 envelope on unknown id.", {
|
|
138
146
|
id: z.string().min(1),
|
|
139
147
|
...boardField,
|
|
140
148
|
}, async (args) => jsonResult(await issueGet(client, args)));
|
|
@@ -163,7 +171,7 @@ server.tool("issue_create", "Create a fresh card via POST /api/issues. Board-sco
|
|
|
163
171
|
...boardField,
|
|
164
172
|
}, async (args) => jsonResult(await issueCreate(client, args, config.board)));
|
|
165
173
|
// ---------------- issue_edit ----------------
|
|
166
|
-
server.tool("issue_edit", "Patch prose fields only via PATCH /api/issues/:id/edit. ALLOWED keys: title, description, ac, effort_level, parent_id, list_id. ANY OTHER KEY (lifecycle timestamps, triage state, dependencies, retro, requires_human, blocked/dispatch gates) returns 400 with offending_keys[] and a pointer to the dedicated semantic handler — use issue_transition / issue_triage / issue_comment / issue_dependency / issue_requires_human / issue_retro instead.
|
|
174
|
+
server.tool("issue_edit", "Patch prose fields only via PATCH /api/issues/:id/edit. ALLOWED keys: title, description, ac, checklists, effort_level, parent_id, list_id. ANY OTHER KEY (lifecycle timestamps, triage state, dependencies, retro, requires_human, blocked/dispatch gates) returns 400 with offending_keys[] and a pointer to the dedicated semantic handler — use issue_transition / issue_triage / issue_comment / issue_dependency / issue_requires_human / issue_retro instead. CHECKLISTS (DX-1290): a card carries 0..N named checklists, each item ONE 4-state status `incomplete|failing|passing|cancelled` (terminal = passing|cancelled). `ac` is the 2-state CONVENIENCE onto the default \"Acceptance Criteria\" checklist (checked:true ↔ passing, false ↔ incomplete) — wholesale soft-delete + reinsert of that checklist's items. `checklists` is the GENERIC wholesale write path: it REPLACES every named checklist on the card with full 4-state control (each `{name, items:[{label, detail?, status}]}`) — use it to author named checklists like \"Feature Tests\". Send EITHER `ac` OR `checklists`, NOT both (400). list_id (DX-1192 / DX-1200) PINS the card to a specific board list. **Pass EITHER a board_lists id OR the list's display NAME (case-insensitive, e.g. a queue name like \"⚙️ Fulfillment Queue\") — the server resolves a name to its id.** Its type MUST match the card's CURRENT derived-status list-type (so to route a ToDo card into a `ready`-type queue, ready it first; mismatch / unknown name or id → 400); pass null to clear the pin (back to default-for-type).", {
|
|
167
175
|
id: z.string().min(1),
|
|
168
176
|
title: z.string().min(1).optional(),
|
|
169
177
|
description: z.string().optional(),
|
|
@@ -173,6 +181,16 @@ server.tool("issue_edit", "Patch prose fields only via PATCH /api/issues/:id/edi
|
|
|
173
181
|
checked: z.boolean().optional(),
|
|
174
182
|
}))
|
|
175
183
|
.optional(),
|
|
184
|
+
checklists: z
|
|
185
|
+
.array(z.object({
|
|
186
|
+
name: z.string().min(1),
|
|
187
|
+
items: z.array(z.object({
|
|
188
|
+
label: z.string().min(1),
|
|
189
|
+
detail: z.string().optional(),
|
|
190
|
+
status: z.enum(CHECKLIST_ITEM_STATUSES),
|
|
191
|
+
})),
|
|
192
|
+
}))
|
|
193
|
+
.optional(),
|
|
176
194
|
effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
|
|
177
195
|
parent_id: z.string().nullable().optional(),
|
|
178
196
|
list_id: z.string().min(1).nullable().optional(),
|
|
@@ -241,6 +259,19 @@ server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retr
|
|
|
241
259
|
})),
|
|
242
260
|
...boardField,
|
|
243
261
|
}, async (args) => jsonResult(await issueRetro(client, args)));
|
|
262
|
+
// ---------------- issue_attach ----------------
|
|
263
|
+
// The "25 MB" ceiling quoted in the description below tracks the backend
|
|
264
|
+
// route's MAX_DECODED_BYTES (src/issues/write/attachments.ts). This package is
|
|
265
|
+
// a separate published artifact and cannot import that constant, so the number
|
|
266
|
+
// is restated here as prose — keep the two in sync if the backend ceiling moves.
|
|
267
|
+
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.", {
|
|
268
|
+
id: z.string().min(1),
|
|
269
|
+
file_path: z
|
|
270
|
+
.string()
|
|
271
|
+
.min(1)
|
|
272
|
+
.describe("Absolute path to a local file on the dispatch's shared filesystem (must start with `/`)."),
|
|
273
|
+
...boardField,
|
|
274
|
+
}, async (args) => jsonResult(await issueAttach(client, args)));
|
|
244
275
|
// ---------------- main ----------------
|
|
245
276
|
async function main() {
|
|
246
277
|
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.
|
|
3
|
+
"version": "0.1.18",
|
|
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",
|
package/dist/handlers.test.js
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { DashboardHttpClient } from "./http-client.js";
|
|
3
|
-
import { issueList, issueTransition } from "./handlers.js";
|
|
4
|
-
/**
|
|
5
|
-
* `issue_list` query-param forwarding. A fake `fetch` captures the built
|
|
6
|
-
* URL so each filter is asserted at the wire, through the real client's
|
|
7
|
-
* URL builder (`?repo=` always stamped, extra query merged after).
|
|
8
|
-
*/
|
|
9
|
-
function clientCapturing() {
|
|
10
|
-
const urls = [];
|
|
11
|
-
const fetchImpl = (async (url) => {
|
|
12
|
-
urls.push(url);
|
|
13
|
-
return new Response(JSON.stringify({ issues: [] }), { status: 200 });
|
|
14
|
-
});
|
|
15
|
-
const client = new DashboardHttpClient({ baseUrl: "http://localhost:5555", repo: "danxbot", token: "t" }, fetchImpl);
|
|
16
|
-
return { client, urls };
|
|
17
|
-
}
|
|
18
|
-
describe("issueList — query forwarding", () => {
|
|
19
|
-
it("forwards q as the server-side search needle", async () => {
|
|
20
|
-
const { client, urls } = clientCapturing();
|
|
21
|
-
await issueList(client, { q: "retire" });
|
|
22
|
-
expect(urls[0]).toContain("q=retire");
|
|
23
|
-
});
|
|
24
|
-
it("omits q when not provided", async () => {
|
|
25
|
-
const { client, urls } = clientCapturing();
|
|
26
|
-
await issueList(client, { include_closed: true });
|
|
27
|
-
expect(urls[0]).not.toContain("q=");
|
|
28
|
-
expect(urls[0]).toContain("include_closed=true");
|
|
29
|
-
});
|
|
30
|
-
});
|
|
31
|
-
describe("issueTransition — body forwarding", () => {
|
|
32
|
-
function clientCapturingBody() {
|
|
33
|
-
const bodies = [];
|
|
34
|
-
const fetchImpl = (async (_url, init) => {
|
|
35
|
-
bodies.push(JSON.parse(String(init?.body ?? "{}")));
|
|
36
|
-
return new Response(JSON.stringify({ issue: {} }), { status: 200 });
|
|
37
|
-
});
|
|
38
|
-
const client = new DashboardHttpClient({ baseUrl: "http://localhost:5555", repo: "danxbot", token: "t" }, fetchImpl);
|
|
39
|
-
return { client, bodies };
|
|
40
|
-
}
|
|
41
|
-
it("forwards manual: true on pickup (DX-946 operator self-pickup)", async () => {
|
|
42
|
-
const { client, bodies } = clientCapturingBody();
|
|
43
|
-
await issueTransition(client, { id: "DX-1", action: "pickup", manual: true });
|
|
44
|
-
expect(bodies[0]).toEqual({ action: "pickup", manual: true });
|
|
45
|
-
});
|
|
46
|
-
it("omits manual when not provided", async () => {
|
|
47
|
-
const { client, bodies } = clientCapturingBody();
|
|
48
|
-
await issueTransition(client, { id: "DX-1", action: "pickup" });
|
|
49
|
-
expect(bodies[0]).toEqual({ action: "pickup" });
|
|
50
|
-
});
|
|
51
|
-
});
|