@thehammer/danx-dashboard-mcp 0.1.19 → 0.1.21
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 +23 -1
- package/dist/handlers.test.js +51 -0
- package/dist/http-client.js +7 -0
- package/dist/index.js +35 -5
- package/package.json +1 -1
package/dist/handlers.js
CHANGED
|
@@ -333,6 +333,28 @@ export async function issueRequiresHuman(client, args) {
|
|
|
333
333
|
board,
|
|
334
334
|
});
|
|
335
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* Flip a single card's per-card quality-gate `required` flag via
|
|
338
|
+
* POST /api/issues/:id/quality-gates/:gate {required} — the same write the
|
|
339
|
+
* dashboard drawer's Quality Gates tab performs (DX-1181). This is the ONLY
|
|
340
|
+
* post-create path to mark a gate required/not — `issue_create` carries
|
|
341
|
+
* `required_gates[]` at birth, and `issue_edit` rejects gate keys; without
|
|
342
|
+
* this tool an agent that created a card cannot turn a gate on afterward.
|
|
343
|
+
*
|
|
344
|
+
* `gate` is a registry name (`plan-dependency` | `plan-architecture` |
|
|
345
|
+
* `plan-tdd` | `code-test-quality` | `code-architecture` | `code-quality`).
|
|
346
|
+
* An unknown gate is a 400 from the server (never a silent no-op). The
|
|
347
|
+
* board-level master switch is a separate surface — a gate only RUNS when
|
|
348
|
+
* the board enables it AND the card marks it required.
|
|
349
|
+
*/
|
|
350
|
+
export async function issueQualityGate(client, args) {
|
|
351
|
+
return client.request({
|
|
352
|
+
method: "POST",
|
|
353
|
+
path: `/${encodeURIComponent(args.id)}/quality-gates/${encodeURIComponent(args.gate)}`,
|
|
354
|
+
body: { required: args.required },
|
|
355
|
+
board: args.board,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
336
358
|
export async function issueRetro(client, args) {
|
|
337
359
|
const { id, board, ...body } = args;
|
|
338
360
|
return client.request({
|
|
@@ -370,7 +392,7 @@ const MIME_BY_EXT = {
|
|
|
370
392
|
".tgz": "application/gzip",
|
|
371
393
|
};
|
|
372
394
|
export function inferAttachmentMime(filename) {
|
|
373
|
-
return MIME_BY_EXT[extname(filename).toLowerCase()] ?? "application/octet-stream";
|
|
395
|
+
return (MIME_BY_EXT[extname(filename).toLowerCase()] ?? "application/octet-stream");
|
|
374
396
|
}
|
|
375
397
|
/**
|
|
376
398
|
* Attach a local file to an issue card. Reads the bytes from the dispatch's
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
});
|
package/dist/http-client.js
CHANGED
|
@@ -11,6 +11,13 @@ export class DashboardHttpClient {
|
|
|
11
11
|
Authorization: `Bearer ${this.config.token}`,
|
|
12
12
|
Accept: "application/json",
|
|
13
13
|
};
|
|
14
|
+
// DX-1398 Hop 3 (outbound) — stamp the cross-process trace at the SAME
|
|
15
|
+
// single choke point that stamps Authorization, so every call (GET + every
|
|
16
|
+
// write) chains under the originating launch. Omitted when the dispatch
|
|
17
|
+
// carries no trace context (the dashboard then mints a fresh root).
|
|
18
|
+
if (this.config.traceparent) {
|
|
19
|
+
headers["traceparent"] = this.config.traceparent;
|
|
20
|
+
}
|
|
14
21
|
let bodyString;
|
|
15
22
|
if (args.body !== undefined) {
|
|
16
23
|
headers["Content-Type"] = "application/json";
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* - issue_checklist POST/PATCH/DELETE /api/issues/:id/checklists[/:cid[/items[/:iid]]]
|
|
22
22
|
* - issue_dependency POST/DELETE /api/issues/:id/dependencies[/:did]
|
|
23
23
|
* - issue_requires_human POST/DELETE /api/issues/:id/requires-human
|
|
24
|
+
* - issue_quality_gate POST /api/issues/:id/quality-gates/:gate
|
|
24
25
|
* - issue_retro PUT /api/issues/:id/retro
|
|
25
26
|
* - issue_attach POST /api/issues/:id/attachments (reads a local file)
|
|
26
27
|
*
|
|
@@ -51,7 +52,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
51
52
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
52
53
|
import { z } from "zod";
|
|
53
54
|
import { DashboardHttpClient } from "./http-client.js";
|
|
54
|
-
import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueRequiresHuman, issueRetro, issueTransition, issueTriage, } from "./handlers.js";
|
|
55
|
+
import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueQualityGate, issueRequiresHuman, issueRetro, issueTransition, issueTriage, } from "./handlers.js";
|
|
55
56
|
function readEnvOrDie(name) {
|
|
56
57
|
const v = process.env[name];
|
|
57
58
|
if (typeof v !== "string" || v === "") {
|
|
@@ -60,6 +61,17 @@ function readEnvOrDie(name) {
|
|
|
60
61
|
}
|
|
61
62
|
return v;
|
|
62
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Read an OPTIONAL env var, treating empty string as absent. DX-1398 — the
|
|
66
|
+
* worker injects `DANXBOT_TRACEPARENT` for a traced dispatch and substitutes it
|
|
67
|
+
* to `""` for an untraced one (the optional-placeholder default), so empty
|
|
68
|
+
* MUST read as "no trace" rather than a literal value. Never exits — an
|
|
69
|
+
* untraced dispatch is valid.
|
|
70
|
+
*/
|
|
71
|
+
function readEnvOptional(name) {
|
|
72
|
+
const v = process.env[name];
|
|
73
|
+
return typeof v === "string" && v !== "" ? v : undefined;
|
|
74
|
+
}
|
|
63
75
|
// Compose the dispatch's qualified board id (`<repo>:<slug>`) from the
|
|
64
76
|
// two env halves the worker injects. DANXBOT_BOARD_NAME already carries
|
|
65
77
|
// the board SLUG at the spawn site (src/dispatch/core.ts sets it from
|
|
@@ -69,6 +81,10 @@ const config = {
|
|
|
69
81
|
baseUrl: readEnvOrDie("DANXBOT_DASHBOARD_URL"),
|
|
70
82
|
token: readEnvOrDie("DANXBOT_DISPATCH_TOKEN"),
|
|
71
83
|
board: `${readEnvOrDie("DANX_REPO_NAME")}:${readEnvOrDie("DANXBOT_BOARD_NAME")}`,
|
|
84
|
+
// DX-1398 — OPTIONAL cross-process trace context. Present → stamped on every
|
|
85
|
+
// outbound call so the agent's card writes chain under the launch; absent
|
|
86
|
+
// (untraced dispatch) → omitted, and the dashboard mints a fresh root.
|
|
87
|
+
traceparent: readEnvOptional("DANXBOT_TRACEPARENT"),
|
|
72
88
|
};
|
|
73
89
|
const client = new DashboardHttpClient(config);
|
|
74
90
|
const server = new McpServer({
|
|
@@ -143,12 +159,12 @@ server.tool("issue_list", "List issues for the dispatch's board by default via G
|
|
|
143
159
|
...boardField,
|
|
144
160
|
}, async (args) => jsonResult(await issueList(client, args)));
|
|
145
161
|
// ---------------- issue_get ----------------
|
|
146
|
-
server.tool("issue_get",
|
|
162
|
+
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.', {
|
|
147
163
|
id: z.string().min(1),
|
|
148
164
|
...boardField,
|
|
149
165
|
}, async (args) => jsonResult(await issueGet(client, args)));
|
|
150
166
|
// ---------------- issue_create ----------------
|
|
151
|
-
server.tool("issue_create",
|
|
167
|
+
server.tool("issue_create", 'Create a fresh card via POST /api/issues. Board-scoped; defaults to the dispatch\'s board. Pass `board` (a qualified id `<repo>:<slug>`) to create the card on another board (forwarded into body.board + ?board=; unknown board → 404). INVARIANT: type=Epic REQUIRES non-empty phase_children[] (epic-with-phases atomicity per DX-575) and the route atomically inserts the epic + every phase in ONE transaction. Non-Epic types REFUSE phase_children[] with 400. Status defaults to Review (no lifecycle timestamps stamped on create). parent_id optional. ac items take {title}; phase children inherit the new epic\'s id as parent_id. Optional list_id PLACES the card directly into a column in ONE call. **Pass EITHER a board_lists id OR the list\'s display NAME (case-insensitive, emoji-tolerant — e.g. a queue name like "⚙️ Fulfillment Queue" or just "Fulfillment Queue") — the server resolves a name to its id.** The card lands DIRECTLY in that column with the matching lifecycle stamped automatically — a `ready`-type queue → ToDo, a `completed` list → Done, etc. **You do NOT need a separate issue_transition(ready) + issue_edit(list_id) afterward — just pass the queue name here and the card is created already in that column.** Omit list_id for the default (Review). NOT valid on type=Epic (Epic status derives from children) → 400. Unknown name/id → 400. Optional required_gates[] flags which quality gates (dependency, architecture, tdd, code) are REQUIRED on this card so they run pre-dispatch (only when the board also has that gate enabled); unknown gate name → 400.', {
|
|
152
168
|
type: z.enum(ISSUE_TYPES),
|
|
153
169
|
title: z.string().min(1),
|
|
154
170
|
description: z.string(),
|
|
@@ -172,7 +188,7 @@ server.tool("issue_create", "Create a fresh card via POST /api/issues. Board-sco
|
|
|
172
188
|
...boardField,
|
|
173
189
|
}, async (args) => jsonResult(await issueCreate(client, args, config.board)));
|
|
174
190
|
// ---------------- issue_edit ----------------
|
|
175
|
-
server.tool("issue_edit",
|
|
191
|
+
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).', {
|
|
176
192
|
id: z.string().min(1),
|
|
177
193
|
title: z.string().min(1).optional(),
|
|
178
194
|
description: z.string().optional(),
|
|
@@ -256,7 +272,7 @@ server.tool("issue_checklist", "Targeted checklist CUD via /api/issues/:id/check
|
|
|
256
272
|
...boardField,
|
|
257
273
|
}, async (args) => jsonResult(await issueChecklist(client, args)));
|
|
258
274
|
// ---------------- issue_dependency ----------------
|
|
259
|
-
server.tool("issue_dependency",
|
|
275
|
+
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.', {
|
|
260
276
|
id: z.string().min(1),
|
|
261
277
|
action: z.enum(["add", "remove"]),
|
|
262
278
|
kind: z.enum(["depends_on", "conflict_on"]).optional(),
|
|
@@ -273,6 +289,20 @@ server.tool("issue_requires_human", "Set or clear the requires_human dispatch ga
|
|
|
273
289
|
steps: z.array(z.string().min(1)).optional(),
|
|
274
290
|
...boardField,
|
|
275
291
|
}, async (args) => jsonResult(await issueRequiresHuman(client, args)));
|
|
292
|
+
// ---------------- issue_quality_gate ----------------
|
|
293
|
+
server.tool("issue_quality_gate", "Toggle a single card's per-card quality-gate `required` flag via POST /api/issues/:id/quality-gates/:gate {required} — the SAME write the dashboard drawer's Quality Gates tab performs (DX-1181). This is the ONLY post-create way to mark a gate required/not-required: `issue_create` carries `required_gates[]` at birth, and `issue_edit` REJECTS gate keys (400 offending_keys) — without this tool a card created without a gate can never have it turned on by an agent. `gate` is a registry name: `plan-dependency` | `plan-architecture` | `plan-tdd` | `code-test-quality` | `code-architecture` | `code-quality` (the PRE/plan- gates run before the work dispatch; the POST/code- gates block issue_transition complete). Unknown gate → 400 (never a silent no-op); a card with no seeded row for a registered gate → 500 (canonical corruption). NOTE the board-level master switch is separate (the Agents-tab `board_quality_gate_settings`): a gate only RUNS when the board enables it AND the card marks it required — flipping `required` here is necessary but not sufficient if the board has the gate disabled. Returns the hydrated issue. Board-scoped; pass `board` (`<repo>:<slug>`) to target another board.", {
|
|
294
|
+
id: z.string().min(1),
|
|
295
|
+
gate: z.enum([
|
|
296
|
+
"plan-dependency",
|
|
297
|
+
"plan-architecture",
|
|
298
|
+
"plan-tdd",
|
|
299
|
+
"code-test-quality",
|
|
300
|
+
"code-architecture",
|
|
301
|
+
"code-quality",
|
|
302
|
+
]),
|
|
303
|
+
required: z.boolean(),
|
|
304
|
+
...boardField,
|
|
305
|
+
}, async (args) => jsonResult(await issueQualityGate(client, args)));
|
|
276
306
|
// ---------------- issue_retro ----------------
|
|
277
307
|
server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retro. Body: {good, bad, action_item_ids[], commits[]}. REFUSES 409 unless the card is terminal (completed_at OR cancelled_at) — retro ships when work concludes. Replace semantics: good/bad upsert; action_item_ids[] + commits[] soft-delete prior live rows and insert with fresh ordinals. action_item_ids[] entries MUST match <PREFIX>-N. commits[] entries take {sha, subject?}.", {
|
|
278
308
|
id: z.string().min(1),
|
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.21",
|
|
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",
|