@thehammer/danx-dashboard-mcp 0.1.31 → 0.1.33
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 +2 -2
- package/dist/handlers.js +19 -24
- package/dist/handlers.test.js +51 -0
- package/dist/index.js +57 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,9 +26,9 @@ All exposed as `mcp__danx_dashboard__<name>` once wired through the workspace `m
|
|
|
26
26
|
| `issue_create` | `POST /api/issues` | Epic REQUIRES non-empty `phase_children[]` (atomic insert) |
|
|
27
27
|
| `issue_edit` | `PATCH /api/issues/:id/edit` | Prose + structured keys (`title`, `description`, `ac`, `checklists`, `effort_level`, `parent_id`, `priority`, `list_id`); semantic keys refused with 400 + pointer to dedicated handler. `priority` (DX-1532) takes a tier word (`low`/`high`/…) or a number in `[0,6)` — the ONLY way to set the numeric column the Trello label + dashboard badge read; never set priority via description prose |
|
|
28
28
|
| `issue_transition` | `POST /api/issues/:id/transition` | Actions: ready, pickup, rollback_pickup, complete, cancel, block, unblock, archive, reopen |
|
|
29
|
-
| `issue_triage` | `POST /api/issues/:id/triage` | Verdicts: approve, cancel, keep, defer (with optional ICE + ttl_seconds) |
|
|
29
|
+
| `issue_triage` | `POST /api/issues/:id/triage` | Verdicts: approve, cancel, keep, defer (with optional ICE + ttl_seconds). None of these are a cross-card ordering gate — a card held at Review via `keep` can still be readied independent of a sibling; use `issue_dependency` to sequence cards |
|
|
30
30
|
| `issue_comment` | `POST/PATCH/DELETE /api/issues/:id/comments[/:cid]` | Author server-stamped, soft-delete preserved |
|
|
31
|
-
| `issue_dependency` | `POST/DELETE /api/issues/:id/dependencies[/:did]` | `depends_on` cycle-checked; remove hardcodes `reason: "recorded_in_error"
|
|
31
|
+
| `issue_dependency` | `POST/DELETE /api/issues/:id/dependencies[/:did]` | `depends_on` cycle-checked; remove hardcodes `reason: "recorded_in_error"`. The only mechanism the dispatch picker enforces to sequence one card after another — status alone is not a substitute |
|
|
32
32
|
| `issue_requires_human` | `POST/DELETE /api/issues/:id/requires-human` | Set replaces step rows atomically; clear soft-deletes them |
|
|
33
33
|
| `issue_retro` | `PUT /api/issues/:id/retro` | Requires terminal card; replace semantics |
|
|
34
34
|
|
package/dist/handlers.js
CHANGED
|
@@ -24,45 +24,40 @@
|
|
|
24
24
|
import { readFile } from "node:fs/promises";
|
|
25
25
|
import { basename, extname, isAbsolute } from "node:path";
|
|
26
26
|
import { resolvePriority } from "./priority.js";
|
|
27
|
+
/**
|
|
28
|
+
* `filter`/`fields`/`sort` are JSON/CSV-encoded onto the query string (the
|
|
29
|
+
* route parses `?filter=<JSON>`, `?fields=a,b`, `?sort=<JSON>` — see
|
|
30
|
+
* `src/issues/read/reader.ts#parseEnvelope`); each is omitted entirely when
|
|
31
|
+
* empty so the wire carries no `{}`/`[]` noise. `board` stays a flat
|
|
32
|
+
* top-level param, resolved by the HTTP client exactly as before.
|
|
33
|
+
*/
|
|
27
34
|
export async function issueList(client, args) {
|
|
28
|
-
// status_derived is display-only on the server but the route accepts
|
|
29
|
-
// it as a projection filter — passthrough verbatim. parent_id=null is
|
|
30
|
-
// the legitimate "root cards only" filter; the server reader treats
|
|
31
|
-
// the literal string "null" identically.
|
|
32
35
|
const query = {};
|
|
33
|
-
if (args.
|
|
34
|
-
query.
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
if (args.filter !== undefined && Object.keys(args.filter).length > 0) {
|
|
37
|
+
query.filter = JSON.stringify(args.filter);
|
|
38
|
+
}
|
|
39
|
+
if (args.fields !== undefined && args.fields.length > 0) {
|
|
40
|
+
query.fields = args.fields.join(",");
|
|
37
41
|
}
|
|
38
|
-
if (args.
|
|
39
|
-
query.
|
|
42
|
+
if (args.sort !== undefined && args.sort.length > 0) {
|
|
43
|
+
query.sort = JSON.stringify(args.sort);
|
|
40
44
|
}
|
|
41
|
-
if (args.assigned_agent !== undefined)
|
|
42
|
-
query.assigned_agent = args.assigned_agent;
|
|
43
|
-
if (args.include_closed !== undefined)
|
|
44
|
-
query.include_closed = args.include_closed;
|
|
45
|
-
if (args.status_derived !== undefined)
|
|
46
|
-
query.status_derived = args.status_derived;
|
|
47
|
-
if (args.q !== undefined)
|
|
48
|
-
query.q = args.q;
|
|
49
45
|
if (args.limit !== undefined)
|
|
50
46
|
query.limit = args.limit;
|
|
51
47
|
if (args.offset !== undefined)
|
|
52
48
|
query.offset = args.offset;
|
|
53
|
-
// DX-1163 — the agent surface is lean by default: opt the route into the
|
|
54
|
-
// `IssueListRowLeanV2` projection (id/type/title/status/parent_id/
|
|
55
|
-
// assigned_agent/children-ids/priority). The SPA omits this and keeps the
|
|
56
|
-
// rich board row; `issue_get` still returns the full card body.
|
|
57
|
-
query.lean = true;
|
|
58
49
|
// DX-1171 — board-only: forward the qualified board id, no repo.
|
|
59
50
|
return client.request({ method: "GET", path: "", query, board: args.board });
|
|
60
51
|
}
|
|
61
|
-
// ---------------- issue_get ----------------
|
|
62
52
|
export async function issueGet(client, args) {
|
|
53
|
+
const query = {};
|
|
54
|
+
if (args.fields !== undefined && args.fields.length > 0) {
|
|
55
|
+
query.fields = args.fields.join(",");
|
|
56
|
+
}
|
|
63
57
|
return client.request({
|
|
64
58
|
method: "GET",
|
|
65
59
|
path: `/${encodeURIComponent(args.id)}`,
|
|
60
|
+
query,
|
|
66
61
|
board: args.board,
|
|
67
62
|
});
|
|
68
63
|
}
|
|
@@ -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
|
@@ -127,6 +127,36 @@ const EFFORT_VALUES = [
|
|
|
127
127
|
];
|
|
128
128
|
const ISSUE_TYPES = ["Epic", "Bug", "Feature", "Story", "Chore"];
|
|
129
129
|
const NON_EPIC_TYPES = ["Bug", "Feature", "Story", "Chore"];
|
|
130
|
+
// DX-935 / DX-937 — field-group taxonomy for the nested read envelope,
|
|
131
|
+
// hand-copied from `src/issues/read/field-groups.ts` (LIST_GROUPS / GET_GROUPS
|
|
132
|
+
// — this package cannot import server source). Drift surfaces at runtime as a
|
|
133
|
+
// server 400, not silently.
|
|
134
|
+
const LIST_FIELD_GROUPS = [
|
|
135
|
+
"description",
|
|
136
|
+
"ac",
|
|
137
|
+
"comments",
|
|
138
|
+
"retro",
|
|
139
|
+
"dependencies",
|
|
140
|
+
"triage",
|
|
141
|
+
"requires_human",
|
|
142
|
+
"assignment",
|
|
143
|
+
"quality_gates",
|
|
144
|
+
"children",
|
|
145
|
+
"effort",
|
|
146
|
+
];
|
|
147
|
+
const GET_FIELD_GROUPS = [
|
|
148
|
+
...LIST_FIELD_GROUPS,
|
|
149
|
+
"mirrors",
|
|
150
|
+
"code_review_items",
|
|
151
|
+
];
|
|
152
|
+
const SORT_ORDERS = ["asc", "desc"];
|
|
153
|
+
const sortField = z
|
|
154
|
+
.array(z.object({
|
|
155
|
+
column: z.string().min(1),
|
|
156
|
+
order: z.enum(SORT_ORDERS),
|
|
157
|
+
}))
|
|
158
|
+
.optional()
|
|
159
|
+
.describe("Multi-column sort — ordered list of {column, order}. Absent → the server's default order (priority desc, repo_name asc, with a numeric-id tiebreaker always appended).");
|
|
130
160
|
// DX-1290 — the uniform 4-state checklist-item status. Terminal = passing|cancelled.
|
|
131
161
|
const CHECKLIST_ITEM_STATUSES = [
|
|
132
162
|
"incomplete",
|
|
@@ -162,21 +192,37 @@ const boardField = {
|
|
|
162
192
|
.describe("Target another board by its qualified id `<repo>:<slug>` (e.g. `platform:the-supply-operations-hub`); omit to use this dispatch's board. Unknown board → 404."),
|
|
163
193
|
};
|
|
164
194
|
// ---------------- issue_list ----------------
|
|
165
|
-
server.tool("issue_list", "List issues for the dispatch's board by default via GET /api/issues. Board-scoped; defaults to the dispatch's board. Pass `board` (a qualified id `<repo>:<slug>`) to list another board instead (unknown board → 404).
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
195
|
+
server.tool("issue_list", "List issues for the dispatch's board by default via GET /api/issues. Board-scoped; defaults to the dispatch's board. Pass `board` (a qualified id `<repo>:<slug>`) to list another board instead (unknown board → 404). Nested envelope (DX-935 / DX-937 — hard-cut, no flat params): `filter` — the OLD flat filters, now nested (type, parent_id, dispatchable_derived, status_derived[], self_dispatchable_derived, assigned_agent, include_closed, include_deleted, and `q` — free-text over id+title+description, the former standalone `q` param now lives at `filter.q`). `fields` — opt-in named field-GROUPS (description, ac, comments, retro, dependencies, triage, requires_human, assignment, quality_gates, children, effort); THE DEFAULT RESPONSE (no `fields`) IS MINIMAL — only cheap scalar columns (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent), zero joins. Point any heavy read (full description, comments[], retro, ac items, dependency edges, triage history, quality-gate rows, children ids) at the matching `fields` entry rather than assuming it's already on the row. `sort` — ordered [{column, order}] (id|priority|repo_name|title|type|status_derived|triage_ice_total|created_at|updated_at); absent → default order (priority desc, repo_name asc) with an always-appended numeric-id tiebreaker (DX-10 follows DX-9). `limit`/`offset` — optional paging (no cap by default). Use issue_get for a single fully-detailed card.", {
|
|
196
|
+
filter: z
|
|
197
|
+
.object({
|
|
198
|
+
q: z.string().optional(),
|
|
199
|
+
type: z.enum(ISSUE_TYPES).optional(),
|
|
200
|
+
parent_id: z.string().nullable().optional(),
|
|
201
|
+
dispatchable_derived: z.boolean().optional(),
|
|
202
|
+
status_derived: z.array(z.string()).optional(),
|
|
203
|
+
self_dispatchable_derived: z.boolean().optional(),
|
|
204
|
+
assigned_agent: z.string().optional(),
|
|
205
|
+
include_closed: z.boolean().optional(),
|
|
206
|
+
include_deleted: z.boolean().optional(),
|
|
207
|
+
})
|
|
208
|
+
.optional()
|
|
209
|
+
.describe("Nested list filters (DX-935) — the hard-cut replacement for the old flat top-level params, including the former standalone `q` (now `filter.q`). Omit entirely for no filtering."),
|
|
210
|
+
fields: z
|
|
211
|
+
.array(z.enum(LIST_FIELD_GROUPS))
|
|
212
|
+
.optional()
|
|
213
|
+
.describe("Opt-in field-GROUPS to add to the minimal default row: description, ac, comments, retro, dependencies, triage, requires_human, assignment, quality_gates, children, effort. Absent/empty = minimal scalars only — no joins."),
|
|
214
|
+
sort: sortField,
|
|
173
215
|
limit: z.number().int().positive().max(1000).optional(),
|
|
174
216
|
offset: z.number().int().nonnegative().optional(),
|
|
175
217
|
...boardField,
|
|
176
218
|
}, async (args) => jsonResult(await issueList(client, args)));
|
|
177
219
|
// ---------------- issue_get ----------------
|
|
178
|
-
server.tool("issue_get",
|
|
220
|
+
server.tool("issue_get", "Fetch a single issue via GET /api/issues/:id. Board-scoped; defaults to the dispatch's board. Issue ids are globally unique, so this resolves from any dispatch regardless of `board`. DEFAULT RESPONSE IS MINIMAL (DX-935 / DX-937) — only cheap scalar columns (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent); no joined collections. Pass `fields` to opt into named field-GROUPS: description (full description body), ac (acceptance-criteria + checklists model), comments (comments[]), retro (retro good/bad/action_items/commits), dependencies (waiting_on/conflict_on/blocked gate state), triage (triage history + ICE), requires_human (the requires_human gate + steps), assignment (dispatch/assigned_agent/lifecycle timestamps), quality_gates (DX-1177 — one row per registered gate {gate, required, status pending|pass|fail, completed_at, message}; a required PRE gate not yet `pass` pre-empts the work dispatch, and `issue_transition complete` refuses while a required POST gate row != pass), children (child id list + rollups), mirrors (external mirror sync state), code_review_items (code-review findings). Point any heavy read at the matching `fields` entry rather than assuming it's already on the row. 404 envelope on unknown id.", {
|
|
179
221
|
id: z.string().min(1),
|
|
222
|
+
fields: z
|
|
223
|
+
.array(z.enum(GET_FIELD_GROUPS))
|
|
224
|
+
.optional()
|
|
225
|
+
.describe("Opt-in field-GROUPS to add to the minimal default row: description, ac, comments, retro, dependencies, triage, requires_human, assignment, quality_gates, children, mirrors, code_review_items. Absent/empty = minimal scalars only."),
|
|
180
226
|
...boardField,
|
|
181
227
|
}, async (args) => jsonResult(await issueGet(client, args)));
|
|
182
228
|
// ---------------- issue_create ----------------
|
|
@@ -262,7 +308,7 @@ server.tool("issue_transition", "Stamp a lifecycle transition via POST /api/issu
|
|
|
262
308
|
...boardField,
|
|
263
309
|
}, async (args) => jsonResult(await issueTransition(client, args)));
|
|
264
310
|
// ---------------- issue_triage ----------------
|
|
265
|
-
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
|
|
311
|
+
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. None of these verdicts create a cross-card ordering gate — a card left at Review (via keep) is invisible to the plan-dependency gate's live-partner comparison and can be readied independent of any sibling's status. To sequence one card after another, use issue_dependency (kind: depends_on) — that is the only mechanism the dispatch picker actually enforces regardless of status.", {
|
|
266
312
|
id: z.string().min(1),
|
|
267
313
|
verdict: z.enum(TRIAGE_VERDICTS),
|
|
268
314
|
reason: z.string().min(1),
|
|
@@ -310,7 +356,7 @@ server.tool("issue_checklist", "Targeted checklist CUD via /api/issues/:id/check
|
|
|
310
356
|
...boardField,
|
|
311
357
|
}, async (args) => jsonResult(await issueChecklist(client, args)));
|
|
312
358
|
// ---------------- issue_dependency ----------------
|
|
313
|
-
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.', {
|
|
359
|
+
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. This is the ONLY mechanism the dispatch picker enforces to sequence one card after another — leaving a card at a Review/held status (e.g. an issue_triage "keep" verdict) is NOT a substitute and provides no cross-card ordering protection.', {
|
|
314
360
|
id: z.string().min(1),
|
|
315
361
|
action: z.enum(["add", "remove"]),
|
|
316
362
|
kind: z.enum(["depends_on", "conflict_on"]).optional(),
|
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.33",
|
|
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",
|