@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 +4 -3
- package/dist/entrypoint.test.js +43 -0
- package/dist/field-tree.js +35 -0
- package/dist/handlers.js +118 -18
- package/dist/index.js +60 -39
- package/package.json +1 -1
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
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
*
|
|
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
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
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
|
|
276
|
-
// hand-copied from `src/issues/read/field-groups.ts` (LIST_GROUPS
|
|
277
|
-
//
|
|
278
|
-
//
|
|
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-
|
|
397
|
-
//
|
|
398
|
-
|
|
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:
|
|
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("
|
|
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.
|
|
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
|
|
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.
|
|
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",
|