@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.
- package/dist/handlers.test.js +51 -0
- package/dist/index.js +57 -21
- package/dist/tool-defs.js +58 -0
- package/package.json +2 -1
|
@@ -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
|
-
//
|
|
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
|
});
|
|
@@ -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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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.
|
|
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
|
},
|