@thehammer/danx-dashboard-mcp 0.1.2 → 0.1.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @thehammer/danx-dashboard-mcp
2
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.
3
+ Stdio MCP server wrapping danxbot's dashboard `/api/issues/*` normalized DB-backed HTTP routes. Replaces the legacy "agents Edit/Write `.yml` files directly" pattern from before DX-704 / DX-811.
4
4
 
5
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
6
 
@@ -10,7 +10,7 @@ Each tool is a thin envelope over one HTTP route — Zod-validated at the MCP bo
10
10
  |---|---|
11
11
  | `DANXBOT_DASHBOARD_URL` | Dashboard base, e.g. `http://danxbot-dashboard:5555` (inside compose) or `http://localhost:5555` (host) |
12
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 |
13
+ | `DANX_REPO_NAME` | Default repo scope appended as `?repo=<name>`; each tool's optional `repo` arg overrides it per-call for cross-repo reads/writes (unknown repo → 404) |
14
14
 
15
15
  ## Tool surface
16
16
 
@@ -18,16 +18,16 @@ All exposed as `mcp__danx_dashboard__<name>` once wired through the workspace `.
18
18
 
19
19
  | Tool | HTTP | Notes |
20
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 |
21
+ | `issue_list` | `GET /api/issues` | filters: `type`, `parent_id` (null → root-only), `dispatchable_derived`, `assigned_agent`, `include_closed`, `limit`, `offset` |
22
+ | `issue_get` | `GET /api/issues/:id` | Returns hydrated card + ancestor chain |
23
+ | `issue_create` | `POST /api/issues` | Epic REQUIRES non-empty `phase_children[]` (atomic insert) |
24
+ | `issue_edit` | `PATCH /api/issues/:id/edit` | Prose-only — semantic keys refused with 400 + pointer to dedicated handler |
25
+ | `issue_transition` | `POST /api/issues/:id/transition` | Actions: ready, pickup, rollback_pickup, complete, cancel, block, unblock, archive, reopen |
26
+ | `issue_triage` | `POST /api/issues/:id/triage` | Verdicts: approve, cancel, keep, defer (with optional ICE + ttl_seconds) |
27
+ | `issue_comment` | `POST/PATCH/DELETE /api/issues/:id/comments[/:cid]` | Author server-stamped, soft-delete preserved |
28
+ | `issue_dependency` | `POST/DELETE /api/issues/:id/dependencies[/:did]` | `depends_on` cycle-checked; remove hardcodes `reason: "recorded_in_error"` |
29
+ | `issue_requires_human` | `POST/DELETE /api/issues/:id/requires-human` | Set replaces step rows atomically; clear soft-deletes them |
30
+ | `issue_retro` | `PUT /api/issues/:id/retro` | Requires terminal card; replace semantics |
31
31
 
32
32
  ## Build + test
33
33
 
package/dist/handlers.js CHANGED
@@ -22,17 +22,24 @@ export async function issueList(client, args) {
22
22
  query.limit = args.limit;
23
23
  if (args.offset !== undefined)
24
24
  query.offset = args.offset;
25
- return client.request({ method: "GET", path: "", query });
25
+ return client.request({ method: "GET", path: "", query, repo: args.repo });
26
26
  }
27
27
  // ---------------- issue_get ----------------
28
28
  export async function issueGet(client, args) {
29
- return client.request({ method: "GET", path: `/${encodeURIComponent(args.id)}` });
29
+ return client.request({
30
+ method: "GET",
31
+ path: `/${encodeURIComponent(args.id)}`,
32
+ repo: args.repo,
33
+ });
30
34
  }
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
35
+ export async function issueCreate(client, args, defaultRepo) {
36
+ // Resolve the target repo ONCE: per-call `args.repo` override (target
37
+ // another registered repo) wins, else the dispatch's `DANX_REPO_NAME`.
38
+ // `repo` lives in BOTH the query string (via http-client) AND the body
39
+ // (server requires it inside the JSON envelope per
34
40
  // `write/create.ts::validate`). Sending only one fails-loud — the
35
- // duplication is intentional.
41
+ // duplication is intentional. The dashboard 404s an unknown repo.
42
+ const repo = args.repo ?? defaultRepo;
36
43
  const body = {
37
44
  repo,
38
45
  type: args.type,
@@ -47,10 +54,13 @@ export async function issueCreate(client, args, repo) {
47
54
  body.effort_level = args.effort_level;
48
55
  if (args.phase_children !== undefined)
49
56
  body.phase_children = args.phase_children;
50
- return client.request({ method: "POST", path: "", body });
57
+ return client.request({ method: "POST", path: "", body, repo });
51
58
  }
52
59
  export async function issueEdit(client, args) {
53
- const { id, ...rest } = args;
60
+ // `repo` is a transport concern (query), NOT a patch field — pull it
61
+ // out of `rest` so it never leaks into the `/edit` body (the server
62
+ // 400s on non-allowlisted keys).
63
+ const { id, repo, ...rest } = args;
54
64
  // Strip undefined so we don't send `{title: undefined}` — JSON.stringify
55
65
  // drops them anyway but explicit is clearer.
56
66
  const body = {};
@@ -62,26 +72,30 @@ export async function issueEdit(client, args) {
62
72
  method: "PATCH",
63
73
  path: `/${encodeURIComponent(id)}/edit`,
64
74
  body,
75
+ repo,
65
76
  });
66
77
  }
67
78
  export async function issueTransition(client, args) {
68
- const { id, ...body } = args;
79
+ const { id, repo, ...body } = args;
69
80
  return client.request({
70
81
  method: "POST",
71
82
  path: `/${encodeURIComponent(id)}/transition`,
72
83
  body,
84
+ repo,
73
85
  });
74
86
  }
75
87
  export async function issueTriage(client, args) {
76
- const { id, ...body } = args;
88
+ const { id, repo, ...body } = args;
77
89
  return client.request({
78
90
  method: "POST",
79
91
  path: `/${encodeURIComponent(id)}/triage`,
80
92
  body,
93
+ repo,
81
94
  });
82
95
  }
83
96
  export async function issueComment(client, args) {
84
97
  const idEnc = encodeURIComponent(args.id);
98
+ const repo = args.repo;
85
99
  if (args.action === "add") {
86
100
  if (typeof args.text !== "string") {
87
101
  throw new Error("issue_comment action=add requires text");
@@ -90,6 +104,7 @@ export async function issueComment(client, args) {
90
104
  method: "POST",
91
105
  path: `/${idEnc}/comments`,
92
106
  body: { text: args.text },
107
+ repo,
93
108
  });
94
109
  }
95
110
  if (args.action === "edit") {
@@ -103,6 +118,7 @@ export async function issueComment(client, args) {
103
118
  method: "PATCH",
104
119
  path: `/${idEnc}/comments/${args.comment_id}`,
105
120
  body: { text: args.text },
121
+ repo,
106
122
  });
107
123
  }
108
124
  // delete
@@ -112,10 +128,12 @@ export async function issueComment(client, args) {
112
128
  return client.request({
113
129
  method: "DELETE",
114
130
  path: `/${idEnc}/comments/${args.comment_id}`,
131
+ repo,
115
132
  });
116
133
  }
117
134
  export async function issueDependency(client, args) {
118
135
  const idEnc = encodeURIComponent(args.id);
136
+ const repo = args.repo;
119
137
  if (args.action === "add") {
120
138
  if (args.kind === undefined) {
121
139
  throw new Error("issue_dependency action=add requires kind");
@@ -131,6 +149,7 @@ export async function issueDependency(client, args) {
131
149
  target_id: args.target_id,
132
150
  reason: args.reason ?? "",
133
151
  },
152
+ repo,
134
153
  });
135
154
  }
136
155
  // remove
@@ -145,10 +164,12 @@ export async function issueDependency(client, args) {
145
164
  // agent that types the wrong reason gets a refusal here at MCP
146
165
  // arg-validation, not a confusing 400 envelope.
147
166
  body: { reason: "recorded_in_error" },
167
+ repo,
148
168
  });
149
169
  }
150
170
  export async function issueRequiresHuman(client, args) {
151
171
  const idEnc = encodeURIComponent(args.id);
172
+ const repo = args.repo;
152
173
  if (args.set) {
153
174
  if (typeof args.reason !== "string") {
154
175
  throw new Error("issue_requires_human set=true requires reason");
@@ -160,18 +181,21 @@ export async function issueRequiresHuman(client, args) {
160
181
  method: "POST",
161
182
  path: `/${idEnc}/requires-human`,
162
183
  body: { reason: args.reason, steps: args.steps },
184
+ repo,
163
185
  });
164
186
  }
165
187
  return client.request({
166
188
  method: "DELETE",
167
189
  path: `/${idEnc}/requires-human`,
190
+ repo,
168
191
  });
169
192
  }
170
193
  export async function issueRetro(client, args) {
171
- const { id, ...body } = args;
194
+ const { id, repo, ...body } = args;
172
195
  return client.request({
173
196
  method: "PUT",
174
197
  path: `/${encodeURIComponent(id)}/retro`,
175
198
  body,
199
+ repo,
176
200
  });
177
201
  }
@@ -6,7 +6,7 @@ export class DashboardHttpClient {
6
6
  this.fetchImpl = fetchImpl;
7
7
  }
8
8
  async request(args) {
9
- const url = this.buildUrl(args.path, args.query);
9
+ const url = this.buildUrl(args.path, args.query, args.repo);
10
10
  const headers = {
11
11
  Authorization: `Bearer ${this.config.token}`,
12
12
  Accept: "application/json",
@@ -47,7 +47,7 @@ export class DashboardHttpClient {
47
47
  }
48
48
  return { ok: false, status: res.status, body: parsed };
49
49
  }
50
- buildUrl(path, extraQuery) {
50
+ buildUrl(path, extraQuery, repoOverride) {
51
51
  const base = this.config.baseUrl.replace(/\/+$/, "");
52
52
  const [rawPath, existingQs] = path.split("?", 2);
53
53
  let cleanPath;
@@ -58,7 +58,7 @@ export class DashboardHttpClient {
58
58
  cleanPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
59
59
  }
60
60
  const params = new URLSearchParams(existingQs ?? "");
61
- params.set("repo", this.config.repo);
61
+ params.set("repo", repoOverride ?? this.config.repo);
62
62
  if (extraQuery) {
63
63
  for (const [k, v] of Object.entries(extraQuery)) {
64
64
  if (v === undefined || v === null)
@@ -66,6 +66,6 @@ export class DashboardHttpClient {
66
66
  params.set(k, String(v));
67
67
  }
68
68
  }
69
- return `${base}/api/v2/issues${cleanPath}?${params.toString()}`;
69
+ return `${base}/api/issues${cleanPath}?${params.toString()}`;
70
70
  }
71
71
  }
package/dist/index.js CHANGED
@@ -2,30 +2,32 @@
2
2
  /**
3
3
  * @thehammer/danx-dashboard-mcp
4
4
  *
5
- * Stdio MCP server wrapping danxbot's dashboard `/api/v2/issues/*`
5
+ * Stdio MCP server wrapping danxbot's dashboard `/api/issues/*`
6
6
  * normalized DB-backed HTTP routes (DX-704 Phase 2 / DX-811). Replaces
7
7
  * the legacy "agents Edit/Write YAML files directly" pattern: every
8
8
  * tool here POSTs/GETs against the dashboard, which atomically applies
9
- * the change via the v2 transactional layer and publishes SSE.
9
+ * the change via the transactional layer and publishes SSE.
10
10
  *
11
11
  * Tool surface (all exposed as `mcp__danx_dashboard__<name>` once wired
12
12
  * through the workspace `.mcp.json`):
13
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
14
+ * - issue_list GET /api/issues
15
+ * - issue_get GET /api/issues/:id
16
+ * - issue_create POST /api/issues
17
+ * - issue_edit PATCH /api/issues/:id/edit
18
+ * - issue_transition POST /api/issues/:id/transition
19
+ * - issue_triage POST /api/issues/:id/triage
20
+ * - issue_comment POST/PATCH/DELETE /api/issues/:id/comments[/:cid]
21
+ * - issue_dependency POST/DELETE /api/issues/:id/dependencies[/:did]
22
+ * - issue_requires_human POST/DELETE /api/issues/:id/requires-human
23
+ * - issue_retro PUT /api/issues/:id/retro
24
24
  *
25
25
  * Env at boot (validated fail-loud — missing → process.exit(1)):
26
26
  * DANXBOT_DASHBOARD_URL dashboard base (e.g. http://danxbot-dashboard:5555)
27
27
  * DANXBOT_DISPATCH_TOKEN per-dispatch bearer
28
- * DANX_REPO_NAME repo scope, appended as ?repo=<name> on every call
28
+ * DANX_REPO_NAME default repo scope, appended as ?repo=<name>;
29
+ * each tool's optional `repo` arg overrides it
30
+ * per-call for cross-repo reads/writes
29
31
  *
30
32
  * Envelope contract: every tool returns the dashboard's response body
31
33
  * verbatim wrapped as `{ok, status, body}`. Refusals (4xx with the v2
@@ -88,8 +90,20 @@ const TRANSITION_ACTIONS = [
88
90
  "reopen",
89
91
  ];
90
92
  const TRIAGE_VERDICTS = ["approve", "cancel", "keep", "defer"];
93
+ // Optional per-call repo override shared by every tool. Omitted → the
94
+ // server binding's DANX_REPO_NAME is used; provided → that registered
95
+ // repo is targeted instead (the dashboard 404s an unknown repo). Spread
96
+ // into each tool's Zod input object so cross-repo work is reachable from
97
+ // any dispatch without rebinding the MCP server.
98
+ const repoField = {
99
+ repo: z
100
+ .string()
101
+ .min(1)
102
+ .optional()
103
+ .describe("Target another registered repo; omit to use this dispatch's repo. Unknown repo → 404."),
104
+ };
91
105
  // ---------------- 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.", {
106
+ server.tool("issue_list", "List issues for the dispatch's repo by default via GET /api/issues. Pass `repo` to list another registered repo instead (unknown repo → 404). 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: Issue[]}. 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
107
  status_derived: z.string().optional(),
94
108
  type: z.enum(ISSUE_TYPES).optional(),
95
109
  parent_id: z.string().nullable().optional(),
@@ -98,13 +112,15 @@ server.tool("issue_list", "List issues for the current repo via GET /api/v2/issu
98
112
  include_closed: z.boolean().optional(),
99
113
  limit: z.number().int().positive().max(1000).optional(),
100
114
  offset: z.number().int().nonnegative().optional(),
115
+ ...repoField,
101
116
  }, async (args) => jsonResult(await issueList(client, args)));
102
117
  // ---------------- 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.", {
118
+ server.tool("issue_get", "Fetch a single hydrated issue via GET /api/issues/:id. Issue ids are globally unique across repos, so this resolves from any dispatch regardless of `repo`. 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
119
  id: z.string().min(1),
105
- }, async ({ id }) => jsonResult(await issueGet(client, { id })));
120
+ ...repoField,
121
+ }, async (args) => jsonResult(await issueGet(client, args)));
106
122
  // ---------------- 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.", {
123
+ server.tool("issue_create", "Create a fresh card via POST /api/issues. Defaults to the dispatch's repo; pass `repo` to create the card in another registered repo (forwarded into body.repo + ?repo=; unknown repo → 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.", {
108
124
  type: z.enum(ISSUE_TYPES),
109
125
  title: z.string().min(1),
110
126
  description: z.string(),
@@ -120,9 +136,10 @@ server.tool("issue_create", "Create a fresh card via POST /api/v2/issues. INVARI
120
136
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
121
137
  }))
122
138
  .optional(),
139
+ ...repoField,
123
140
  }, async (args) => jsonResult(await issueCreate(client, args, config.repo)));
124
141
  // ---------------- 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.", {
142
+ server.tool("issue_edit", "Patch prose fields only via PATCH /api/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
143
  id: z.string().min(1),
127
144
  title: z.string().min(1).optional(),
128
145
  description: z.string().optional(),
@@ -134,17 +151,19 @@ server.tool("issue_edit", "Patch prose fields only via PATCH /api/v2/issues/:id/
134
151
  .optional(),
135
152
  effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
136
153
  parent_id: z.string().nullable().optional(),
154
+ ...repoField,
137
155
  }, async (args) => jsonResult(await issueEdit(client, args)));
138
156
  // ---------------- issue_transition ----------------
139
- server.tool("issue_transition", "Stamp a lifecycle transition via POST /api/v2/issues/:id/transition. **THIS IS THE ONLY WAY TO MOVE A CARD'S LIFECYCLE STATE** — DX-835 separated card lifecycle (this tool) from dispatch finalization (`mcp__danxbot__danxbot_complete`). The worker no longer infers card moves from `danxbot_complete.status`; agents that want the card to move MUST call this tool BEFORE calling `danxbot_complete`. 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** (stamps completed_at — moves card to Done; this is YOUR explicit decision, not a side effect of danxbot_complete; REFUSES 409 on Epic if any phase child non-terminal — see non_terminal_phases[]), cancel (stamps cancelled_at — terminal), **block** (requires non-empty reason — stamps blocked_at + blocked_reason + clears dispatch; USE THIS when the CARD itself cannot proceed without human intervention; distinct from env-fault dispatch failures which use `danxbot_complete({status:'failed'})`), 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).", {
157
+ server.tool("issue_transition", "Stamp a lifecycle transition via POST /api/issues/:id/transition. **THIS IS THE ONLY WAY TO MOVE A CARD'S LIFECYCLE STATE** — DX-835 separated card lifecycle (this tool) from dispatch finalization (`mcp__danxbot__danxbot_complete`). The worker no longer infers card moves from `danxbot_complete.status`; agents that want the card to move MUST call this tool BEFORE calling `danxbot_complete`. 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** (stamps completed_at — moves card to Done; this is YOUR explicit decision, not a side effect of danxbot_complete; REFUSES 409 on Epic if any phase child non-terminal — see non_terminal_phases[]), cancel (stamps cancelled_at — terminal), **block** (requires non-empty reason — stamps blocked_at + blocked_reason + clears dispatch; USE THIS when the CARD itself cannot proceed without human intervention; distinct from env-fault dispatch failures which use `danxbot_complete({status:'failed'})`), 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
158
  id: z.string().min(1),
141
159
  action: z.enum(TRANSITION_ACTIONS),
142
160
  reason: z.string().optional(),
143
161
  summary: z.string().optional(),
144
162
  dispatch_id: z.string().min(1).optional(),
163
+ ...repoField,
145
164
  }, async (args) => jsonResult(await issueTransition(client, args)));
146
165
  // ---------------- 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.", {
166
+ server.tool("issue_triage", "Record a triage verdict via POST /api/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
167
  id: z.string().min(1),
149
168
  verdict: z.enum(TRIAGE_VERDICTS),
150
169
  reason: z.string().min(1),
@@ -156,32 +175,36 @@ server.tool("issue_triage", "Record a triage verdict via POST /api/v2/issues/:id
156
175
  })
157
176
  .optional(),
158
177
  ttl_seconds: z.number().positive().optional(),
178
+ ...repoField,
159
179
  }, async (args) => jsonResult(await issueTriage(client, args)));
160
180
  // ---------------- 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).", {
181
+ server.tool("issue_comment", "Comment CRUD via /api/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
182
  id: z.string().min(1),
163
183
  action: z.enum(["add", "edit", "delete"]),
164
184
  comment_id: z.number().int().positive().optional(),
165
185
  text: z.string().min(1).optional(),
186
+ ...repoField,
166
187
  }, async (args) => jsonResult(await issueComment(client, args)));
167
188
  // ---------------- 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.", {
189
+ server.tool("issue_dependency", "Dependency CRUD via /api/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
190
  id: z.string().min(1),
170
191
  action: z.enum(["add", "remove"]),
171
192
  kind: z.enum(["depends_on", "conflict_on"]).optional(),
172
193
  target_id: z.string().min(1).optional(),
173
194
  reason: z.string().optional(),
174
195
  dependency_id: z.number().int().positive().optional(),
196
+ ...repoField,
175
197
  }, async (args) => jsonResult(await issueDependency(client, args)));
176
198
  // ---------------- 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.", {
199
+ server.tool("issue_requires_human", "Set or clear the requires_human dispatch gate via /api/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
200
  id: z.string().min(1),
179
201
  set: z.boolean(),
180
202
  reason: z.string().optional(),
181
203
  steps: z.array(z.string().min(1)).optional(),
204
+ ...repoField,
182
205
  }, async (args) => jsonResult(await issueRequiresHuman(client, args)));
183
206
  // ---------------- 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?}.", {
207
+ 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?}.", {
185
208
  id: z.string().min(1),
186
209
  good: z.string(),
187
210
  bad: z.string(),
@@ -190,6 +213,7 @@ server.tool("issue_retro", "Replace the retro block via PUT /api/v2/issues/:id/r
190
213
  sha: z.string().min(1),
191
214
  subject: z.string().optional(),
192
215
  })),
216
+ ...repoField,
193
217
  }, async (args) => jsonResult(await issueRetro(client, args)));
194
218
  // ---------------- main ----------------
195
219
  async function main() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.2",
4
- "description": "Stdio MCP server wrapping danxbot's dashboard /api/v2/issues/* normalized DB-backed HTTP routes for dispatched agents (DX-704 Phase 2).",
3
+ "version": "0.1.5",
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",
7
7
  "main": "dist/index.js",