@thehammer/danx-dashboard-mcp 0.1.60 → 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` | Returns hydrated card + ancestor chain |
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}` |
@@ -33,6 +33,10 @@ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `m
33
33
  | `issue_requires_human` | `POST/DELETE /api/issues/:id/requires-human` | Set replaces step rows atomically; clear soft-deletes them. A successful set also returns `solutions_reminder: {solution_count, instruction}` |
34
34
  | `issue_retro` | `PUT /api/issues/:id/retro` | Requires terminal card; replace semantics |
35
35
 
36
+ ## `plan_get` — cheap by default, opt-in for the rest (DX-2727)
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` (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
+
36
40
  ## `listen` — a working session's event listener
37
41
 
38
42
  `plan_connect` connects the session to a plan AND returns `listener: {command, persistent: true, instruction}`. The agent arms `command` with Claude Code's Monitor tool (`persistent: true`); every line the command prints becomes a notification that wakes the session.
package/dist/handlers.js CHANGED
@@ -49,11 +49,46 @@ 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;
59
+ /**
60
+ * Fetch one card via `GET /api/issues/:id`, or many via
61
+ * `GET /api/issues/batch?ids=...` (DX-2727) — a real batch read that keeps
62
+ * the single-id route's global, cross-board resolution rather than routing
63
+ * through `issue_list` (board-scoped per call, and its filter has no `id`
64
+ * property). Exactly one of `id` / `ids` must be given; the batch form
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.
68
+ */
52
69
  export async function issueGet(client, args) {
70
+ if (args.id !== undefined && args.ids !== undefined) {
71
+ throw new Error("issue_get: pass exactly one of id or ids, not both");
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
+ }
53
76
  const query = {};
54
77
  if (args.fields !== undefined && args.fields.length > 0) {
55
78
  query.fields = args.fields.join(",");
56
79
  }
80
+ if (args.ids !== undefined) {
81
+ if (args.ids.length === 0) {
82
+ throw new Error("issue_get: ids must be a non-empty array");
83
+ }
84
+ query.ids = args.ids.join(",");
85
+ // DX-2727 — the batch route resolves globally, exactly like the
86
+ // single-id path (see its own doc comment): no `board` is forwarded.
87
+ return client.request({ method: "GET", path: "/batch", query });
88
+ }
89
+ if (args.id === undefined) {
90
+ throw new Error("issue_get: pass exactly one of id or ids");
91
+ }
57
92
  return client.request({
58
93
  method: "GET",
59
94
  path: `/${encodeURIComponent(args.id)}`,
@@ -704,21 +739,49 @@ export async function issueAttach(client, args, deps = {}) {
704
739
  /* ── Plans: what a connected session may read and add to (DX-2683) ────────── */
705
740
  const PLANS_BASE_PATH = "/api/plans";
706
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
+ ];
707
760
  /** Every plan, plus which one THIS session is connected to. */
708
761
  export async function planList(client) {
709
762
  return client.request({ method: "GET", path: "", basePath: PLANS_BASE_PATH });
710
763
  }
711
764
  /**
712
- * One plan, entire — its cards, the boards they cover, its goals + rules +
713
- * caveats, its architecture sections, and the sessions working on it. ONE
714
- * call rather than five, which is what keeps this tool surface small enough
715
- * to be worth an agent's context.
765
+ * One plan — its cheap scalars by default, or opt into its cards, its goals
766
+ * + rules + caveats, its architecture sections, and the sessions working on
767
+ * it via `fields` (DX-2727). One call rather than several, which is what
768
+ * keeps this tool surface small enough to be worth an agent's context even
769
+ * once every part of a plan is opt-in rather than always-on.
716
770
  */
717
771
  export async function planGet(client, args = {}) {
772
+ const query = {};
773
+ if (args.fields !== undefined && args.fields.length > 0) {
774
+ query.fields = args.fields.join(",");
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;
718
780
  return client.request({
719
781
  method: "GET",
720
782
  path: args.plan_id === undefined ? "/mine" : `/${args.plan_id}/full`,
721
783
  basePath: PLANS_BASE_PATH,
784
+ query,
722
785
  });
723
786
  }
724
787
  function readIssuedTicket(body) {
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * through the workspace `mcp.template.json`):
13
13
  *
14
14
  * - issue_list GET /api/issues
15
- * - issue_get GET /api/issues/:id
15
+ * - issue_get GET /api/issues/:id | /api/issues/batch (DX-2727 ids[] batch)
16
16
  * - issue_create POST /api/issues
17
17
  * - issue_edit PATCH /api/issues/:id/edit
18
18
  * - issue_transition POST /api/issues/:id/transition
@@ -33,7 +33,7 @@
33
33
  * - brief_get_page GET /api/brief/page (DX-2083 / DX-2484)
34
34
  * - brief_set_page PUT /api/brief/page (DX-2083 / DX-2484)
35
35
  * - plan_list GET /api/plans (DX-2683)
36
- * - plan_get GET /api/plans/:id/full | /api/plans/mine
36
+ * - plan_get GET /api/plans/:id/full | /api/plans/mine (DX-2727: bare = scalars only, fields= opts in)
37
37
  * - plan_create POST /api/plans
38
38
  * - plan_connect POST /api/plan-sessions/me/plan
39
39
  * - plan_add_record POST /api/plans/mine/records
@@ -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];
@@ -307,8 +307,14 @@ server.tool("issue_list", "List issues for the dispatch's board by default via G
307
307
  ...boardField,
308
308
  }, async (args) => jsonResult(await issueList(client, args)));
309
309
  // ---------------- issue_get ----------------
310
- 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, 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: 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. 404 envelope on unknown id.", {
311
- id: z.string().min(1),
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.", {
311
+ id: z.string().min(1).optional().describe("A single card id. Mutually exclusive with `ids`."),
312
+ ids: z
313
+ .array(z.string().min(1))
314
+ .min(1)
315
+ .max(ISSUE_BATCH_GET_MAX)
316
+ .optional()
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.`),
312
318
  fields: z
313
319
  .array(z.enum(GET_FIELD_GROUPS))
314
320
  .optional()
@@ -643,13 +649,30 @@ server.tool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug
643
649
  // also takes no plan id, but for a different reason: it MAKES a plan rather
644
650
  // than acting on one, so there is no existing plan for an id to name yet.
645
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)));
646
- server.tool("plan_get", "Read one plan WHOLE via GET /api/plans (DX-2683) — its member cards (with the boards they cover), its goals, rules and caveats, its architecture sections, the sessions working on it, and your own session state. One call, not five. 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. Returns `{plan, cards, boards, records: {goal, rule, caveat} (each `[{id, ref, body, context, contentHash}]` — `context` is markdown detail or null), architecture: {sections: [{id, planId, contentHash, title, content, sortOrder, createdAt, updatedAt}]}, sessions, session, sessionListenerAttached}` — `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`.", {
647
653
  plan_id: z
648
654
  .number()
649
655
  .int()
650
656
  .positive()
651
657
  .optional()
652
658
  .describe("A plan id from `plan_list`. Omit to read the plan this session is connected to."),
659
+ fields: z
660
+ .array(z.enum(PLAN_FIELD_GROUPS))
661
+ .optional()
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`."),
653
676
  }, async (args) => jsonResult(await planGet(client, args)));
654
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.", {
655
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.60",
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",