@thehammer/danx-dashboard-mcp 0.1.24 → 0.1.26

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.
@@ -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/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
  });
@@ -321,7 +334,7 @@ server.tool("issue_quality_gate", "Toggle a single card's per-card quality-gate
321
334
  ...boardField,
322
335
  }, async (args) => jsonResult(await issueQualityGate(client, args)));
323
336
  // ---------------- issue_retro ----------------
324
- 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).", {
325
338
  id: z.string().min(1),
326
339
  good: z.string(),
327
340
  bad: z.string(),
@@ -330,6 +343,20 @@ server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retr
330
343
  sha: z.string().min(1),
331
344
  subject: z.string().optional(),
332
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
+ })),
333
360
  ...boardField,
334
361
  }, async (args) => jsonResult(await issueRetro(client, args)));
335
362
  // ---------------- issue_attach ----------------
@@ -347,11 +374,20 @@ server.tool("issue_attach", "Attach a LOCAL file to an issue card via POST /api/
347
374
  }, async (args) => jsonResult(await issueAttach(client, args)));
348
375
  // ---------------- main ----------------
349
376
  async function main() {
377
+ boot();
350
378
  const transport = new StdioServerTransport();
351
379
  await server.connect(transport);
352
380
  console.error(`danx-dashboard-mcp running on stdio (dashboard=${config.baseUrl}, board=${config.board})`);
353
381
  }
354
- main().catch((err) => {
355
- console.error(`[danx-dashboard-mcp] fatal: ${err.message}`);
356
- process.exit(1);
357
- });
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.24",
3
+ "version": "0.1.26",
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
  },