@thehammer/danx-dashboard-mcp 0.1.1

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/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # @thehammer/danx-dashboard-mcp
2
+
3
+ Stdio MCP server wrapping danxbot's dashboard `/api/v2/issues/*` normalized DB-backed HTTP routes. Replaces the legacy "agents Edit/Write `.yml` files directly" pattern from before DX-704 / DX-811.
4
+
5
+ Each tool is a thin envelope over one HTTP route — Zod-validated at the MCP boundary, fetch under the hood, server response passed back to the agent verbatim. Refusal envelopes (`{error, ...extra}` with `failed_gate`, `non_terminal_phases`, `offending_keys`, etc.) come through as `{ok: false, status, body}` so the agent can pick the right next action without guessing. 5xx and network failures throw.
6
+
7
+ ## Env vars (required, fail-loud at boot)
8
+
9
+ | Var | Purpose |
10
+ |---|---|
11
+ | `DANXBOT_DASHBOARD_URL` | Dashboard base, e.g. `http://danxbot-dashboard:5555` (inside compose) or `http://localhost:5555` (host) |
12
+ | `DANXBOT_DISPATCH_TOKEN` | Per-dispatch bearer — server validates via `requireUser`/dispatch-token checks |
13
+ | `DANX_REPO_NAME` | Repo scope appended as `?repo=<name>` to every URL |
14
+
15
+ ## Tool surface
16
+
17
+ All exposed as `mcp__danx_dashboard__<name>` once wired through the workspace `.mcp.json`.
18
+
19
+ | Tool | HTTP | Notes |
20
+ |---|---|---|
21
+ | `issue_list` | `GET /api/v2/issues` | filters: `type`, `parent_id` (null → root-only), `dispatchable_derived`, `assigned_agent`, `include_closed`, `limit`, `offset` |
22
+ | `issue_get` | `GET /api/v2/issues/:id` | Returns hydrated card + ancestor chain |
23
+ | `issue_create` | `POST /api/v2/issues` | Epic REQUIRES non-empty `phase_children[]` (atomic insert) |
24
+ | `issue_edit` | `PATCH /api/v2/issues/:id/edit` | Prose-only — semantic keys refused with 400 + pointer to dedicated handler |
25
+ | `issue_transition` | `POST /api/v2/issues/:id/transition` | Actions: ready, pickup, rollback_pickup, complete, cancel, block, unblock, archive, reopen |
26
+ | `issue_triage` | `POST /api/v2/issues/:id/triage` | Verdicts: approve, cancel, keep, defer (with optional ICE + ttl_seconds) |
27
+ | `issue_comment` | `POST/PATCH/DELETE /api/v2/issues/:id/comments[/:cid]` | Author server-stamped, soft-delete preserved |
28
+ | `issue_dependency` | `POST/DELETE /api/v2/issues/:id/dependencies[/:did]` | `depends_on` cycle-checked; remove hardcodes `reason: "recorded_in_error"` |
29
+ | `issue_requires_human` | `POST/DELETE /api/v2/issues/:id/requires-human` | Set replaces step rows atomically; clear soft-deletes them |
30
+ | `issue_retro` | `PUT /api/v2/issues/:id/retro` | Requires terminal card; replace semantics |
31
+
32
+ ## Build + test
33
+
34
+ ```bash
35
+ cd packages/danx-dashboard-mcp
36
+ npm install
37
+ npm test
38
+ npm run build # → dist/index.js (executable)
39
+ ```
40
+
41
+ ## Publishing
42
+
43
+ Out of scope for this package's own code — the `make publish-danx-dashboard-mcp` target in the danxbot root Makefile owns version bump + `npm publish` + propagation wait. Standing operator authorization per `~/.claude/skills/thehammer-publish/`.
@@ -0,0 +1,177 @@
1
+ export async function issueList(client, args) {
2
+ // status_derived is display-only on the server but the route accepts
3
+ // it as a projection filter — passthrough verbatim. parent_id=null is
4
+ // the legitimate "root cards only" filter; the server reader treats
5
+ // the literal string "null" identically.
6
+ const query = {};
7
+ if (args.type !== undefined)
8
+ query.type = args.type;
9
+ if (args.parent_id !== undefined) {
10
+ query.parent_id = args.parent_id === null ? "null" : args.parent_id;
11
+ }
12
+ if (args.dispatchable_derived !== undefined) {
13
+ query.dispatchable_derived = args.dispatchable_derived;
14
+ }
15
+ if (args.assigned_agent !== undefined)
16
+ query.assigned_agent = args.assigned_agent;
17
+ if (args.include_closed !== undefined)
18
+ query.include_closed = args.include_closed;
19
+ if (args.status_derived !== undefined)
20
+ query.status_derived = args.status_derived;
21
+ if (args.limit !== undefined)
22
+ query.limit = args.limit;
23
+ if (args.offset !== undefined)
24
+ query.offset = args.offset;
25
+ return client.request({ method: "GET", path: "", query });
26
+ }
27
+ // ---------------- issue_get ----------------
28
+ export async function issueGet(client, args) {
29
+ return client.request({ method: "GET", path: `/${encodeURIComponent(args.id)}` });
30
+ }
31
+ export async function issueCreate(client, args, repo) {
32
+ // `repo` lives in BOTH the query string (via http-client) AND the
33
+ // body (server requires it inside the JSON envelope per
34
+ // `write/create.ts::validate`). Sending only one fails-loud — the
35
+ // duplication is intentional.
36
+ const body = {
37
+ repo,
38
+ type: args.type,
39
+ title: args.title,
40
+ description: args.description,
41
+ };
42
+ if (args.parent_id !== undefined)
43
+ body.parent_id = args.parent_id;
44
+ if (args.ac !== undefined)
45
+ body.ac = args.ac;
46
+ if (args.effort_level !== undefined)
47
+ body.effort_level = args.effort_level;
48
+ if (args.phase_children !== undefined)
49
+ body.phase_children = args.phase_children;
50
+ return client.request({ method: "POST", path: "", body });
51
+ }
52
+ export async function issueEdit(client, args) {
53
+ const { id, ...rest } = args;
54
+ // Strip undefined so we don't send `{title: undefined}` — JSON.stringify
55
+ // drops them anyway but explicit is clearer.
56
+ const body = {};
57
+ for (const [k, v] of Object.entries(rest)) {
58
+ if (v !== undefined)
59
+ body[k] = v;
60
+ }
61
+ return client.request({
62
+ method: "PATCH",
63
+ path: `/${encodeURIComponent(id)}/edit`,
64
+ body,
65
+ });
66
+ }
67
+ export async function issueTransition(client, args) {
68
+ const { id, ...body } = args;
69
+ return client.request({
70
+ method: "POST",
71
+ path: `/${encodeURIComponent(id)}/transition`,
72
+ body,
73
+ });
74
+ }
75
+ export async function issueTriage(client, args) {
76
+ const { id, ...body } = args;
77
+ return client.request({
78
+ method: "POST",
79
+ path: `/${encodeURIComponent(id)}/triage`,
80
+ body,
81
+ });
82
+ }
83
+ export async function issueComment(client, args) {
84
+ const idEnc = encodeURIComponent(args.id);
85
+ if (args.action === "add") {
86
+ if (typeof args.text !== "string") {
87
+ throw new Error("issue_comment action=add requires text");
88
+ }
89
+ return client.request({
90
+ method: "POST",
91
+ path: `/${idEnc}/comments`,
92
+ body: { text: args.text },
93
+ });
94
+ }
95
+ if (args.action === "edit") {
96
+ if (args.comment_id === undefined) {
97
+ throw new Error("issue_comment action=edit requires comment_id");
98
+ }
99
+ if (typeof args.text !== "string") {
100
+ throw new Error("issue_comment action=edit requires text");
101
+ }
102
+ return client.request({
103
+ method: "PATCH",
104
+ path: `/${idEnc}/comments/${args.comment_id}`,
105
+ body: { text: args.text },
106
+ });
107
+ }
108
+ // delete
109
+ if (args.comment_id === undefined) {
110
+ throw new Error("issue_comment action=delete requires comment_id");
111
+ }
112
+ return client.request({
113
+ method: "DELETE",
114
+ path: `/${idEnc}/comments/${args.comment_id}`,
115
+ });
116
+ }
117
+ export async function issueDependency(client, args) {
118
+ const idEnc = encodeURIComponent(args.id);
119
+ if (args.action === "add") {
120
+ if (args.kind === undefined) {
121
+ throw new Error("issue_dependency action=add requires kind");
122
+ }
123
+ if (args.target_id === undefined) {
124
+ throw new Error("issue_dependency action=add requires target_id");
125
+ }
126
+ return client.request({
127
+ method: "POST",
128
+ path: `/${idEnc}/dependencies`,
129
+ body: {
130
+ kind: args.kind,
131
+ target_id: args.target_id,
132
+ reason: args.reason ?? "",
133
+ },
134
+ });
135
+ }
136
+ // remove
137
+ if (args.dependency_id === undefined) {
138
+ throw new Error("issue_dependency action=remove requires dependency_id");
139
+ }
140
+ return client.request({
141
+ method: "DELETE",
142
+ path: `/${idEnc}/dependencies/${args.dependency_id}`,
143
+ // Server demands literal "recorded_in_error" — any other value is
144
+ // a 400. The MCP boundary hardcodes it to remove a footgun: an
145
+ // agent that types the wrong reason gets a refusal here at MCP
146
+ // arg-validation, not a confusing 400 envelope.
147
+ body: { reason: "recorded_in_error" },
148
+ });
149
+ }
150
+ export async function issueRequiresHuman(client, args) {
151
+ const idEnc = encodeURIComponent(args.id);
152
+ if (args.set) {
153
+ if (typeof args.reason !== "string") {
154
+ throw new Error("issue_requires_human set=true requires reason");
155
+ }
156
+ if (!Array.isArray(args.steps)) {
157
+ throw new Error("issue_requires_human set=true requires steps[]");
158
+ }
159
+ return client.request({
160
+ method: "POST",
161
+ path: `/${idEnc}/requires-human`,
162
+ body: { reason: args.reason, steps: args.steps },
163
+ });
164
+ }
165
+ return client.request({
166
+ method: "DELETE",
167
+ path: `/${idEnc}/requires-human`,
168
+ });
169
+ }
170
+ export async function issueRetro(client, args) {
171
+ const { id, ...body } = args;
172
+ return client.request({
173
+ method: "PUT",
174
+ path: `/${encodeURIComponent(id)}/retro`,
175
+ body,
176
+ });
177
+ }
@@ -0,0 +1,71 @@
1
+ export class DashboardHttpClient {
2
+ config;
3
+ fetchImpl;
4
+ constructor(config, fetchImpl = fetch) {
5
+ this.config = config;
6
+ this.fetchImpl = fetchImpl;
7
+ }
8
+ async request(args) {
9
+ const url = this.buildUrl(args.path, args.query);
10
+ const headers = {
11
+ Authorization: `Bearer ${this.config.token}`,
12
+ Accept: "application/json",
13
+ };
14
+ let bodyString;
15
+ if (args.body !== undefined) {
16
+ headers["Content-Type"] = "application/json";
17
+ bodyString = JSON.stringify(args.body);
18
+ }
19
+ let res;
20
+ try {
21
+ res = await this.fetchImpl(url, {
22
+ method: args.method,
23
+ headers,
24
+ body: bodyString,
25
+ });
26
+ }
27
+ catch (err) {
28
+ throw new Error(`[danx-dashboard-mcp] network failure on ${args.method} ${url}: ${err instanceof Error ? err.message : String(err)}`);
29
+ }
30
+ const text = await res.text();
31
+ let parsed = null;
32
+ if (text !== "") {
33
+ try {
34
+ parsed = JSON.parse(text);
35
+ }
36
+ catch {
37
+ // Non-JSON response from a v2 route is a server-side fault —
38
+ // throw rather than fabricate an envelope.
39
+ throw new Error(`[danx-dashboard-mcp] non-JSON response (status ${res.status}) from ${args.method} ${url}: ${text.slice(0, 200)}`);
40
+ }
41
+ }
42
+ if (res.status >= 500) {
43
+ throw new Error(`[danx-dashboard-mcp] server error ${res.status} on ${args.method} ${url}: ${text.slice(0, 500)}`);
44
+ }
45
+ if (res.status >= 200 && res.status < 300) {
46
+ return { ok: true, status: res.status, body: parsed };
47
+ }
48
+ return { ok: false, status: res.status, body: parsed };
49
+ }
50
+ buildUrl(path, extraQuery) {
51
+ const base = this.config.baseUrl.replace(/\/+$/, "");
52
+ const [rawPath, existingQs] = path.split("?", 2);
53
+ let cleanPath;
54
+ if (rawPath === "" || rawPath === "/") {
55
+ cleanPath = "";
56
+ }
57
+ else {
58
+ cleanPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
59
+ }
60
+ const params = new URLSearchParams(existingQs ?? "");
61
+ params.set("repo", this.config.repo);
62
+ if (extraQuery) {
63
+ for (const [k, v] of Object.entries(extraQuery)) {
64
+ if (v === undefined || v === null)
65
+ continue;
66
+ params.set(k, String(v));
67
+ }
68
+ }
69
+ return `${base}/api/v2/issues${cleanPath}?${params.toString()}`;
70
+ }
71
+ }
package/dist/index.js ADDED
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @thehammer/danx-dashboard-mcp
4
+ *
5
+ * Stdio MCP server wrapping danxbot's dashboard `/api/v2/issues/*`
6
+ * normalized DB-backed HTTP routes (DX-704 Phase 2 / DX-811). Replaces
7
+ * the legacy "agents Edit/Write YAML files directly" pattern: every
8
+ * tool here POSTs/GETs against the dashboard, which atomically applies
9
+ * the change via the v2 transactional layer and publishes SSE.
10
+ *
11
+ * Tool surface (all exposed as `mcp__danx_dashboard__<name>` once wired
12
+ * through the workspace `.mcp.json`):
13
+ *
14
+ * - issue_list GET /api/v2/issues
15
+ * - issue_get GET /api/v2/issues/:id
16
+ * - issue_create POST /api/v2/issues
17
+ * - issue_edit PATCH /api/v2/issues/:id/edit
18
+ * - issue_transition POST /api/v2/issues/:id/transition
19
+ * - issue_triage POST /api/v2/issues/:id/triage
20
+ * - issue_comment POST/PATCH/DELETE /api/v2/issues/:id/comments[/:cid]
21
+ * - issue_dependency POST/DELETE /api/v2/issues/:id/dependencies[/:did]
22
+ * - issue_requires_human POST/DELETE /api/v2/issues/:id/requires-human
23
+ * - issue_retro PUT /api/v2/issues/:id/retro
24
+ *
25
+ * Env at boot (validated fail-loud — missing → process.exit(1)):
26
+ * DANXBOT_DASHBOARD_URL dashboard base (e.g. http://danxbot-dashboard:5555)
27
+ * DANXBOT_DISPATCH_TOKEN per-dispatch bearer
28
+ * DANX_REPO_NAME repo scope, appended as ?repo=<name> on every call
29
+ *
30
+ * Envelope contract: every tool returns the dashboard's response body
31
+ * verbatim wrapped as `{ok, status, body}`. Refusals (4xx with the v2
32
+ * `{error, ...extra}` shape) come through as `{ok: false, ...}`; the
33
+ * agent reads `body.error` + structured fields to decide next action.
34
+ * 5xx and network failures throw — never silently swallowed.
35
+ */
36
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
37
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
38
+ import { z } from "zod";
39
+ import { DashboardHttpClient } from "./http-client.js";
40
+ import { issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueRequiresHuman, issueRetro, issueTransition, issueTriage, } from "./handlers.js";
41
+ function readEnvOrDie(name) {
42
+ const v = process.env[name];
43
+ if (typeof v !== "string" || v === "") {
44
+ console.error(`[danx-dashboard-mcp] required env var ${name} is missing or empty`);
45
+ process.exit(1);
46
+ }
47
+ return v;
48
+ }
49
+ const config = {
50
+ baseUrl: readEnvOrDie("DANXBOT_DASHBOARD_URL"),
51
+ token: readEnvOrDie("DANXBOT_DISPATCH_TOKEN"),
52
+ repo: readEnvOrDie("DANX_REPO_NAME"),
53
+ };
54
+ const client = new DashboardHttpClient(config);
55
+ const server = new McpServer({
56
+ name: "danx-dashboard-mcp",
57
+ version: "0.1.0",
58
+ });
59
+ function jsonResult(value) {
60
+ return {
61
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
62
+ };
63
+ }
64
+ // Effort + verdict + action tuples kept in lockstep with the v2 write
65
+ // handlers. Drift surfaces at runtime as a server 400 — not silently
66
+ // wrong data — but pinning them here gets the failure caught at the
67
+ // MCP boundary before the round-trip.
68
+ const EFFORT_VALUES = [
69
+ "min",
70
+ "very_low",
71
+ "low",
72
+ "medium",
73
+ "high",
74
+ "very_high",
75
+ "max",
76
+ ];
77
+ const ISSUE_TYPES = ["Epic", "Bug", "Feature", "Chore"];
78
+ const NON_EPIC_TYPES = ["Bug", "Feature", "Chore"];
79
+ const TRANSITION_ACTIONS = [
80
+ "ready",
81
+ "pickup",
82
+ "rollback_pickup",
83
+ "complete",
84
+ "cancel",
85
+ "block",
86
+ "unblock",
87
+ "archive",
88
+ "reopen",
89
+ ];
90
+ const TRIAGE_VERDICTS = ["approve", "cancel", "keep", "defer"];
91
+ // ---------------- issue_list ----------------
92
+ server.tool("issue_list", "List issues for the current repo via GET /api/v2/issues. Filters: type, parent_id (string id, null for root-only), dispatchable_derived (boolean — server-computed pickup-ready gate), assigned_agent, include_closed (default false — excludes completed_at/cancelled_at). Response body shape: {issues: IssueV2[]}. Server-side numeric-suffix ordering so DX-10 follows DX-9. Use this instead of grepping .danxbot/issues/ — the DB-backed route is the source of truth post-DX-704.", {
93
+ status_derived: z.string().optional(),
94
+ type: z.enum(ISSUE_TYPES).optional(),
95
+ parent_id: z.string().nullable().optional(),
96
+ dispatchable_derived: z.boolean().optional(),
97
+ assigned_agent: z.string().optional(),
98
+ include_closed: z.boolean().optional(),
99
+ limit: z.number().int().positive().max(1000).optional(),
100
+ offset: z.number().int().nonnegative().optional(),
101
+ }, async (args) => jsonResult(await issueList(client, args)));
102
+ // ---------------- issue_get ----------------
103
+ server.tool("issue_get", "Fetch a single hydrated issue via GET /api/v2/issues/:id. Returns the full card (every joined child collection: ac, comments, dependencies, requires_human steps, retro action items + commits, triage history) plus the ancestor chain walked via parent_id. 404 envelope on unknown id.", {
104
+ id: z.string().min(1),
105
+ }, async ({ id }) => jsonResult(await issueGet(client, { id })));
106
+ // ---------------- issue_create ----------------
107
+ server.tool("issue_create", "Create a fresh card via POST /api/v2/issues. 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.", {
108
+ type: z.enum(ISSUE_TYPES),
109
+ title: z.string().min(1),
110
+ description: z.string(),
111
+ parent_id: z.string().nullable().optional(),
112
+ ac: z.array(z.object({ title: z.string().min(1) })).optional(),
113
+ effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
114
+ phase_children: z
115
+ .array(z.object({
116
+ type: z.enum(NON_EPIC_TYPES),
117
+ title: z.string().min(1),
118
+ description: z.string(),
119
+ ac: z.array(z.object({ title: z.string().min(1) })).optional(),
120
+ effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
121
+ }))
122
+ .optional(),
123
+ }, async (args) => jsonResult(await issueCreate(client, args, config.repo)));
124
+ // ---------------- issue_edit ----------------
125
+ server.tool("issue_edit", "Patch prose fields only via PATCH /api/v2/issues/:id/edit. ALLOWED keys: title, description, ac, effort_level, parent_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. AC replacement is wholesale soft-delete + reinsert with fresh ordinals; check_item_id linkage survives via title match.", {
126
+ id: z.string().min(1),
127
+ title: z.string().min(1).optional(),
128
+ description: z.string().optional(),
129
+ ac: z
130
+ .array(z.object({
131
+ title: z.string(),
132
+ checked: z.boolean().optional(),
133
+ }))
134
+ .optional(),
135
+ effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
136
+ parent_id: z.string().nullable().optional(),
137
+ }, async (args) => jsonResult(await issueEdit(client, args)));
138
+ // ---------------- issue_transition ----------------
139
+ server.tool("issue_transition", "Stamp a lifecycle transition via POST /api/v2/issues/:id/transition. Actions: ready (Review→ToDo), pickup (ToDo→In Progress — server checks every dispatch gate: ready_at, blocked_at, requires_human_reason, depends_on partners terminal, conflict_on partners idle; refuses 409 with failed_gate naming the cause), rollback_pickup, complete (REFUSES 409 on Epic if any phase child non-terminal — see non_terminal_phases[]), cancel, block (requires non-empty reason — sets blocked_at + clears dispatch), unblock, archive (parks to Backlog, clears ready_at), reopen (terminal→active, clears completed_at/cancelled_at/archived_at). Terminal cards refuse every action except reopen. Ladder timestamps preserved — forward stamps never clear earlier ones (CLAUDE.md Core Principle 2).", {
140
+ id: z.string().min(1),
141
+ action: z.enum(TRANSITION_ACTIONS),
142
+ reason: z.string().optional(),
143
+ summary: z.string().optional(),
144
+ dispatch_id: z.string().min(1).optional(),
145
+ }, async (args) => jsonResult(await issueTransition(client, args)));
146
+ // ---------------- issue_triage ----------------
147
+ server.tool("issue_triage", "Record a triage verdict via POST /api/v2/issues/:id/triage. Verdicts: approve (stamps ready_at — moves to ToDo, clears triage TTL), cancel (stamps cancelled_at — terminal), keep (refreshes triage_expires_at by ttl_seconds — defaults 7 days, card stays at Review), defer (stamps archived_at, clears ready_at — parks to Backlog). ICE components optional but recorded when present (total = i+c+e). Reason is REQUIRED non-empty. REFUSES 409 on terminal cards.", {
148
+ id: z.string().min(1),
149
+ verdict: z.enum(TRIAGE_VERDICTS),
150
+ reason: z.string().min(1),
151
+ ice: z
152
+ .object({
153
+ i: z.number().finite(),
154
+ c: z.number().finite(),
155
+ e: z.number().finite(),
156
+ })
157
+ .optional(),
158
+ ttl_seconds: z.number().positive().optional(),
159
+ }, async (args) => jsonResult(await issueTriage(client, args)));
160
+ // ---------------- issue_comment ----------------
161
+ server.tool("issue_comment", "Comment CRUD via /api/v2/issues/:id/comments[/:cid]. action=add → POST {text} (server stamps author from bearer + auto-incrementing ordinal); action=edit → PATCH /:cid {text}; action=delete → DELETE /:cid (soft-delete, audit trail preserved — comments are NEVER hard-deleted). Client-supplied author is IGNORED (server-stamped to prevent impersonation).", {
162
+ id: z.string().min(1),
163
+ action: z.enum(["add", "edit", "delete"]),
164
+ comment_id: z.number().int().positive().optional(),
165
+ text: z.string().min(1).optional(),
166
+ }, async (args) => jsonResult(await issueComment(client, args)));
167
+ // ---------------- issue_dependency ----------------
168
+ server.tool("issue_dependency", "Dependency CRUD via /api/v2/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.", {
169
+ id: z.string().min(1),
170
+ action: z.enum(["add", "remove"]),
171
+ kind: z.enum(["depends_on", "conflict_on"]).optional(),
172
+ target_id: z.string().min(1).optional(),
173
+ reason: z.string().optional(),
174
+ dependency_id: z.number().int().positive().optional(),
175
+ }, async (args) => jsonResult(await issueDependency(client, args)));
176
+ // ---------------- issue_requires_human ----------------
177
+ server.tool("issue_requires_human", "Set or clear the requires_human dispatch gate via /api/v2/issues/:id/requires-human. set=true → POST {reason, steps[]} — sets requires_human_reason (the dispatch gate per DX-704 — poller refuses pickup while non-null), set_by from bearer, set_at NOW(), REPLACES the step rows (prior soft-deleted, fresh ordinals). set=false → DELETE — clears the columns and soft-deletes every live step. Terminal cards refuse 409 on set.", {
178
+ id: z.string().min(1),
179
+ set: z.boolean(),
180
+ reason: z.string().optional(),
181
+ steps: z.array(z.string().min(1)).optional(),
182
+ }, async (args) => jsonResult(await issueRequiresHuman(client, args)));
183
+ // ---------------- issue_retro ----------------
184
+ server.tool("issue_retro", "Replace the retro block via PUT /api/v2/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?}.", {
185
+ id: z.string().min(1),
186
+ good: z.string(),
187
+ bad: z.string(),
188
+ action_item_ids: z.array(z.string()),
189
+ commits: z.array(z.object({
190
+ sha: z.string().min(1),
191
+ subject: z.string().optional(),
192
+ })),
193
+ }, async (args) => jsonResult(await issueRetro(client, args)));
194
+ // ---------------- main ----------------
195
+ async function main() {
196
+ const transport = new StdioServerTransport();
197
+ await server.connect(transport);
198
+ console.error(`danx-dashboard-mcp running on stdio (dashboard=${config.baseUrl}, repo=${config.repo})`);
199
+ }
200
+ main().catch((err) => {
201
+ console.error(`[danx-dashboard-mcp] fatal: ${err.message}`);
202
+ process.exit(1);
203
+ });
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@thehammer/danx-dashboard-mcp",
3
+ "version": "0.1.1",
4
+ "description": "Stdio MCP server wrapping danxbot's dashboard /api/v2/issues/* normalized DB-backed HTTP routes for dispatched agents (DX-704 Phase 2).",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "bin": {
9
+ "danx-dashboard-mcp": "dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/newms87/danxbot.git",
18
+ "directory": "packages/danx-dashboard-mcp"
19
+ },
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.json && node -e \"require('fs').chmodSync('dist/index.js', 0o755)\"",
25
+ "start": "node dist/index.js",
26
+ "dev": "tsx src/index.ts",
27
+ "test": "vitest run",
28
+ "test:watch": "vitest"
29
+ },
30
+ "dependencies": {
31
+ "@modelcontextprotocol/sdk": "^1.12.1",
32
+ "zod": "^3.25.76"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^22.0.0",
36
+ "tsx": "^4.0.0",
37
+ "typescript": "^5.7.0",
38
+ "vitest": "^4.1.5"
39
+ }
40
+ }