@thehammer/danx-dashboard-mcp 0.1.121 → 0.1.125

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/dist/handlers.js CHANGED
@@ -413,6 +413,33 @@ function argCheckers(tool, mode) {
413
413
  },
414
414
  };
415
415
  }
416
+ /**
417
+ * Every action-dispatched tool's args carry these three regardless of
418
+ * `action` — never action-specific, so every per-action allow-list passed to
419
+ * `refuseInapplicableFields` is unioned with this rather than repeating it.
420
+ */
421
+ const STRUCTURAL_ACTION_FIELDS = ["id", "action", "board"];
422
+ /**
423
+ * DX-3310 (code-review nit) — a field the caller sent that means nothing for
424
+ * the chosen `action` used to be silently ignored: `issue_solution`'s `add`
425
+ * built its `content` object from `title`/`body`/`pro`/`con`/`recommended`
426
+ * regardless of action, then simply never read it on `remove`/`add_step`/etc,
427
+ * and `issue_problem`'s `remove` never looked at `statement`/`context`/
428
+ * `solutions` at all. Either way the caller got a 200 with the field quietly
429
+ * discarded — no different from a typo. Refused loud instead, mirroring
430
+ * `argCheckers`' own wording: every ACTUALLY-SUPPLIED (`!== undefined`) key
431
+ * outside the action's own allow-list (plus `STRUCTURAL_ACTION_FIELDS`,
432
+ * always allowed) is named in one error, so a caller fixes every offending
433
+ * field in one round trip rather than one at a time.
434
+ */
435
+ function refuseInapplicableFields(tool, mode, args, allowed) {
436
+ const offending = Object.keys(args)
437
+ .filter((key) => args[key] !== undefined && !STRUCTURAL_ACTION_FIELDS.includes(key) && !allowed.has(key))
438
+ .sort();
439
+ if (offending.length > 0) {
440
+ throw new Error(`${tool} ${mode} refuses fields that do not apply to this action: ${offending.join(", ")}`);
441
+ }
442
+ }
416
443
  export async function issueComment(client, args) {
417
444
  const idEnc = encodeURIComponent(args.id);
418
445
  const board = args.board;
@@ -614,10 +641,25 @@ export async function issueChecklist(client, args) {
614
641
  *
615
642
  * No answer action, for the reason `issueSolution` gives.
616
643
  */
644
+ /**
645
+ * DX-3310 (nit) — per-action allow-list for `refuseInapplicableFields`.
646
+ * `id`/`action`/`board` are structural and never listed (every action takes
647
+ * them). `type` is listed under `edit` even though it can never actually be
648
+ * SENT there (see the dedicated throw below) — that keeps the generic
649
+ * refusal from firing first with a less specific message than the one that
650
+ * already names the real reason.
651
+ */
652
+ const PROBLEM_ACTION_FIELDS = {
653
+ list: [],
654
+ add: ["statement", "context", "type", "summary", "solutions"],
655
+ edit: ["problem_id", "base_hash", "statement", "context", "summary", "type"],
656
+ remove: ["problem_id", "base_hash"],
657
+ };
617
658
  export async function issueProblem(client, args) {
618
659
  const idEnc = encodeURIComponent(args.id);
619
660
  const board = args.board;
620
661
  const need = argCheckers("issue_problem", `action=${args.action}`);
662
+ refuseInapplicableFields("issue_problem", `action=${args.action}`, args, new Set(PROBLEM_ACTION_FIELDS[args.action]));
621
663
  switch (args.action) {
622
664
  case "list":
623
665
  return client.request({ method: "GET", path: `/${idEnc}/problems`, board });
@@ -693,11 +735,24 @@ export async function issueProblem(client, args) {
693
735
  * question could release the very stop it set to wait for a human. The operator
694
736
  * answers in the dashboard.
695
737
  */
738
+ const SOLUTION_ACTION_FIELDS = {
739
+ add: ["title", "body", "pro", "con", "recommended", "steps"],
740
+ edit: ["solution_id", "base_hash", "title", "body", "pro", "con", "recommended", "steps", "steps_base_hash"],
741
+ remove: ["solution_id", "base_hash"],
742
+ add_step: ["solution_id", "title", "description", "parent_step_id", "position"],
743
+ edit_step: ["solution_id", "step_id", "base_hash", "title", "description"],
744
+ remove_step: ["solution_id", "step_id", "base_hash"],
745
+ };
696
746
  export async function issueSolution(client, args) {
697
747
  const need = argCheckers("issue_solution", `action=${args.action}`);
698
748
  // DX-2735: checked at runtime too, not only by the schema — a caller that skips
699
749
  // the MCP boundary must never build `/problems/undefined/solutions`.
700
750
  const problemId = need.id(args.problem_id, "problem_id");
751
+ // DX-3310 (nit) — `problem_id` is unioned in here rather than listed in
752
+ // every `SOLUTION_ACTION_FIELDS` entry: unlike `issue_problem` (where it
753
+ // only applies to edit/remove), `issue_solution` requires it for every
754
+ // action (checked above), so it is structural FOR THIS TOOL specifically.
755
+ refuseInapplicableFields("issue_solution", `action=${args.action}`, args, new Set(["problem_id", ...SOLUTION_ACTION_FIELDS[args.action]]));
701
756
  const base = `/${encodeURIComponent(args.id)}/problems/${problemId}/solutions`;
702
757
  const board = args.board;
703
758
  const content = {};
@@ -723,8 +778,14 @@ export async function issueSolution(client, args) {
723
778
  const baseHash = need.string(args.base_hash, "base_hash");
724
779
  // DX-3310 — omitted keeps the stored steps untouched; an explicit
725
780
  // (including empty) array replaces them via the server's diff.
726
- if (args.steps !== undefined)
781
+ if (args.steps !== undefined) {
727
782
  content.steps = args.steps;
783
+ // B2 (code-review fix) — REQUIRED alongside `steps`, checked here
784
+ // (not only by the server) so a caller gets the teaching error at
785
+ // this boundary rather than a round trip for something this client
786
+ // already knows is missing.
787
+ content.steps_base_hash = need.string(args.steps_base_hash, "steps_base_hash");
788
+ }
728
789
  return client.request({
729
790
  method: "PATCH",
730
791
  path: `${base}/${solutionId}`,
package/dist/index.js CHANGED
@@ -626,6 +626,28 @@ const SOLUTION_FIELDS = {
626
626
  con: z.string().optional(),
627
627
  recommended: z.boolean().optional(),
628
628
  };
629
+ const STEP_INPUT = z.lazy(() => z
630
+ .object({
631
+ id: z
632
+ .number()
633
+ .int()
634
+ .positive()
635
+ .optional()
636
+ .describe("present -> this node IS an existing step (matched on this id, 400 if it does not resolve to a live " +
637
+ "child of this exact parent scope — a supplied id is NEVER claimed by the positional/title fallback " +
638
+ "below); absent -> matched against unclaimed siblings by TITLE (first unclaimed match, in order), else " +
639
+ "created new. An id-less MIDDLE insert recreates every later id-less sibling's row unless you send ids " +
640
+ "for the ones you want preserved — send ids whenever an existing step's identity matters (checked " +
641
+ "progress, a later single-step edit)."),
642
+ title: z
643
+ .string()
644
+ .min(1)
645
+ .describe("one imperative line, the thing to do — labels (\"1.\", \"2a\") are DERIVED on read and must never appear " +
646
+ "in the title itself."),
647
+ description: z.string().nullable().optional().describe("markdown detail, or null/omitted for none"),
648
+ steps: z.array(STEP_INPUT).optional().describe("this step's own children, nested — refused past 3 levels total"),
649
+ })
650
+ .strict());
629
651
  strictTool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pid]: 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 — there is no separate flag to set, adding a problem IS putting the card in front of a human. 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 `problems_reminder: {open_problem_count, instruction}` naming each 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 (a 400 names the actual length otherwise): write it as ONE plain sentence (a question for a question, the deed itself for an action), and put any investigation detail in `context` (markdown, no cap) instead of running it on. Good: statement \"Which cache should we use?\", context \"Redis fits the read-heavy path; see benchmark in #123. Memcached is simpler ops but no persistence.\" Bad: statement \"We looked at Redis vs Memcached, ran benchmarks showing Redis 3x faster on reads, but Memcached has simpler ops and we're not sure persistence matters here since the cache is fully rebuildable from Postgres...\" (too long, refused — move it to context). Every problem is also a `type`, with a `summary` distinct from `context`: read BOTH fields' own descriptions below before your first `add` — together they teach which type this is, and the three-field split (`statement` / `summary` / `context`) an action actually needs.", {
630
652
  id: z.string().min(1),
631
653
  action: z.enum(["list", "add", "edit", "remove"]),
@@ -659,9 +681,9 @@ strictTool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pi
659
681
  "WHY THE ACTION IS NEEDED AND WHY YOU CANNOT DO IT YOURSELF — that sentence is what tells the operator this " +
660
682
  "is not you being lazy (an add with no summary is refused 400); context is whatever additional detail the " +
661
683
  "person needs to carry it out; solutions[] are the possible ROUTES a person could take (e.g. \"rotate by " +
662
- "hand in the console\" vs \"run the provisioning script\" vs \"ask the vendor\"), each carrying its own " +
663
- "steps to completion once the operator picks one. Omitted defaults to \"question\" — decide deliberately " +
664
- "every time."),
684
+ "hand in the console\" vs \"run the provisioning script\" vs \"ask the vendor\"), and EACH ONE MUST CARRY " +
685
+ "AT LEAST ONE STEP (refused 400 otherwise) — an action with no procedure is not yet actionable. Omitted " +
686
+ "defaults to \"question\" — decide deliberately every time."),
665
687
  summary: z
666
688
  .string()
667
689
  .nullable()
@@ -673,43 +695,43 @@ strictTool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pi
673
695
  "when given (omit/null on a question means none). edit: omit to keep the stored summary, null to clear it " +
674
696
  "(refused if the problem is a live action)."),
675
697
  solutions: z
676
- .array(z.object({ title: z.string().min(1), ...SOLUTION_FIELDS }).strict())
698
+ .array(z.object({ title: z.string().min(1), ...SOLUTION_FIELDS, steps: z.array(STEP_INPUT).optional() }).strict())
677
699
  .optional()
678
- .describe("add only; fields as issue_solution add"),
700
+ .describe("add only; fields as issue_solution add, INCLUDING steps (S6/AC 35332) — an ACTION problem's inline " +
701
+ "solutions must each carry at least one step (refused 400 otherwise); a QUESTION's may carry any number, " +
702
+ "including none."),
679
703
  ...boardField,
680
704
  }, async (args) => jsonResult(await issueProblem(client, args)));
681
- const STEP_INPUT = z.lazy(() => z
682
- .object({
683
- id: z
684
- .number()
685
- .int()
686
- .positive()
687
- .optional()
688
- .describe("present → this node IS an existing step (matched on this id); absent → new, or matched by (parent, ordinal, title)"),
689
- title: z.string().min(1),
690
- description: z.string().nullable().optional().describe("markdown detail, or null/omitted for none"),
691
- steps: z.array(STEP_INPUT).optional().describe("this step's own children, nested — refused past 3 levels total"),
692
- })
693
- .strict());
705
+ // ---------------- issue_solution ----------------
694
706
  strictTool("issue_solution", "One problem's options via /api/issues/:id/problems/:pid/solutions[/:sid], AND (DX-3310) one solution's individual " +
695
707
  "procedure steps via .../solutions/:sid/steps[/:stepId]; `problem_id` is REQUIRED (from issue_problem list/add; " +
696
708
  "another problem's solution id → 404). add {title, body?, pro?, con?, recommended?, steps?}: title names the " +
697
709
  "option/route, body is its markdown detail, pro/con the case for and against, `steps` is its WHOLE procedure " +
698
710
  "authored in one call — an ordered array of {title, description?, steps?}, nestable to 3 levels (a 4th is refused " +
699
- "400 naming the offending step and the limit); edit :sid {base_hash, ...only the changed fields, steps?}: `steps` " +
700
- "omitted leaves the stored procedure untouched, an explicit array (including []) DIFFS against it — send back an " +
701
- "existing step's `id` (read from the card) to keep it (even while retitling/reordering it) and omit `id` for a " +
702
- "genuinely new step; any existing step you don't include gets removed. NEVER resend the whole tree just to fix " +
703
- "one word — see add_step/edit_step/remove_step below. remove :sid {base_hash}. A stale base_hash → 409 " +
704
- "`stale_solution` with currentHash + currentSolution: merge, then retry. At most ONE live recommended per " +
705
- "problem: a second → 409 naming `recommended_solution_id`. A chosen option cannot be edited (409 — add a new " +
706
- "one) but can be removed.\n\n" +
711
+ "400 naming the offending step and the limit). AC 35332 — under an ACTION problem, `steps` must be non-empty " +
712
+ "(refused 400 otherwise); under a QUESTION it may be omitted or empty. Labels (\"1\", \"2a\", \"2a.i\") are " +
713
+ "ALWAYS DERIVED on read from position — never put one in a title yourself. edit :sid {base_hash, ...only the " +
714
+ "changed fields, steps?, steps_base_hash?}: `steps` omitted leaves the stored procedure untouched, an explicit " +
715
+ "array (including [], refused if it would leave an action's solution with zero steps) DIFFS against it — send " +
716
+ "back an existing step's `id` (read from the card) to keep it (even while retitling/reordering it) and omit " +
717
+ "`id` for a genuinely new step; any existing step you don't include gets removed, and an id-less MIDDLE insert " +
718
+ "recreates every later id-less sibling's row unless you send ids for the ones whose identity matters. " +
719
+ "`steps_base_hash` is REQUIRED whenever `steps` is sent — the solution's current `steps_hash` (read it off the " +
720
+ "card first); a stale value → 409 `stale_steps` carrying the current tree, so a step added/removed since your " +
721
+ "read is never silently dropped. NEVER resend the whole tree just to fix one word — see add_step/edit_step/" +
722
+ "remove_step below. remove :sid {base_hash}. A stale base_hash → 409 `stale_solution` with currentHash + " +
723
+ "currentSolution: merge, then retry. At most ONE live recommended per problem: a second → 409 naming " +
724
+ "`recommended_solution_id`. A CHOSEN option's words are frozen (409 — add a new one instead), and so is its " +
725
+ "WHOLE PROCEDURE — every steps route below also refuses once a decision has chosen this solution.\n\n" +
707
726
  "Granular single-step actions — change ONE step without resending the tree: add_step {solution_id, title, " +
708
- "description?, parent_step_id?, position?} — parent_step_id omitted/null = top-level; position is 1-indexed " +
709
- "among the parent's current live children, omitted = append; nesting past depth 3 → 400. edit_step " +
710
- "{solution_id, step_id, base_hash, title?, description?} — never moves a step (no parent_step_id/position here). " +
711
- "remove_step {solution_id, step_id, base_hash} — soft-deletes it AND its own live children. A stale base_hash on " +
712
- "a step → 409 `stale_step` with currentHash + currentStep (carrying its real derived `label`).", {
727
+ "description?, parent_step_id?, position?} — title is ONE imperative line, never a label like \"1.\" or \"2a\" " +
728
+ "(derived on read); parent_step_id omitted/null = top-level; position is 1-indexed among the parent's current " +
729
+ "live children, omitted = append, and a position beyond the current sibling count is refused (400), never " +
730
+ "silently clamped to append; nesting past depth 3 → 400. edit_step {solution_id, step_id, base_hash, title?, " +
731
+ "description?} — never moves a step (no parent_step_id/position here). remove_step {solution_id, step_id, " +
732
+ "base_hash} — soft-deletes it AND its own live children; refused (409) if this solution belongs to an ACTION " +
733
+ "problem and removing it would leave zero live steps. A stale base_hash on a step → 409 `stale_step` with " +
734
+ "currentHash + currentStep (carrying its real derived `label`).", {
713
735
  id: z.string().min(1),
714
736
  action: z.enum(["add", "edit", "remove", "add_step", "edit_step", "remove_step"]),
715
737
  problem_id: z.number().int().positive(),
@@ -718,6 +740,11 @@ strictTool("issue_solution", "One problem's options via /api/issues/:id/problems
718
740
  title: z.string().min(1).optional().describe("add; also add_step/edit_step"),
719
741
  ...SOLUTION_FIELDS,
720
742
  steps: z.array(STEP_INPUT).optional().describe("add/edit only — see this tool's own description"),
743
+ steps_base_hash: z
744
+ .string()
745
+ .min(1)
746
+ .optional()
747
+ .describe("edit only, REQUIRED whenever steps is sent — the solution's current steps_hash; a stale value is refused 409 stale_steps"),
721
748
  description: z.string().nullable().optional().describe("add_step/edit_step; markdown detail, or null/omitted for none"),
722
749
  step_id: z.number().int().positive().optional().describe("edit_step/remove_step"),
723
750
  parent_step_id: z
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.121",
3
+ "version": "0.1.125",
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",