@thehammer/danx-dashboard-mcp 0.1.23 → 0.1.25
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 +3 -3
- package/dist/index.js +74 -26
- package/dist/tool-defs.js +58 -0
- package/package.json +2 -1
package/dist/handlers.js
CHANGED
|
@@ -89,8 +89,8 @@ export async function issueCreate(client, args, defaultBoard) {
|
|
|
89
89
|
body.effort_level = args.effort_level;
|
|
90
90
|
if (args.list_id !== undefined)
|
|
91
91
|
body.list_id = args.list_id;
|
|
92
|
-
if (args.
|
|
93
|
-
body.
|
|
92
|
+
if (args.gate_decisions !== undefined)
|
|
93
|
+
body.gate_decisions = args.gate_decisions;
|
|
94
94
|
if (args.phase_children !== undefined)
|
|
95
95
|
body.phase_children = args.phase_children;
|
|
96
96
|
return client.request({ method: "POST", path: "", body, board });
|
|
@@ -346,7 +346,7 @@ export async function issueRequiresHuman(client, args) {
|
|
|
346
346
|
* POST /api/issues/:id/quality-gates/:gate {required} — the same write the
|
|
347
347
|
* dashboard drawer's Quality Gates tab performs (DX-1181). This is the ONLY
|
|
348
348
|
* post-create path to mark a gate required/not — `issue_create` carries
|
|
349
|
-
* `
|
|
349
|
+
* `gate_decisions` at birth, and `issue_edit` rejects gate keys; without
|
|
350
350
|
* this tool an agent that created a card cannot turn a gate on afterward.
|
|
351
351
|
*
|
|
352
352
|
* `gate` is a registry name (`plan-dependency` | `plan-architecture` |
|
package/dist/index.js
CHANGED
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
* agent reads `body.error` + structured fields to decide next action.
|
|
49
49
|
* 5xx and network failures throw — never silently swallowed.
|
|
50
50
|
*/
|
|
51
|
+
import { pathToFileURL } from "node:url";
|
|
51
52
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
52
53
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
53
54
|
import { z } from "zod";
|
|
@@ -73,22 +74,34 @@ function readEnvOptional(name) {
|
|
|
73
74
|
const v = process.env[name];
|
|
74
75
|
return typeof v === "string" && v !== "" ? v : undefined;
|
|
75
76
|
}
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
77
|
+
// The dispatch config + HTTP client are LATE-BOUND (assigned by `boot()` at
|
|
78
|
+
// entrypoint, below). Tool registration happens at module import and the
|
|
79
|
+
// callbacks close over these `let` bindings, reading them at CALL time — so the
|
|
80
|
+
// module can be IMPORTED without env (the DX-1606 tool-defs generator + its
|
|
81
|
+
// drift test introspect `server`'s tool schemas without spawning a dispatch),
|
|
82
|
+
// while a real run still fails loud on missing env via `boot()`.
|
|
83
|
+
let config;
|
|
84
|
+
let client;
|
|
85
|
+
/**
|
|
86
|
+
* Compose the dispatch's qualified board id (`<repo>:<slug>`) from the two env
|
|
87
|
+
* halves the worker injects and build the HTTP client. DANXBOT_BOARD_NAME
|
|
88
|
+
* already carries the board SLUG at the spawn site (src/dispatch/core.ts sets it
|
|
89
|
+
* from `board.slug`), so a plain join yields the canonical id — no transform.
|
|
90
|
+
* Both halves are fail-loud required (Core Principle 1 — no fallback).
|
|
91
|
+
*/
|
|
92
|
+
function boot() {
|
|
93
|
+
config = {
|
|
94
|
+
baseUrl: readEnvOrDie("DANXBOT_DASHBOARD_URL"),
|
|
95
|
+
token: readEnvOrDie("DANXBOT_DISPATCH_TOKEN"),
|
|
96
|
+
board: `${readEnvOrDie("DANX_REPO_NAME")}:${readEnvOrDie("DANXBOT_BOARD_NAME")}`,
|
|
97
|
+
// DX-1398 — OPTIONAL cross-process trace context. Present → stamped on every
|
|
98
|
+
// outbound call so the agent's card writes chain under the launch; absent
|
|
99
|
+
// (untraced dispatch) → omitted, and the dashboard mints a fresh root.
|
|
100
|
+
traceparent: readEnvOptional("DANXBOT_TRACEPARENT"),
|
|
101
|
+
};
|
|
102
|
+
client = new DashboardHttpClient(config);
|
|
103
|
+
}
|
|
104
|
+
export const server = new McpServer({
|
|
92
105
|
name: "danx-dashboard-mcp",
|
|
93
106
|
version: "0.1.0",
|
|
94
107
|
});
|
|
@@ -165,7 +178,7 @@ server.tool("issue_get", 'Fetch a single hydrated issue via GET /api/issues/:id.
|
|
|
165
178
|
...boardField,
|
|
166
179
|
}, async (args) => jsonResult(await issueGet(client, args)));
|
|
167
180
|
// ---------------- issue_create ----------------
|
|
168
|
-
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.
|
|
181
|
+
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. **gate_decisions is REQUIRED whenever the board has any OPTIONAL quality gate for the card\'s type** (DX-1594): supply one `{gate, enabled, note}` per board-optional gate. The create FAILS CLOSED — a missing decision returns 400 `{error, required_gate_decisions:[...]}` enumerating exactly which gates to answer, so just retry with a decision for each listed gate. `required`/`disabled` board gates take no decision; a board with no optional gates needs no gate_decisions at all.', {
|
|
169
182
|
type: z.enum(ISSUE_TYPES),
|
|
170
183
|
title: z.string().min(1),
|
|
171
184
|
description: z.string(),
|
|
@@ -173,10 +186,14 @@ server.tool("issue_create", 'Create a fresh card via POST /api/issues. Board-sco
|
|
|
173
186
|
ac: z.array(z.object({ title: z.string().min(1) })).optional(),
|
|
174
187
|
effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
|
|
175
188
|
list_id: z.string().min(1).nullable().optional(),
|
|
176
|
-
|
|
177
|
-
.array(z.
|
|
189
|
+
gate_decisions: z
|
|
190
|
+
.array(z.object({
|
|
191
|
+
gate: z.string().min(1),
|
|
192
|
+
enabled: z.boolean(),
|
|
193
|
+
note: z.string(),
|
|
194
|
+
}))
|
|
178
195
|
.optional()
|
|
179
|
-
.describe('
|
|
196
|
+
.describe('REQUIRED fail-closed quality-gate decisions (DX-1594 — replaces required_gates). One {gate, enabled, note} per board-OPTIONAL gate of the card\'s type: `enabled` answers whether the gate runs on this card, `note` records the rationale (persisted as the decision rationale, distinct from the reviewer verdict). The board requirement is TRI-STATE per gate (`board_quality_gate_settings.default_state`): `required` runs always (NO decision — auto-on); `optional` REQUIRES a decision here (unanswered → the create 400s); `disabled` never runs (NO decision). Omit this only on a board with no optional gates; otherwise the 400 body\'s `required_gate_decisions` lists exactly which gates to answer — retry with {enabled, note} for each. A decision naming a non-optional gate is rejected 400.'),
|
|
180
197
|
phase_children: z
|
|
181
198
|
.array(z.object({
|
|
182
199
|
type: z.enum(NON_EPIC_TYPES),
|
|
@@ -184,6 +201,14 @@ server.tool("issue_create", 'Create a fresh card via POST /api/issues. Board-sco
|
|
|
184
201
|
description: z.string(),
|
|
185
202
|
ac: z.array(z.object({ title: z.string().min(1) })).optional(),
|
|
186
203
|
effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
|
|
204
|
+
gate_decisions: z
|
|
205
|
+
.array(z.object({
|
|
206
|
+
gate: z.string().min(1),
|
|
207
|
+
enabled: z.boolean(),
|
|
208
|
+
note: z.string(),
|
|
209
|
+
}))
|
|
210
|
+
.optional()
|
|
211
|
+
.describe("Per-child fail-closed gate decisions — same shape + rule as the root gate_decisions, resolved against THIS child's own type. Required when the child's type has board-optional gates."),
|
|
187
212
|
}))
|
|
188
213
|
.optional(),
|
|
189
214
|
...boardField,
|
|
@@ -295,7 +320,7 @@ server.tool("issue_requires_human", "Set or clear the requires_human dispatch ga
|
|
|
295
320
|
...boardField,
|
|
296
321
|
}, async (args) => jsonResult(await issueRequiresHuman(client, args)));
|
|
297
322
|
// ---------------- issue_quality_gate ----------------
|
|
298
|
-
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 `
|
|
323
|
+
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 `gate_decisions` 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 board requirement is TRI-STATE per gate (`board_quality_gate_settings.default_state`, the Agents-tab surface), NOT a binary on/off: `required` = gate always runs (this flag irrelevant); `optional` = gate runs WHEN this per-card flag is true (per-card opt-in — `optional` is ENABLED, NOT off); `disabled` = never runs (this flag inert). So flipping `required:true` here LAUNCHES the gate when the board state is `required` OR `optional`; it is inert ONLY when the board state is `disabled`. Do not read `optional` as off. (Source of truth: `isGateEffectivelyRequired` in `src/issues/quality-gates/read.ts`.) Returns the hydrated issue. Board-scoped; pass `board` (`<repo>:<slug>`) to target another board.", {
|
|
299
324
|
id: z.string().min(1),
|
|
300
325
|
gate: z.enum([
|
|
301
326
|
"plan-dependency",
|
|
@@ -309,7 +334,7 @@ server.tool("issue_quality_gate", "Toggle a single card's per-card quality-gate
|
|
|
309
334
|
...boardField,
|
|
310
335
|
}, async (args) => jsonResult(await issueQualityGate(client, args)));
|
|
311
336
|
// ---------------- issue_retro ----------------
|
|
312
|
-
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?}.", {
|
|
337
|
+
server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retro. Body: {good, bad, action_item_ids[], commits[], tests[]}. 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[] + tests[] soft-delete prior live rows and insert with fresh ordinals. action_item_ids[] entries MUST match <PREFIX>-N. commits[] entries take {sha, subject?}. tests[] (DX-1646) is REQUIRED (empty array allowed — the \"ran no tests\" case): one row per test GROUP that ran (a whole suite/class — name the group, do NOT list individual unit tests) or per individual e2e test (kind:'e2e', listed explicitly since they are few + expensive). Each row: {name, kind:'group'|'e2e', num_tests, num_passing_tests, duration_ms} required; num_assertions + num_passing_assertions NULLABLE (vitest surfaces no assertion totals — pass null or omit).", {
|
|
313
338
|
id: z.string().min(1),
|
|
314
339
|
good: z.string(),
|
|
315
340
|
bad: z.string(),
|
|
@@ -318,6 +343,20 @@ server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retr
|
|
|
318
343
|
sha: z.string().min(1),
|
|
319
344
|
subject: z.string().optional(),
|
|
320
345
|
})),
|
|
346
|
+
tests: z.array(z.object({
|
|
347
|
+
name: z.string().min(1),
|
|
348
|
+
kind: z.enum(["group", "e2e"]),
|
|
349
|
+
num_tests: z.number().int().nonnegative(),
|
|
350
|
+
num_assertions: z.number().int().nonnegative().nullable().optional(),
|
|
351
|
+
num_passing_tests: z.number().int().nonnegative(),
|
|
352
|
+
num_passing_assertions: z
|
|
353
|
+
.number()
|
|
354
|
+
.int()
|
|
355
|
+
.nonnegative()
|
|
356
|
+
.nullable()
|
|
357
|
+
.optional(),
|
|
358
|
+
duration_ms: z.number().int().nonnegative(),
|
|
359
|
+
})),
|
|
321
360
|
...boardField,
|
|
322
361
|
}, async (args) => jsonResult(await issueRetro(client, args)));
|
|
323
362
|
// ---------------- issue_attach ----------------
|
|
@@ -335,11 +374,20 @@ server.tool("issue_attach", "Attach a LOCAL file to an issue card via POST /api/
|
|
|
335
374
|
}, async (args) => jsonResult(await issueAttach(client, args)));
|
|
336
375
|
// ---------------- main ----------------
|
|
337
376
|
async function main() {
|
|
377
|
+
boot();
|
|
338
378
|
const transport = new StdioServerTransport();
|
|
339
379
|
await server.connect(transport);
|
|
340
380
|
console.error(`danx-dashboard-mcp running on stdio (dashboard=${config.baseUrl}, board=${config.board})`);
|
|
341
381
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
382
|
+
// Boot the stdio server ONLY when run as the entrypoint (the published bin).
|
|
383
|
+
// Importing this module (the tool-defs generator + its drift test) registers
|
|
384
|
+
// the tools on `server` without reading env or attaching stdin — so the
|
|
385
|
+
// schemas can be introspected without spawning a dispatch.
|
|
386
|
+
const isEntrypoint = typeof process.argv[1] === "string" &&
|
|
387
|
+
import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
388
|
+
if (isEntrypoint) {
|
|
389
|
+
main().catch((err) => {
|
|
390
|
+
console.error(`[danx-dashboard-mcp] fatal: ${err.message}`);
|
|
391
|
+
process.exit(1);
|
|
392
|
+
});
|
|
393
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-1606 — expose THIS MCP server's tool DEFINITIONS as plain JSON Schema, so
|
|
3
|
+
* the danxbot service can count the injected-context token cost of each tool.
|
|
4
|
+
*
|
|
5
|
+
* The server registers tools via `server.tool(name, description, zodShape, …)`
|
|
6
|
+
* (in `index.ts`); the zod shapes are converted to JSON Schema by the MCP SDK
|
|
7
|
+
* when it answers `tools/list`. Rather than re-declare the schemas (drift), this
|
|
8
|
+
* module drives the SAME `tools/list` the live agent receives by linking an
|
|
9
|
+
* in-memory client to the already-registered `server` — so the emitted defs are
|
|
10
|
+
* byte-identical to what the agent's context is charged for.
|
|
11
|
+
*
|
|
12
|
+
* The danxbot service can NOT take a zod / MCP-SDK runtime dependency (and the
|
|
13
|
+
* Docker image does not install this package's node_modules), so the service
|
|
14
|
+
* reads the COMMITTED `tool-defs.json` produced by `generateToolDefsJson()`
|
|
15
|
+
* below — plain data, no runtime deps. `gen-tool-defs.ts` writes that file and
|
|
16
|
+
* `tool-defs.test.ts` asserts it is in sync (fail-loud on drift).
|
|
17
|
+
*/
|
|
18
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
19
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
20
|
+
import { server } from "./index.js";
|
|
21
|
+
/** The server id the danx-dashboard catalog mcp-server artifact is keyed by. */
|
|
22
|
+
export const DANX_DASHBOARD_SERVER_ID = "danx-dashboard";
|
|
23
|
+
/**
|
|
24
|
+
* Introspect `server`'s registered tools via an in-memory `tools/list` — the
|
|
25
|
+
* exact `{name, description, inputSchema}` the agent's context receives — and
|
|
26
|
+
* map them to the API's `{name, description, input_schema}` shape, sorted by
|
|
27
|
+
* name for a stable committed artifact.
|
|
28
|
+
*/
|
|
29
|
+
export async function generateToolDefs() {
|
|
30
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
31
|
+
const client = new Client({ name: "tool-defs-introspect", version: "0.0.0" });
|
|
32
|
+
await Promise.all([
|
|
33
|
+
server.connect(serverTransport),
|
|
34
|
+
client.connect(clientTransport),
|
|
35
|
+
]);
|
|
36
|
+
try {
|
|
37
|
+
const { tools } = await client.listTools();
|
|
38
|
+
return tools
|
|
39
|
+
.map((t) => ({
|
|
40
|
+
name: t.name,
|
|
41
|
+
description: t.description ?? "",
|
|
42
|
+
input_schema: t.inputSchema,
|
|
43
|
+
}))
|
|
44
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
await client.close();
|
|
48
|
+
await server.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** The full `tool-defs.json` payload (server id + the introspected defs). */
|
|
52
|
+
export async function generateToolDefsFile() {
|
|
53
|
+
return { server: DANX_DASHBOARD_SERVER_ID, tools: await generateToolDefs() };
|
|
54
|
+
}
|
|
55
|
+
/** Deterministic 2-space JSON the committed file + the drift test compare. */
|
|
56
|
+
export function serializeToolDefsFile(file) {
|
|
57
|
+
return JSON.stringify(file, null, 2) + "\n";
|
|
58
|
+
}
|
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.25",
|
|
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",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"build": "tsc -p tsconfig.json && node -e \"require('fs').chmodSync('dist/index.js', 0o755)\"",
|
|
25
25
|
"start": "node dist/index.js",
|
|
26
26
|
"dev": "tsx src/index.ts",
|
|
27
|
+
"gen-tool-defs": "tsx scripts/gen-tool-defs.ts",
|
|
27
28
|
"test": "vitest run",
|
|
28
29
|
"test:watch": "vitest"
|
|
29
30
|
},
|