@thehammer/danx-dashboard-mcp 0.1.61 → 0.1.62

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
@@ -22,7 +22,7 @@ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `m
22
22
  | Tool | HTTP | Notes |
23
23
  |---|---|---|
24
24
  | `issue_list` | `GET /api/issues` | filters: `type`, `parent_id` (null → root-only), `dispatchable_derived`, `assigned_agent`, `include_closed`, `limit`, `offset` |
25
- | `issue_get` | `GET /api/issues/:id` or `GET /api/issues/batch` | Pass `id` for one card, or `ids[]` (DX-2727) to resolve many across boards in ONE call — global, no board scope, per-id `not_found` rather than a whole-call 404. Minimal scalars by default; `fields` opts in |
25
+ | `issue_get` | `GET /api/issues/:id` or `GET /api/issues/batch` | Pass `id` for one card, or `ids[]` (DX-2727, at most 100) to resolve many across boards in ONE call — global, so `ids` with `board` throws; per-id `not_found` rather than a whole-call 404. Minimal scalars by default; `fields` opts in |
26
26
  | `issue_create` | `POST /api/issues` | Epic REQUIRES non-empty `phase_children[]` (atomic insert). `title` = short domain-naming label; `summary` = 1–3 plain-language sentences, always shown; `description` = the collapsed "Context" body. Root and every phase child take their own `summary` |
27
27
  | `issue_edit` | `PATCH /api/issues/:id/edit` | Prose + structured keys (`title`, `summary` (null clears), `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. A successful `block` also returns `solutions_reminder: {solution_count, instruction}` |
@@ -35,7 +35,7 @@ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `m
35
35
 
36
36
  ## `plan_get` — cheap by default, opt-in for the rest (DX-2727)
37
37
 
38
- A bare `plan_get` (no `fields`) returns only the plan's cheap scalars — `plan`, `boards`, `cardCount`, `bucketCounts`, `session`, `sessionListenerAttached` — plus `available_field_groups` naming what else exists. Pass `fields` to opt into `cards` (capped at 200 members, with `cards_total` reporting the true count so a truncation is never silent), `records` (every goal/rule/caveat) or `records:goal` / `records:rule` / `records:caveat` (just one kind), `architecture`, and `sessions`. A `plan_get` made only to grab a hash before a one-line edit no longer pays for the architecture document or every member card.
38
+ A bare `plan_get` (no `fields`) returns only the plan's cheap scalars — `plan`, `boards`, `cardCount`, `bucketCounts`, `session`, `sessionListenerAttached` — plus `available_field_groups` naming what else exists. Pass `fields` to opt into `cards` (paged: `cards_offset`, default 0, and `cards_limit`, 1..1000, default 200, pick the page; the response carries `cards_total` and `cards_offset`, so page with `cards_offset` while `cards_offset + cards.length < cards_total` — either paging arg without `cards` in `fields` is a 400), `records` (every goal/rule/caveat) or `records:goal` / `records:rule` / `records:caveat` (just one kind), `architecture`, and `sessions`. A `plan_get` made only to grab a hash before a one-line edit no longer pays for the architecture document or every member card.
39
39
 
40
40
  ## `listen` — a working session's event listener
41
41
 
package/dist/handlers.js CHANGED
@@ -49,19 +49,30 @@ export async function issueList(client, args) {
49
49
  // DX-1171 — board-only: forward the qualified board id, no repo.
50
50
  return client.request({ method: "GET", path: "", query, board: args.board });
51
51
  }
52
+ // ---------------- issue_get ----------------
53
+ /**
54
+ * The most distinct ids one batch `issue_get` may name — mirrors the server's
55
+ * `src/issues/list-page.ts#ISSUE_BATCH_GET_MAX` (the batch route 400s above it);
56
+ * `__tests__/handlers.test.ts` asserts the two agree.
57
+ */
58
+ export const ISSUE_BATCH_GET_MAX = 100;
52
59
  /**
53
60
  * Fetch one card via `GET /api/issues/:id`, or many via
54
61
  * `GET /api/issues/batch?ids=...` (DX-2727) — a real batch read that keeps
55
62
  * the single-id route's global, cross-board resolution rather than routing
56
63
  * through `issue_list` (board-scoped per call, and its filter has no `id`
57
64
  * property). Exactly one of `id` / `ids` must be given; the batch form
58
- * reports a per-id `not_found` list rather than failing the whole call, and
59
- * ignores `board` — the same no-scope contract the single-id form already has.
65
+ * reports a per-id `not_found` list rather than failing the whole call. The
66
+ * batch form is a global read with no board scope, so `ids` together with
67
+ * `board` is REFUSED here rather than `board` being silently dropped.
60
68
  */
61
69
  export async function issueGet(client, args) {
62
70
  if (args.id !== undefined && args.ids !== undefined) {
63
71
  throw new Error("issue_get: pass exactly one of id or ids, not both");
64
72
  }
73
+ if (args.ids !== undefined && args.board !== undefined) {
74
+ throw new Error("issue_get: board does not apply to the ids batch form — it resolves every id globally; drop board");
75
+ }
65
76
  const query = {};
66
77
  if (args.fields !== undefined && args.fields.length > 0) {
67
78
  query.fields = args.fields.join(",");
@@ -728,6 +739,24 @@ export async function issueAttach(client, args, deps = {}) {
728
739
  /* ── Plans: what a connected session may read and add to (DX-2683) ────────── */
729
740
  const PLANS_BASE_PATH = "/api/plans";
730
741
  const PLAN_SESSIONS_BASE_PATH = "/api/plan-sessions";
742
+ /**
743
+ * DX-2727 — the plan_get field-group taxonomy: the ONE copy in this package
744
+ * (the `plan_get` zod enum in `index.ts` reads this const, and the type is
745
+ * derived from it). The published package cannot import server source at
746
+ * runtime, so it stays a literal; `__tests__/handlers.test.ts` imports the
747
+ * server's `src/issues/plan-field-groups.ts#PLAN_FIELD_GROUPS` and asserts
748
+ * the two are equal, so drift fails a test rather than a live call.
749
+ * `records` is the union of every kind; `records:<kind>` narrows to one.
750
+ */
751
+ export const PLAN_FIELD_GROUPS = [
752
+ "cards",
753
+ "records",
754
+ "records:goal",
755
+ "records:rule",
756
+ "records:caveat",
757
+ "architecture",
758
+ "sessions",
759
+ ];
731
760
  /** Every plan, plus which one THIS session is connected to. */
732
761
  export async function planList(client) {
733
762
  return client.request({ method: "GET", path: "", basePath: PLANS_BASE_PATH });
@@ -744,6 +773,10 @@ export async function planGet(client, args = {}) {
744
773
  if (args.fields !== undefined && args.fields.length > 0) {
745
774
  query.fields = args.fields.join(",");
746
775
  }
776
+ if (args.cards_offset !== undefined)
777
+ query.cards_offset = args.cards_offset;
778
+ if (args.cards_limit !== undefined)
779
+ query.cards_limit = args.cards_limit;
747
780
  return client.request({
748
781
  method: "GET",
749
782
  path: args.plan_id === undefined ? "/mine" : `/${args.plan_id}/full`,
package/dist/index.js CHANGED
@@ -88,7 +88,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
88
88
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
89
89
  import { z } from "zod";
90
90
  import { DashboardHttpClient } from "./http-client.js";
91
- import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueQualityGate, issueQualityGateVerdict, issueRequiresHuman, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, planAddArchitectureSection, planAddCard, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteRecord, planGet, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
91
+ import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueQualityGate, issueQualityGateVerdict, issueRequiresHuman, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, planAddArchitectureSection, planAddCard, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, ISSUE_BATCH_GET_MAX, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
92
92
  import { PRIORITY_TIER_WORDS } from "./priority.js";
93
93
  function readEnvOrDie(name) {
94
94
  const v = process.env[name];
@@ -231,18 +231,6 @@ const GET_FIELD_GROUPS = [
231
231
  "mirrors",
232
232
  "code_review_items",
233
233
  ];
234
- // DX-2727 — `plan_get`'s field-group taxonomy, hand-copied from
235
- // `src/issues/plan-field-groups.ts` (this package cannot import server
236
- // source). `records` is every kind; `records:<kind>` narrows to just one.
237
- const PLAN_FIELD_GROUPS = [
238
- "cards",
239
- "records",
240
- "records:goal",
241
- "records:rule",
242
- "records:caveat",
243
- "architecture",
244
- "sessions",
245
- ];
246
234
  const SORT_ORDERS = ["asc", "desc"];
247
235
  const sortField = z
248
236
  .array(z.object({
@@ -319,13 +307,14 @@ server.tool("issue_list", "List issues for the dispatch's board by default via G
319
307
  ...boardField,
320
308
  }, async (args) => jsonResult(await issueList(client, args)));
321
309
  // ---------------- issue_get ----------------
322
- server.tool("issue_get", "Fetch one card via GET /api/issues/:id, or MANY via GET /api/issues/batch?ids=... (DX-2727) — pass exactly one of `id` (single) or `ids` (batch, an array). Board-scoped for the single form; defaults to the dispatch's board. Issue ids are globally unique, so BOTH forms resolve from any dispatch regardless of `board` (the batch form ignores `board` entirely — it is a global, cross-board read). 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, content_hash — DX-2741, the card's optimistic-concurrency token; pass it back as `content_hash` on an `issue_edit` touching title/description/checklists); no joined collections. Pass `fields` to opt into named field-GROUPS, applied per card in the batch form too: description (full description body + the plain-language summary), solutions (the card's live candidate solutions, each with its content_hash, + every operator answer in decisions[]), 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. Single form: 404 envelope on unknown id. Batch form: returns `{issues: [...found cards...], not_found: [...ids that didn't resolve...]}` — an unknown or soft-deleted id never fails the whole call, it just lands in `not_found` alongside every card that DID resolve.", {
310
+ server.tool("issue_get", "Fetch one card via GET /api/issues/:id, or MANY via GET /api/issues/batch?ids=... (DX-2727) — pass exactly one of `id` (single) or `ids` (batch, an array). Board-scoped for the single form; defaults to the dispatch's board. Issue ids are globally unique, so BOTH forms resolve from any dispatch regardless of `board`; the batch form is a global, cross-board read and REFUSES `board` (passing `ids` with `board` throws). The batch form takes at most 100 ids per call — split a larger set. 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, content_hash — DX-2741, the card's optimistic-concurrency token; pass it back as `content_hash` on an `issue_edit` touching title/description/checklists); no joined collections. Pass `fields` to opt into named field-GROUPS, applied per card in the batch form too: description (full description body + the plain-language summary), solutions (the card's live candidate solutions, each with its content_hash, + every operator answer in decisions[]), 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. Single form: 404 envelope on unknown id. Batch form: returns `{issues: [...found cards...], not_found: [...ids that didn't resolve...]}` — an unknown or soft-deleted id never fails the whole call, it just lands in `not_found` alongside every card that DID resolve.", {
323
311
  id: z.string().min(1).optional().describe("A single card id. Mutually exclusive with `ids`."),
324
312
  ids: z
325
313
  .array(z.string().min(1))
326
314
  .min(1)
315
+ .max(ISSUE_BATCH_GET_MAX)
327
316
  .optional()
328
- .describe("Batch form (DX-2727): every requested id, resolved globally in ONE call. Mutually exclusive with `id`. Reports not_found per id rather than failing the whole call."),
317
+ .describe(`Batch form (DX-2727): every requested id (at most ${ISSUE_BATCH_GET_MAX}), resolved globally in ONE call. Mutually exclusive with \`id\`, and never combined with \`board\` (the batch is global; passing both throws). Reports not_found per id rather than failing the whole call.`),
329
318
  fields: z
330
319
  .array(z.enum(GET_FIELD_GROUPS))
331
320
  .optional()
@@ -660,7 +649,7 @@ server.tool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug
660
649
  // also takes no plan id, but for a different reason: it MAKES a plan rather
661
650
  // than acting on one, so there is no existing plan for an id to name yet.
662
651
  server.tool("plan_list", "List every plan via GET /api/plans (DX-2683), and learn which plan THIS session is connected to. Plans are GLOBAL, not board-scoped: a plan is a named, dated set of cards an operator assembled by hand, and its cards may come from any repository. Returns `{ok, status, body: {plans: [{id, name, createdAt, cardCount, boards}], session, sessionListenerAttached}}`. `session` is your own registration — `{sessionId, title, planId, planName, firstSeenAt, lastActiveAt}` — or `null` if this process is not running inside a Claude Code session. A `planId` of null means you are connected to no plan: read any plan with `plan_get`, then `plan_connect` to the one you are working on (or ask the operator to connect you from the Plans list). `sessionListenerAttached` says whether your event listener is running: `false` while connected to a plan means you will NOT hear about its cards — call `plan_connect` again and arm the Monitor it returns. NOTE this is NOT the board Brief (`brief_list`), which is a different feature entirely.", {}, async () => jsonResult(await planList(client)));
663
- server.tool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id` to read ANY plan (browsing another plan is useful and changes nothing); OMIT it to read the plan this session is connected to. Omitting it while connected to no plan fails loud with `{error: \"session_not_connected\"}` — connect first. A BARE call (no `fields`) returns ONLY the plan's cheap scalars: `{plan, boards, cardCount, bucketCounts, session, sessionListenerAttached, available_field_groups}` — no member cards, no goals/rules/caveats, no architecture body. Pass `fields` to opt into the rest, one call at a time: `cards` (every member card, capped at 200 with `cards_total` reporting the true count so a truncation is never silent), `records` (every goal+rule+caveat, keyed by kind) or `records:goal` / `records:rule` / `records:caveat` (just that one kind — cheaper than the full union), `architecture` (`{sections: [{id, planId, contentHash, title, content, sortOrder, createdAt, updatedAt}]}`), `sessions` (every session connected to the plan). `session`/`sessionListenerAttached` (your own connection state) and `available_field_groups` ride EVERY response, gated or not. `sessionListenerAttached: false` while connected means your event listener is not running; call `plan_connect` again and arm the Monitor it returns. ALWAYS `plan_get`/`plan_get_architecture_section` immediately before `plan_update_architecture_section`/`plan_delete_architecture_section` and pass the returned `contentHash` back as `base_hash`.", {
652
+ server.tool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id` to read ANY plan (browsing another plan is useful and changes nothing); OMIT it to read the plan this session is connected to. Omitting it while connected to no plan fails loud with `{error: \"session_not_connected\"}` — connect first. A BARE call (no `fields`) returns ONLY the plan's cheap scalars: `{plan, boards, cardCount, bucketCounts, session, sessionListenerAttached, available_field_groups}` — no member cards, no goals/rules/caveats, no architecture body. Pass `fields` to opt into the rest, one call at a time: `cards` (member cards, PAGED: `cards_offset` (default 0) and `cards_limit` (1..1000, default 200) pick the page, and the response carries `cards_total` and `cards_offset` — page with cards_offset while cards_offset + cards.length < cards_total; either paging arg without `fields: [\"cards\"]` is a 400), `records` (every goal+rule+caveat, keyed by kind) or `records:goal` / `records:rule` / `records:caveat` (just that one kind — cheaper than the full union), `architecture` (`{sections: [{id, planId, contentHash, title, content, sortOrder, createdAt, updatedAt}]}`), `sessions` (every session connected to the plan). `session`/`sessionListenerAttached` (your own connection state) and `available_field_groups` ride EVERY response, gated or not. `sessionListenerAttached: false` while connected means your event listener is not running; call `plan_connect` again and arm the Monitor it returns. ALWAYS `plan_get`/`plan_get_architecture_section` immediately before `plan_update_architecture_section`/`plan_delete_architecture_section` and pass the returned `contentHash` back as `base_hash`.", {
664
653
  plan_id: z
665
654
  .number()
666
655
  .int()
@@ -671,6 +660,19 @@ server.tool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id
671
660
  .array(z.enum(PLAN_FIELD_GROUPS))
672
661
  .optional()
673
662
  .describe("Opt-in field-GROUPS: cards, records (every kind) or records:goal/records:rule/records:caveat (one kind), architecture, sessions. Absent/empty = cheap scalars only (plan, boards, cardCount, bucketCounts) plus session state."),
663
+ cards_offset: z
664
+ .number()
665
+ .int()
666
+ .nonnegative()
667
+ .optional()
668
+ .describe("Where the `cards` page starts (default 0). Requires `fields` to include `cards`. Page with cards_offset while cards_offset + cards.length < cards_total."),
669
+ cards_limit: z
670
+ .number()
671
+ .int()
672
+ .positive()
673
+ .max(1000)
674
+ .optional()
675
+ .describe("How many cards one page holds, 1..1000 (default 200). Requires `fields` to include `cards`."),
674
676
  }, async (args) => jsonResult(await planGet(client, args)));
675
677
  server.tool("plan_create", "Create a new, empty plan via POST /api/plans (DX-2531). GLOBAL — a plan is not board-scoped, and this call adds no cards, no records, and no architecture sections; it does NOT connect any session to the new plan (call `plan_connect` separately, exactly as adding a card to a plan is its own separate step). Returns `{ok, status, body: {plan: {id, name, createdAt}}}`. Use the returned `plan.id` with `plan_connect` to start working on it, or with `plan_get({plan_id})` to browse it.", {
676
678
  name: z.string().min(1).describe("The plan's name — shown in the Plans list."),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.61",
3
+ "version": "0.1.62",
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",