@thehammer/danx-dashboard-mcp 0.1.148 → 0.1.150

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,12 +22,13 @@ 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, 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 |
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` (DX-3426) opts in via a recursive field TREE — `{"description": true, "problems": {"solutions": true}, "comments": {"limit": 10}}` — not a flat group list; a cursor-paged relation (today only `comments`) takes its page args (`limit`/`before`) inside its own nested object, so paging now works on the batch form too. See `resource_fields` |
26
+ | `resource_fields` | `GET /api/resources/:resource/fields` | DX-3426 — what an `issue_get`-style field tree may name for one resource: `{resource, description, always, hashes, fields, relations}`. Install-global, no `board`. `resource: "issue"` is the root; a relation's own `resource` in the response is what to call this again with, one level deeper |
26
27
  | `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
28
  | `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
29
  | `issue_transition` | `POST /api/issues/:id/transition` | Actions: ready, pickup, rollback_pickup, complete, cancel, block, unblock, archive, reopen. `block` is a dispatch hold only — it never marks the card as needing a human; use `issue_problem` add for that |
29
- | `issue_problem` | `GET/POST/PATCH/DELETE /api/issues/:id/problems[/:pid]` | Actions list / add / edit / remove. A problem is one statement the operator must resolve (a question or a plan flaw) with its own solutions and answers; `add` takes `statement` + optional `solutions[]` in one transaction and IS what puts the card in front of a human (`open_problem_count > 0`) — a successful add also returns a `reminders: [{key, text}]` array (DX-3365 — the reminder registry's middleware, DB-driven and operator-overridable from the dashboard). Edit + remove are hash-guarded (`base_hash`, 409 `stale_problem`); removing the card's last open problem is always allowed — it just means the card no longer needs a human |
30
- | `issue_solution` | `POST/PATCH/DELETE /api/issues/:id/problems/:pid/solutions[/:sid]` | Actions add / edit / remove, `problem_id` required (list via `issue_problem list`). Edit + remove are hash-guarded (`base_hash`, 409 `stale_solution` with the current row); at most one live `recommended` per problem; a chosen option cannot be edited. No answer action on any tool — the operator answers in the dashboard |
30
+ | `issue_problem` | `GET/POST/PATCH/DELETE /api/issues/:id/problems[/:pid]` + `POST .../answer`, `change-answer`, `unanswer` | Actions list / add / edit / remove / answer / change_answer / unanswer (DX-3471). A problem is one statement the operator must resolve (a question or a plan flaw) with its own solutions and answers; `add` takes `statement` + optional `solutions[]` in one transaction and IS what puts the card in front of a human (`open_problem_count > 0`) — a successful add also returns a `reminders: [{key, text}]` array (DX-3365 — the reminder registry's middleware, DB-driven and operator-overridable from the dashboard). Edit + remove are hash-guarded (`base_hash`, 409 `stale_problem`); removing the card's last open problem is always allowed — it just means the card no longer needs a human. `answer` `{solution_id, note?}` \| `{freeform}` and `change_answer` (same + `base_hash` naming the live DECISION) / `unanswer` (`{base_hash}`) record or retract an operator's decision — server-refused 403 for a dispatched agent's machine credential (DX-2830), reachable from an OPERATOR SESSION relaying an in-chat decision, which also durably records the relaying session (`relayed_by_session`) via the same session header every call already carries |
31
+ | `issue_solution` | `POST/PATCH/DELETE /api/issues/:id/problems/:pid/solutions[/:sid]` | Actions add / edit / remove, `problem_id` required (list via `issue_problem list`). Edit + remove are hash-guarded (`base_hash`, 409 `stale_solution` with the current row); at most one live `recommended` per problem; a chosen option cannot be edited. No answer action on THIS tool — see `issue_problem`'s answer / change_answer / unanswer actions |
31
32
  | `issue_triage` | `POST /api/issues/:id/triage` | Send `{confidence, reason}` — an integer 0-5 score; the server computes the verdict (approve/cancel/keep/defer) against the board's configured thresholds (DX-2086). `keep`/`defer` now block the card. None of these are a cross-card ordering gate; use `issue_dependency` to sequence cards |
32
33
  | `issue_comment` | `POST/PATCH/DELETE /api/issues/:id/comments[/:cid]` | Author server-stamped, soft-delete preserved |
33
34
  | `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 |
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { mkdtempSync, writeFileSync, symlinkSync, rmSync, realpathSync, } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { isEntrypointModule } from "./entrypoint.js";
7
+ describe("isEntrypointModule (DX-1647)", () => {
8
+ let dir;
9
+ let real;
10
+ let link;
11
+ beforeAll(() => {
12
+ dir = mkdtempSync(join(tmpdir(), "entrypoint-test-"));
13
+ real = join(dir, "index.js");
14
+ writeFileSync(real, "// stub entry\n");
15
+ link = join(dir, "danx-dashboard-mcp"); // mimics node_modules/.bin symlink
16
+ symlinkSync(real, link);
17
+ });
18
+ afterAll(() => {
19
+ rmSync(dir, { recursive: true, force: true });
20
+ });
21
+ // import.meta.url always reports the module REALPATH — model that here.
22
+ const moduleUrl = () => pathToFileURL(realpathSync(real)).href;
23
+ it("true when argv[1] is the real file (direct `node index.js`)", () => {
24
+ expect(isEntrypointModule(moduleUrl(), real)).toBe(true);
25
+ });
26
+ it("true when argv[1] is a SYMLINK to the file (npx / global bin) — the fleet regression", () => {
27
+ // The symlink path !== the realpath, but the entrypoint MUST still be
28
+ // detected, or npx imports the module and exits without booting the server.
29
+ expect(link).not.toBe(realpathSync(link));
30
+ expect(isEntrypointModule(moduleUrl(), link)).toBe(true);
31
+ });
32
+ it("false when argv[1] is undefined (module imported, not run as bin)", () => {
33
+ expect(isEntrypointModule(moduleUrl(), undefined)).toBe(false);
34
+ });
35
+ it("false when argv[1] is an empty string", () => {
36
+ expect(isEntrypointModule(moduleUrl(), "")).toBe(false);
37
+ });
38
+ it("false when argv[1] points at a different real file", () => {
39
+ const other = join(dir, "other.js");
40
+ writeFileSync(other, "// other\n");
41
+ expect(isEntrypointModule(moduleUrl(), other)).toBe(false);
42
+ });
43
+ });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * DX-3426 — the shared recursive FIELD-TREE shape.
3
+ *
4
+ * The dashboard's single-card and batch card reads (`issue_get` today;
5
+ * `issue_list` / `plan_get` in later cards, per DX-3426's own follow-up scope)
6
+ * take a nested JSON tree instead of a flat field-GROUP list: each key names a
7
+ * field or relation of the resource at that level, and each value is either
8
+ * `true` (the field, or a relation's child with its default fields), a
9
+ * non-negative integer (a cursor-paged relation's own page ARGUMENT — e.g.
10
+ * `{"comments": {"limit": 10, "before": 812}}`, where `limit`/`before` are
11
+ * themselves plain integer values inside the nested object), or another
12
+ * field tree (a relation's child with a NAMED field selection). Example:
13
+ *
14
+ * {"description": true, "ac": true, "problems": {"solutions": {"steps": true}, "decisions": true}, "comments": {"limit": 10}}
15
+ *
16
+ * One schema, one module — every tool that accepts a field tree imports THIS
17
+ * export rather than redeclaring the shape, so the recursion is defined once
18
+ * and every caller's JSON Schema serializes the same way. `z.lazy` is
19
+ * required because the schema refers to itself; `zod-to-json-schema` (the
20
+ * MCP SDK's own zod v3 → JSON Schema converter, see
21
+ * `@modelcontextprotocol/sdk`'s `zod-json-schema-compat.ts`) resolves that
22
+ * self-reference to a local `$ref` rather than unrolling it infinitely —
23
+ * verified directly against the listed tool schema in
24
+ * `__tests__/tool-defs.test.ts`, not assumed from reading the library's
25
+ * source. `issue_solution`'s pre-existing `steps` field (DX-3310) is the
26
+ * same `z.lazy` self-reference pattern already shipping on this server.
27
+ */
28
+ import { z } from "zod";
29
+ /**
30
+ * The shared schema. A record whose values are `true`, a non-negative
31
+ * integer, or the same schema recursively — generic on purpose (no
32
+ * issue-specific field names baked in here) so `issue_list` / `plan_get` can
33
+ * reuse it unchanged in their own later cards.
34
+ */
35
+ export const fieldTreeSchema = z.lazy(() => z.record(z.union([z.literal(true), z.number().int().nonnegative(), fieldTreeSchema])));
package/dist/handlers.js CHANGED
@@ -95,16 +95,15 @@ export async function issueGet(client, args) {
95
95
  if (args.ids !== undefined && args.board !== undefined) {
96
96
  throw new Error("issue_get: board does not apply to the ids batch form — it resolves every id globally; drop board");
97
97
  }
98
- // DX-3321 — comments paging is a SINGLE-id-route feature
99
- // (`GET /api/issues/:id?comments_limit=&comments_offset=`); the batch
100
- // route has no per-card paging at all. Refuse rather than silently drop,
101
- // same as the `ids` + `board` refusal just above.
102
- if (args.ids !== undefined && (args.comments_limit !== undefined || args.comments_offset !== undefined)) {
103
- throw new Error("issue_get: comments_limit/comments_offset do not apply to the ids batch form — it has no per-card comment paging; drop them or use the single-id form");
104
- }
105
98
  const query = {};
106
- if (args.fields !== undefined && args.fields.length > 0) {
107
- query.fields = args.fields.join(",");
99
+ // DX-3426 — the tree travels as ONE JSON-encoded query param (mirrors how
100
+ // `issue_list`'s `filter`/`sort` envelopes already ride the wire above),
101
+ // rather than the old CSV-of-group-names. Sent on both the single and
102
+ // batch forms — comment paging (or any other relation argument) now lives
103
+ // INSIDE the tree, so there is nothing left for the old ids+comments_*
104
+ // refusal to guard against.
105
+ if (args.fields !== undefined && Object.keys(args.fields).length > 0) {
106
+ query.fields = JSON.stringify(args.fields);
108
107
  }
109
108
  if (args.ids !== undefined) {
110
109
  if (args.ids.length === 0) {
@@ -118,10 +117,6 @@ export async function issueGet(client, args) {
118
117
  if (args.id === undefined) {
119
118
  throw new Error("issue_get: pass exactly one of id or ids");
120
119
  }
121
- if (args.comments_limit !== undefined)
122
- query.comments_limit = args.comments_limit;
123
- if (args.comments_offset !== undefined)
124
- query.comments_offset = args.comments_offset;
125
120
  return client.request({
126
121
  method: "GET",
127
122
  path: `/${encodeURIComponent(args.id)}`,
@@ -568,7 +563,31 @@ export async function issueChecklist(client, args) {
568
563
  * `.describe()` in `index.ts` — that description, not this comment, is what
569
564
  * a calling agent actually reads.
570
565
  *
571
- * No answer action, for the reason `issueSolution` gives.
566
+ * DX-3471 — `answer` {solution_id, note?} | {freeform} → POST :pid/answer;
567
+ * `change_answer` {base_hash, solution_id, note?} | {base_hash, freeform} →
568
+ * POST :pid/change-answer (retracts the live decision and records a new one
569
+ * in ONE server transaction); `unanswer` {base_hash} → POST :pid/unanswer
570
+ * (retracts with no new answer, reopening the problem). `base_hash` on these
571
+ * two names the live DECISION (`decisionContentHash`), NOT the problem —
572
+ * read it off a problem's `decisions[].content_hash` (from `list`), never
573
+ * off the problem's own `content_hash`. A stale one → 409 `stale_decision`
574
+ * with `currentHash` + `currentProblem`: merge, then retry.
575
+ *
576
+ * ALL THREE ARE OPERATOR-SESSION ONLY. The server's route policy admits only
577
+ * a `human` principal on these three routes (`route-policies/index.ts`) — a
578
+ * dispatched agent's machine credential is refused before it ever reaches a
579
+ * handler, for the DX-2830 reason: an agent that could answer its own
580
+ * question could release the very human gate it set to stop and wait for.
581
+ * An operator-session agent (running under the OPERATOR's own dashboard
582
+ * token, human-authenticated) CAN call these — this is what turns "the
583
+ * operator decided in chat" into a durable, correctly-attributed decision
584
+ * instead of a comment or a raw curl call. The decision records the human
585
+ * decider (`decided_by`) and, when this call carries the session id
586
+ * `@thehammer/danx-dashboard-mcp` already stamps on every request
587
+ * (`x-danx-session-id`, DX-2683 — no separate parameter needed here),
588
+ * which working session relayed it (`relayed_by_session` on the resulting
589
+ * decision, DX-3471) — so a decision entered from the dashboard's own UI
590
+ * (no header) stays distinguishable from one an agent relayed in chat.
572
591
  */
573
592
  /**
574
593
  * DX-3310 (nit) — per-action allow-list for `refuseInapplicableFields`.
@@ -583,6 +602,9 @@ const PROBLEM_ACTION_FIELDS = {
583
602
  add: ["statement", "context", "type", "summary", "solutions"],
584
603
  edit: ["problem_id", "base_hash", "statement", "context", "summary", "type"],
585
604
  remove: ["problem_id", "base_hash"],
605
+ answer: ["problem_id", "solution_id", "note", "freeform"],
606
+ change_answer: ["problem_id", "base_hash", "solution_id", "note", "freeform"],
607
+ unanswer: ["problem_id", "base_hash"],
586
608
  };
587
609
  export async function issueProblem(client, args) {
588
610
  const idEnc = encodeURIComponent(args.id);
@@ -652,8 +674,66 @@ export async function issueProblem(client, args) {
652
674
  board,
653
675
  });
654
676
  }
677
+ // DX-3471 — answer/change_answer share the exact-one-of solution_id/freeform
678
+ // shape the server's own `validateAnswer` enforces (`write/problem-answer.ts`);
679
+ // checked here too so a caller gets the same clear boundary error every
680
+ // OTHER missing/conflicting-arg case in this tool already gets, rather than
681
+ // a round trip to the server for a 400 the client could have caught.
682
+ case "answer": {
683
+ const problemId = need.id(args.problem_id, "problem_id");
684
+ return client.request({
685
+ method: "POST",
686
+ path: `/${idEnc}/problems/${problemId}/answer`,
687
+ body: answerChoiceBody(args, "answer"),
688
+ board,
689
+ });
690
+ }
691
+ case "change_answer": {
692
+ const problemId = need.id(args.problem_id, "problem_id");
693
+ const baseHash = need.string(args.base_hash, "base_hash");
694
+ return client.request({
695
+ method: "POST",
696
+ path: `/${idEnc}/problems/${problemId}/change-answer`,
697
+ body: { base_hash: baseHash, ...answerChoiceBody(args, "change_answer") },
698
+ board,
699
+ });
700
+ }
701
+ case "unanswer": {
702
+ const problemId = need.id(args.problem_id, "problem_id");
703
+ const baseHash = need.string(args.base_hash, "base_hash");
704
+ return client.request({
705
+ method: "POST",
706
+ path: `/${idEnc}/problems/${problemId}/unanswer`,
707
+ body: { base_hash: baseHash },
708
+ board,
709
+ });
710
+ }
655
711
  }
656
712
  }
713
+ /**
714
+ * DX-3471 — the either/or answer shape `answer`/`change_answer` both send:
715
+ * `{solution_id, note?}` XOR `{freeform}`, mirroring the server's own
716
+ * `validateAnswer` (`write/problem-answer.ts`) so a caller gets the SAME
717
+ * refusal at this client boundary that the server would otherwise 400 on,
718
+ * with no round trip.
719
+ */
720
+ function answerChoiceBody(args, mode) {
721
+ const hasSolution = args.solution_id !== undefined;
722
+ const hasFreeform = args.freeform !== undefined;
723
+ if (hasSolution === hasFreeform) {
724
+ throw new Error(`issue_problem ${mode} requires either solution_id or freeform (never both, never neither)`);
725
+ }
726
+ if (hasFreeform) {
727
+ if (args.note !== undefined) {
728
+ throw new Error(`issue_problem ${mode}: note qualifies a chosen solution — it is not valid with freeform`);
729
+ }
730
+ return { freeform: args.freeform };
731
+ }
732
+ const body = { solution_id: args.solution_id };
733
+ if (args.note !== undefined)
734
+ body.note = args.note;
735
+ return body;
736
+ }
657
737
  /**
658
738
  * One problem's candidate solutions, AND (DX-3310) one solution's individual
659
739
  * procedure steps, via `/api/issues/:id/problems/:pid/solutions[/:sid][/steps[/:stepId]]`
@@ -664,10 +744,13 @@ export async function issueProblem(client, args) {
664
744
  * solution/step id from the wrong scope → 404, nesting past depth 3 → 400)
665
745
  * pass through verbatim.
666
746
  *
667
- * There is deliberately NO answer action, on this tool or any other. Answering
668
- * releases the human gate on a card — an agent that could answer its own
669
- * question could release the very stop it set to wait for a human. The operator
670
- * answers in the dashboard.
747
+ * There is deliberately NO answer action on THIS tool — answering releases the
748
+ * human gate on a card, and this tool has no notion of "who is calling".
749
+ * `issue_problem`'s `answer` / `change_answer` / `unanswer` actions (DX-3471)
750
+ * are where that lives instead: server-side human-principal-only (a
751
+ * dispatched agent's machine credential is refused 403), so an operator
752
+ * session can record a decision but a dispatched worker cannot release its
753
+ * own gate.
671
754
  */
672
755
  const SOLUTION_ACTION_FIELDS = {
673
756
  add: ["title", "body", "pro", "con", "recommended", "steps"],
@@ -1635,3 +1718,20 @@ export async function dispatchTranscriptSearch(client, args) {
1635
1718
  },
1636
1719
  };
1637
1720
  }
1721
+ // ---------------- resource_fields (DX-3426) ----------------
1722
+ const RESOURCES_BASE_PATH = "/api/resources";
1723
+ /**
1724
+ * `GET /api/resources/:resource/fields` — what a field TREE (`issue_get`'s
1725
+ * `fields`, and any later tool built on the same shape) may name for ONE
1726
+ * resource: `{resource, description, always, hashes, fields, relations}`.
1727
+ * Install-global (no `board` — mirrors `failure_category_list`'s own
1728
+ * board-less rationale just above: nothing server-side would read one).
1729
+ * Unknown resource → 404 `unknown_resource`.
1730
+ */
1731
+ export async function resourceFields(client, args) {
1732
+ return client.request({
1733
+ method: "GET",
1734
+ path: `/${encodeURIComponent(args.resource)}/fields`,
1735
+ basePath: RESOURCES_BASE_PATH,
1736
+ });
1737
+ }
package/dist/index.js CHANGED
@@ -12,7 +12,11 @@
12
12
  * through the workspace `mcp.template.json`):
13
13
  *
14
14
  * - issue_list GET /api/issues
15
- * - issue_get GET /api/issues/:id | /api/issues/batch (DX-2727 ids[] batch)
15
+ * - issue_get GET /api/issues/:id | /api/issues/batch (DX-2727 ids[] batch;
16
+ * DX-3426 `fields` is a recursive field TREE, not a flat group list —
17
+ * see `resource_fields` below and `./field-tree.ts`)
18
+ * - resource_fields GET /api/resources/:resource/fields (DX-3426 — what `fields` may
19
+ * name for a resource; install-global, no `board`)
16
20
  * - issue_create POST /api/issues
17
21
  * - issue_edit PATCH /api/issues/:id/edit
18
22
  * - issue_transition POST /api/issues/:id/transition
@@ -106,8 +110,9 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
106
110
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
107
111
  import { z } from "zod";
108
112
  import { DashboardHttpClient } from "./http-client.js";
109
- import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRetireBranch, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, failureCategoryCreate, failureCategoryList, failureCategoryUpdate, dispatchTranscriptSearch, planAddArchitectureSection, planAddCard, planAddNote, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteNote, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, PLAN_EVENT_KINDS, PLAN_EVENT_ORIGINS, PLAN_STATUSES, ISSUE_BATCH_GET_MAX, LIST_PAGE_MAX_LIMIT, PLAN_GET_CARDS_DEFAULT_LIMIT, PLAN_GET_EVENTS_DEFAULT_LIMIT, PLAN_GET_EVENTS_MAX_LIMIT, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateNote, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, qualityGateInstruction, } from "./handlers.js";
113
+ import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRetireBranch, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, failureCategoryCreate, failureCategoryList, failureCategoryUpdate, dispatchTranscriptSearch, planAddArchitectureSection, planAddCard, planAddNote, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteNote, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, PLAN_EVENT_KINDS, PLAN_EVENT_ORIGINS, PLAN_STATUSES, ISSUE_BATCH_GET_MAX, LIST_PAGE_MAX_LIMIT, PLAN_GET_CARDS_DEFAULT_LIMIT, PLAN_GET_EVENTS_DEFAULT_LIMIT, PLAN_GET_EVENTS_MAX_LIMIT, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateNote, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, qualityGateInstruction, resourceFields, } from "./handlers.js";
110
114
  import { PRIORITY_TIER_WORDS } from "./priority.js";
115
+ import { fieldTreeSchema } from "./field-tree.js";
111
116
  function readEnvOrDie(name) {
112
117
  const v = process.env[name];
113
118
  if (typeof v !== "string" || v === "") {
@@ -272,10 +277,11 @@ const EFFORT_VALUES = [
272
277
  // still be CREATED through this MCP.
273
278
  const ISSUE_TYPES = ["Epic", "Bug", "Feature", "Story", "Chore", "Task"];
274
279
  const NON_EPIC_TYPES = ["Bug", "Feature", "Story", "Chore", "Task"];
275
- // DX-935 / DX-937 — field-group taxonomy for the nested read envelope,
276
- // hand-copied from `src/issues/read/field-groups.ts` (LIST_GROUPS / GET_GROUPS
277
- // — this package cannot import server source). Drift surfaces at runtime as a
278
- // server 400, not silently.
280
+ // DX-935 / DX-937 — field-group taxonomy for the LIST read envelope,
281
+ // hand-copied from `src/issues/read/field-groups.ts` (`LIST_GROUPS` — this
282
+ // package cannot import server source). Drift surfaces at runtime as a server
283
+ // 400, not silently. DX-3426 moved `issue_get` to the field tree
284
+ // (`field-tree.ts`); DX-3427 moves the list the same way and deletes this.
279
285
  const LIST_FIELD_GROUPS = [
280
286
  "description",
281
287
  // DX-2735: replaced the flat "solutions" group (hard cut, no alias).
@@ -290,13 +296,6 @@ const LIST_FIELD_GROUPS = [
290
296
  "children",
291
297
  "effort",
292
298
  ];
293
- const GET_FIELD_GROUPS = [
294
- ...LIST_FIELD_GROUPS,
295
- "mirrors",
296
- "code_review_items",
297
- // DX-2835 — every plan this card is on ({id, ref, name}[]), detail only.
298
- "plans",
299
- ];
300
299
  const SORT_ORDERS = ["asc", "desc"];
301
300
  const sortField = z
302
301
  .array(z.object({
@@ -393,33 +392,27 @@ strictTool("issue_list",
393
392
  }, async (args) => jsonResult(await issueList(client, args)));
394
393
  // ---------------- issue_get ----------------
395
394
  strictTool("issue_get",
396
- // DX-2735: trimmed to pay for the problem tools inside the work-profile
397
- // injected-surface budget — same facts, no repeated prose.
398
- "Fetch one card (`id`) or many (`ids`, at most " + ISSUE_BATCH_GET_MAX + " — split larger sets); pass exactly one. Ids are globally unique, so both resolve from any board; the batch form is global and throws with `board`. THE DEFAULT ROW IS MINIMAL — scalars only (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent, content_hash: the concurrency token issue_edit needs for title/description/checklists). `fields` opts into groups, per card in a batch too: description (body + summary), problems (live problems in order, each {id, statement, content_hash, open} with its solutions[] and decisions[] — a card needs a human exactly when open_problem_count > 0), ac (acceptance criteria + checklists), comments (paged via `comments_limit`/`comments_offset` — see their own field descriptions, single-id form only), retro, dependencies (waiting_on/conflict_on/blocked), triage (history + ICE), assignment (dispatch, assigned_agent, lifecycle timestamps), quality_gates (one row per gate {gate, required, status pending|pass|fail, completed_at, message}; a required PRE gate not `pass` pre-empts the work dispatch, and complete refuses while a required POST gate is not `pass`), children (ids + rollups), mirrors (external sync state), code_review_items, plans (every plan this card is on, `{id, ref, name}[]` via `plan_cards` — `ref` is the plan's `PLN-<id>`). Within a `comments` page, comments are ordered CHRONOLOGICALLY (oldest first) — only the requested WINDOW is anchored at the newest end, not the array itself; naming `comments_limit`/`comments_offset` alongside `ids` (the batch form) is refused. Single form: unknown id → 404. Batch form: `{issues: [...], not_found: [...ids]}` — an unknown or deleted id never fails the call.", {
395
+ // DX-3426: field GROUPS replaced by a recursive field TREE (see
396
+ // `./field-tree.ts`) — comment paging now lives INSIDE the tree (a
397
+ // cursor-paged relation, today only `comments`, takes `limit`/`before` in
398
+ // its own nested object) instead of top-level `comments_limit`/
399
+ // `comments_offset`, which is also why the old "ids + comments_* refused"
400
+ // rule is gone: paging is per-relation now, so it works on the batch form
401
+ // too. Kept compact — call `resource_fields({resource:"issue"})` for the
402
+ // full, current list of what `fields` may name, rather than enumerating it
403
+ // here (it would drift the moment a field/relation is added or removed).
404
+ "Fetch one card (`id`) or many (`ids`, at most " + ISSUE_BATCH_GET_MAX + " — split larger sets); pass exactly one. Ids are globally unique, so both resolve from any board; the batch form is global and throws with `board`. THE DEFAULT ROW IS THE ALWAYS-ON FIELDS ONLY — identity, lifecycle stamps, derived status/list/blocked, assigned_agent, and content_hash (the concurrency token issue_edit needs for title/description/checklists); every child hash is likewise always returned. `fields` opts into more, per card in a batch too: a JSON object tree — each key names a field or relation, each value is `true` (that field, or a relation's child with its default fields), a nested object (a relation's child with named fields), or on the cursor-paged `comments` relation the nested object may also carry `limit`/`before` (comment id) to page — e.g. `{\"description\": true, \"problems\": {\"solutions\": {\"steps\": true}}, \"comments\": {\"limit\": 10}}`; a paged response also carries `comments_page: {limit, total, next_cursor}` (`next_cursor` → pass back as `before` for the next older page, null = none). Call `resource_fields({resource:\"issue\"})` for exactly what may be requested (every field/relation name, each relation's own child resource, and its paging shape if any) — never guess a name. Unknown field/relation → 400 `unknown_field`; bad shape → 400 `invalid_fields`; bad `limit`/`before` → 400 `invalid_field_argument`; a field you lack permission for → 403 `forbidden_field`. Single form: unknown id → 404. Batch form: `{issues: [...], not_found: [...ids]}` — an unknown or deleted id never fails the call.", {
399
405
  id: z.string().min(1).optional(),
400
406
  ids: z.array(z.string().min(1)).min(1).max(ISSUE_BATCH_GET_MAX).optional(),
401
- fields: z
402
- .array(z.enum(GET_FIELD_GROUPS))
403
- .optional()
404
- .describe("Field groups to add; absent = minimal scalars."),
405
- comments_limit: z
406
- .number()
407
- .int()
408
- .positive()
407
+ fields: fieldTreeSchema
409
408
  .optional()
410
- .describe("Single-id form only. How many comments the page holds (server default 20), the window anchored at the " +
411
- "NEWEST comment. An out-of-range value (server max 200) is refused by the server with its own 400, " +
412
- "never silently clamped here. Requires `fields:[\"comments\"]`."),
413
- comments_offset: z
414
- .number()
415
- .int()
416
- .nonnegative()
417
- .optional()
418
- .describe("Single-id form only. Skip this many of the newest comments before the page starts (default 0) — page " +
419
- "forward while comments_offset + comments.length < comments_total to reach older comments. Requires " +
420
- "`fields` to include `comments`."),
409
+ .describe("A field tree (see tool description); absent/empty = minimal scalars. `resource_fields({resource:\"issue\"})` names every valid key."),
421
410
  ...boardField,
422
411
  }, async (args) => jsonResult(await issueGet(client, args)));
412
+ // ---------------- resource_fields ----------------
413
+ strictTool("resource_fields", "DX-3426: what a field TREE (`issue_get`'s `fields`, and any later tool that takes one) may name for one resource — `{resource, description, always: string[], hashes: string[], fields: [{name, description}], relations: [{name, description, resource, paging?: {default_limit, max_limit, page_field}}]}`. `always`/`hashes` ride every response regardless of `fields` (never request them). A relation's `resource` names what to call this same tool with next, to go one level deeper. Install-global, not board-scoped. `resource: \"issue\"` is the root; unknown resource → 404 `unknown_resource`.", {
414
+ resource: z.string().min(1).describe('Resource name, e.g. "issue", or a relation\'s own `resource` from a prior call.'),
415
+ }, async (args) => jsonResult(await resourceFields(client, args)));
423
416
  // ---------------- issue_create ----------------
424
417
  strictTool("issue_create", '`plan` is REQUIRED on every create — no default, no inference; see its own field description for the two values and the refusal. This replaces the plan_add_card follow-up at creation time; plan_add_card remains for putting an EXISTING card on a plan. ' +
425
418
  'Create a card. Board-scoped; see `board`. type=Epic REQUIRES non-empty phase_children[] (epic and phases inserted in one transaction; children get the epic as parent); other types refuse phase_children[] (400). Status starts at Review. `list_id` places the card straight into a column — a board_lists id or the list\'s display NAME (case-insensitive, emoji-tolerant): a `ready`-type queue lands it in ToDo, a `completed` list in Done, with no follow-up transition. Not valid on Epic; unknown name/id → 400. `quality_gates` adds gates beyond the board\'s type defaults (see its own field description); add one later with `issue_quality_gate`. ALWAYS pass `triage_enabled` explicitly on the root card and every phase child (see its own field description).', {
@@ -657,11 +650,39 @@ const STEP_INPUT = z.lazy(() => z
657
650
  steps: z.array(STEP_INPUT).optional().describe("this step's own children, nested — refused past 3 levels total"),
658
651
  })
659
652
  .strict());
660
- strictTool("issue_problem", "A card's PROBLEMS: one statement the operator must resolve (a question, or a flaw in the plan) OR an action only a person can carry out, each with its own solutions/answers — one problem per question. OPEN = not yet answered; the card needs a human exactly while open_problem_count > 0 — adding a problem IS putting the card in front of a human, there is no separate flag. list → live problems in order, each {id, statement, context, type, summary, content_hash, open, solutions[], decisions[]}; add {statement, context?, type?, summary?, solutions?} → problem_id + solution_ids in one transaction (zero solutions is valid: the operator answers free-form) plus a `reminders: [{key, text}]` array (DX-3365 — every MCP-response reminder rides this ONE field now, DB-registry-driven and operator-overridable from the dashboard) naming each still-open problem's solution count; edit :pid {base_hash, statement, context?, summary?}; remove :pid {base_hash} — always allowed, even as the card's last open problem (removing it just means the card no longer needs a human). A stale base_hash → 409 `stale_problem` with currentHash + currentProblem: merge, then retry. No answer action — the operator answers in the dashboard. `statement` is capped at 200 characters (400 names the actual length otherwise): one plain sentence, with any investigation detail in `context` (markdown, no cap) instead. Read `type`'s and `summary`'s own descriptions below before your first `add` — together they teach which type this is and the three-field split (`statement`/`summary`/`context`) an action needs.", {
653
+ strictTool("issue_problem", "A card's PROBLEMS: one statement the operator must resolve (a question, or a flaw in the plan) OR an action only a person can carry out, each with its own solutions/answers — one problem per question. OPEN = not yet answered; the card needs a human exactly while open_problem_count > 0 — adding a problem IS putting the card in front of a human, there is no separate flag. list → live problems in order, each {id, statement, context, type, summary, content_hash, open, solutions[], decisions[]} (each decision carries `relayed_by_session`, DX-3471 — the working session that recorded it, or null for one entered directly in the dashboard); add {statement, context?, type?, summary?, solutions?} → problem_id + solution_ids in one transaction (zero solutions is valid: the operator answers free-form) plus a `reminders: [{key, text}]` array (DX-3365 — every MCP-response reminder rides this ONE field now, DB-registry-driven and operator-overridable from the dashboard) naming each still-open problem's solution count; edit :pid {base_hash, statement, context?, summary?}; remove :pid {base_hash} — always allowed, even as the card's last open problem (removing it just means the card no longer needs a human). A stale base_hash → 409 `stale_problem` with currentHash + currentProblem: merge, then retry. " +
654
+ "DX-3471 — answer :pid {solution_id, note?} | {freeform} records the operator's decision on a LIVE problem (exactly one of solution_id/freeform; note only qualifies a chosen solution); change_answer :pid {base_hash, solution_id, note?} | {base_hash, freeform} retracts the current decision and records a new one in ONE server transaction; unanswer :pid {base_hash} retracts with no new answer, reopening the problem. `base_hash` on these two names the live DECISION (from that problem's `decisions[].content_hash` in `list`), NOT the problem's own hash — a stale one → 409 `stale_decision` with currentHash + currentDecision: merge, then retry. " +
655
+ "**These three are OPERATOR-SESSION ONLY** — the server refuses a dispatched agent's machine credential 403 (DX-2830: an agent that could answer its own question could release the very human gate it set to stop and wait for); call them only when relaying a decision the OPERATOR actually made in this chat, never a decision you are making yourself. Session attribution is automatic (the same `x-danx-session-id` every call already carries, DX-2683) — no session parameter to pass here. " +
656
+ "`statement` is capped at 200 characters (400 names the actual length otherwise): one plain sentence, with any investigation detail in `context` (markdown, no cap) instead. Read `type`'s and `summary`'s own descriptions below before your first `add` — together they teach which type this is and the three-field split (`statement`/`summary`/`context`) an action needs.", {
661
657
  id: z.string().min(1),
662
- action: z.enum(["list", "add", "edit", "remove"]),
663
- problem_id: z.number().int().positive().optional().describe("edit/remove"),
664
- base_hash: z.string().min(1).optional().describe("content_hash last read; edit/remove"),
658
+ action: z.enum(["list", "add", "edit", "remove", "answer", "change_answer", "unanswer"]),
659
+ problem_id: z.number().int().positive().optional().describe("edit/remove/answer/change_answer/unanswer"),
660
+ base_hash: z
661
+ .string()
662
+ .min(1)
663
+ .optional()
664
+ .describe("edit/remove: content_hash last read for the PROBLEM. change_answer/unanswer: content_hash last read for " +
665
+ "the live DECISION instead (that problem's `decisions[].content_hash` from `list`) — the two are never " +
666
+ "interchangeable."),
667
+ solution_id: z
668
+ .number()
669
+ .int()
670
+ .positive()
671
+ .optional()
672
+ .describe("answer/change_answer only. The chosen option's id, from this problem's own solutions[]. Exactly one of " +
673
+ "solution_id / freeform — never both, never neither (refused before any request)."),
674
+ note: z
675
+ .string()
676
+ .min(1)
677
+ .optional()
678
+ .describe("answer/change_answer only, solution_id answers only: an optional qualification (\"this, but…\"). Refused " +
679
+ "alongside freeform, which is already the operator's own words."),
680
+ freeform: z
681
+ .string()
682
+ .min(1)
683
+ .optional()
684
+ .describe("answer/change_answer only. The operator's own words, when no listed solution fits. Exactly one of " +
685
+ "solution_id / freeform — never both, never neither."),
665
686
  statement: z
666
687
  .string()
667
688
  .min(1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.148",
3
+ "version": "0.1.150",
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",