@thehammer/danx-dashboard-mcp 0.1.2 → 0.1.3

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
@@ -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
 
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)
package/dist/index.js CHANGED
@@ -25,7 +25,9 @@
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/v2/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: 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
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/v2/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/v2/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,6 +136,7 @@ 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
142
  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.", {
@@ -134,6 +151,7 @@ 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
157
  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).", {
@@ -142,6 +160,7 @@ server.tool("issue_transition", "Stamp a lifecycle transition via POST /api/v2/i
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
166
  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.", {
@@ -156,6 +175,7 @@ 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
181
  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).", {
@@ -163,6 +183,7 @@ server.tool("issue_comment", "Comment CRUD via /api/v2/issues/:id/comments[/:cid
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
189
  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.", {
@@ -172,6 +193,7 @@ server.tool("issue_dependency", "Dependency CRUD via /api/v2/issues/:id/dependen
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
199
  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.", {
@@ -179,6 +201,7 @@ server.tool("issue_requires_human", "Set or clear the requires_human dispatch ga
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
207
  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?}.", {
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
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
5
  "license": "MIT",
6
6
  "type": "module",