@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 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.required_gates !== undefined)
93
- body.required_gates = args.required_gates;
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
- * `required_gates[]` at birth, and `issue_edit` rejects gate keys; without
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
- // Compose the dispatch's qualified board id (`<repo>:<slug>`) from the
77
- // two env halves the worker injects. DANXBOT_BOARD_NAME already carries
78
- // the board SLUG at the spawn site (src/dispatch/core.ts sets it from
79
- // `board.slug`), so a plain join yields the canonical id no transform.
80
- // Both halves are fail-loud required (Core Principle 1 no fallback).
81
- const config = {
82
- baseUrl: readEnvOrDie("DANXBOT_DASHBOARD_URL"),
83
- token: readEnvOrDie("DANXBOT_DISPATCH_TOKEN"),
84
- board: `${readEnvOrDie("DANX_REPO_NAME")}:${readEnvOrDie("DANXBOT_BOARD_NAME")}`,
85
- // DX-1398 OPTIONAL cross-process trace context. Present stamped on every
86
- // outbound call so the agent's card writes chain under the launch; absent
87
- // (untraced dispatch) omitted, and the dashboard mints a fresh root.
88
- traceparent: readEnvOptional("DANXBOT_TRACEPARENT"),
89
- };
90
- const client = new DashboardHttpClient(config);
91
- const server = new McpServer({
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. 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.', {
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
- required_gates: z
177
- .array(z.string().min(1))
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('Optional array of quality-gate names to mark REQUIRED on this card (e.g. ["architecture","tdd"]). Board requirement is TRI-STATE per gate (`board_quality_gate_settings.default_state`), NOT binary: `required` runs always; `optional` runs WHEN flagged on the card (per-card opt-in `optional` is ENABLED, not off); `disabled` never runs (flag inert). So flagging makes the gate run unless the board state is `disabled`. Use this when you (the creating agent) judge the card needs that pre-dispatch review gate; omit for cards that don\'t. Known gates: dependency, architecture, tdd, code. The operator can also toggle these per-card later in the issue drawer.'),
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 `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 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.", {
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
- main().catch((err) => {
343
- console.error(`[danx-dashboard-mcp] fatal: ${err.message}`);
344
- process.exit(1);
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.23",
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
  },