@thehammer/danx-dashboard-mcp 0.1.164 → 0.1.165

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
@@ -62,23 +62,11 @@ export async function planConnect(client, args) {
62
62
  },
63
63
  });
64
64
  }
65
- // ---------------- resource_fields (DX-3426) ----------------
66
- const RESOURCES_BASE_PATH = "/api/resources";
67
- /**
68
- * `GET /api/resources/:resource/fields` — what a field TREE (the `fields`
69
- * arg on `GET /api/issues/:id`, and any later route built on the same shape)
70
- * may name for ONE resource: `{resource, description, always, hashes,
71
- * fields, relations}`. Install-global (no `board` — the failure-category
72
- * list route is board-less the same way: nothing server-side would read one).
73
- * Unknown resource → 404 `unknown_resource`.
74
- */
75
- export async function resourceFields(client, args) {
76
- return client.request({
77
- method: "GET",
78
- path: `/${encodeURIComponent(args.resource)}/fields`,
79
- basePath: RESOURCES_BASE_PATH,
80
- });
81
- }
65
+ // DX-3465 (code-architecture gate finding, at 275f82c0d) — the dedicated
66
+ // `resource_fields` handler (DX-3426) was deleted from here: it was a thin
67
+ // wrapper around `GET /api/resources/:resource/fields`, which `danxbot_api`
68
+ // already reaches with nothing agent-specific in it. See `index.ts`'s own
69
+ // note at the former tool-registration site.
82
70
  // ---------------- danxbot_api (DX-3465, PLN-11 R-25) ----------------
83
71
  export const DANXBOT_API_METHODS = ["GET", "POST", "PATCH", "PUT", "DELETE"];
84
72
  export function isDanxbotApiMethod(value) {
@@ -102,7 +102,16 @@ export class DashboardHttpClient {
102
102
  for (const [k, v] of Object.entries(extraQuery)) {
103
103
  if (v === undefined || v === null)
104
104
  continue;
105
- params.set(k, String(v));
105
+ // DX-3465 (code-quality gate finding, at 275f82c0d) — a field TREE
106
+ // (`fields`, and any other tree-shaped route param) arrives here as a
107
+ // real object/array, not a string; `String(v)` on either produces
108
+ // the useless literal "[object Object]" (objects) or a comma-joined
109
+ // list that drops all nesting (arrays). JSON-encode both so the
110
+ // server's own `fields=` query-string parser (which expects exactly
111
+ // this shape — see `src/dashboard/...` field-tree route handling)
112
+ // receives what it can actually parse; a plain scalar still
113
+ // stringifies the same way it always did.
114
+ params.set(k, typeof v === "object" ? JSON.stringify(v) : String(v));
106
115
  }
107
116
  }
108
117
  return `${base}${basePath}${cleanPath}?${params.toString()}`;
package/dist/index.js CHANGED
@@ -23,19 +23,23 @@
23
23
  * first to find the route for something you need to do.
24
24
  * - danxbot_api_spec discovers the exact request/response shape of any route
25
25
  * `danxbot_api` can call, before calling it.
26
- * - resource_fields GET /api/resources/:resource/fields (DX-3426 — what `fields` may
27
- * name for a resource; install-global, no `board`)
28
26
  * - plan_connect POST /api/plan-sessions/me/plan — SESSION-BOUND (kept as a
29
- * dedicated tool; every other plan_* operation goes through
30
- * danxbot_api now)
27
+ * dedicated tool; every plan READ or WRITE operation goes
28
+ * through danxbot_api now, e.g. GET /api/plans/mine or
29
+ * POST /api/plans/mine/records)
31
30
  *
32
- * DX-2683 — THE PLAN TOOLS ARE SESSION-BOUND, and asymmetrically so. Reads
33
- * may name any plan; WRITES take no plan id at all and act on the plan this
34
- * session is connected to, resolved server-side from the session id this
35
- * package forwards on every request. A session therefore cannot modify a plan
36
- * it is not connected to, whatever id it believes. A write from an
37
- * unconnected session is refused loudly (`session_not_connected`), never
38
- * silently dropped and never guessed at.
31
+ * DX-2683, updated DX-3465 — PLAN WRITES ARE SESSION-BOUND, and
32
+ * asymmetrically so relative to reads. There is no dedicated plan-write
33
+ * tool any more (DX-3465 deleted every `plan_add_*`/`plan_update_*`/
34
+ * `plan_delete_*`/`plan_remove_card`/`plan_rename`/`plan_reorder_*` tool);
35
+ * a plan write is an ordinary `danxbot_api` call to a `/api/plans/mine/...`
36
+ * route. Reads may name any plan by id (`/api/plans/:id/...`); a WRITE
37
+ * route instead takes NO plan id at all and acts on the plan this session
38
+ * is connected to (`plan_connect`), resolved server-side from the session
39
+ * id this package forwards on every request. A session therefore cannot
40
+ * modify a plan it is not connected to, whatever id it believes. A write
41
+ * from an unconnected session is refused loudly (`session_not_connected`),
42
+ * never silently dropped and never guessed at.
39
43
  *
40
44
  * BOARD-ONLY (DX-1171): board is the first-level concept; repo is
41
45
  * DERIVED from board server-side, never passed. The package composes the
@@ -79,7 +83,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
79
83
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
80
84
  import { z } from "zod";
81
85
  import { DashboardHttpClient } from "./http-client.js";
82
- import { planConnect, resourceFields, danxbotApi, danxbotApiList, danxbotApiSpec, attachFile, DANXBOT_API_METHODS, } from "./handlers.js";
86
+ import { planConnect, danxbotApi, danxbotApiList, danxbotApiSpec, attachFile, DANXBOT_API_METHODS, } from "./handlers.js";
83
87
  import { registerLifecycleTools, applyLifecycleVisibility, readLifecycleUrlsFromEnv, } from "./lifecycle.js";
84
88
  function readEnvOrDie(name) {
85
89
  const v = process.env[name];
@@ -311,10 +315,14 @@ const boardField = {
311
315
  .optional()
312
316
  .describe("Board id `<repo>:<slug>`; omit for this dispatch's board. Unknown → 404."),
313
317
  };
314
- // ---------------- resource_fields ----------------
315
- strictTool("resource_fields", "DX-3426: what a field TREE (the `fields` arg on `GET /api/issues/:id` via `danxbot_api`, 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`.", {
316
- resource: z.string().min(1).describe('Resource name, e.g. "issue", or a relation\'s own `resource` from a prior call.'),
317
- }, async (args) => jsonResult(await resourceFields(client, args)));
318
+ // DX-3465 (code-architecture gate finding, at 275f82c0d) — `resource_fields`
319
+ // (DX-3426) was deleted from here: it was a dedicated wrapper around a plain
320
+ // `GET /api/resources/:resource/fields`, which `danxbot_api` already reaches
321
+ // with no agent-specific behavior earning it its own tool (R-25 keeps a
322
+ // dedicated tool only for what's genuinely agent-specific — session binding,
323
+ // local-file reads, dispatch lifecycle). Call
324
+ // `danxbot_api({method:"GET", path:"/api/resources/<resource>/fields"})`
325
+ // instead; the route and its response shape are unchanged.
318
326
  // ---------------- danxbot_api (DX-3465, PLN-11 R-25) ----------------
319
327
  strictTool("danxbot_api", "Call any dashboard API route your credential may reach. `method` + `path` (a FULL path, e.g. " +
320
328
  '"/api/issues/DX-1/transition" — substitute real ids yourself, never a route template with `:id` in it) + ' +
@@ -329,7 +337,24 @@ strictTool("danxbot_api", "Call any dashboard API route your credential may reac
329
337
  .min(1)
330
338
  .startsWith("/api/")
331
339
  .describe('Full path starting with "/api/" — never a route template.'),
332
- query: z.record(z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(),
340
+ // DX-3465 (code-quality gate finding, at 275f82c0d) — a query value may
341
+ // be a field TREE (a plain object or array, e.g.
342
+ // `{"description":true,"comments":{"limit":20}}`), not just a scalar —
343
+ // SERVER_INSTRUCTIONS below says "ALWAYS pass `fields` … a field tree"
344
+ // and the OpenAPI spec agrees (`fields`'s schema is `{type:"object"}`),
345
+ // so the schema here must accept what those two tell the caller to
346
+ // send. `DashboardHttpClient#buildUrl` JSON-encodes an object/array
347
+ // value into the query string; a scalar serializes as before.
348
+ query: z
349
+ .record(z.union([
350
+ z.string(),
351
+ z.number(),
352
+ z.boolean(),
353
+ z.null(),
354
+ z.record(z.unknown()),
355
+ z.array(z.unknown()),
356
+ ]))
357
+ .optional(),
333
358
  body: z.unknown().optional(),
334
359
  ...boardField,
335
360
  }, async (args) => jsonResult(await danxbotApi(client, args)));
package/dist/lifecycle.js CHANGED
@@ -38,7 +38,7 @@
38
38
  */
39
39
  import { z } from "zod";
40
40
  /**
41
- * The six canonical agent-visible `danxbot_complete` statuses. DX-3394
41
+ * The five canonical agent-visible `danxbot_complete` statuses. DX-3394
42
42
  * problem 1682 / Core Principle 1 — the pre-DX-770 deprecated aliases
43
43
  * (`completed`, `agent_blocked`) were already gone; this merge additionally
44
44
  * drops the `ready` / `archive` / `review` backward-compat aliases that
@@ -132,8 +132,15 @@ function parseOptionalAbsolutePathArray(field, value) {
132
132
  */
133
133
  export function registerLifecycleTools(strictTool) {
134
134
  // ---------------- danxbot_complete ----------------
135
- // Always visible — every dispatch, of every kind, ends with this call.
136
- strictTool("danxbot_complete", "Finalize the current dispatch. Releases the worker's per-dispatch scope and ends the dispatch row. " +
135
+ // DX-3465 (code-quality gate finding, at 275f82c0d) — this tool used to be
136
+ // unconditionally visible on the theory that "every dispatch ends with
137
+ // it", but an OPERATOR session is not a dispatch: it has no
138
+ // DANXBOT_STOP_URL, so `danxbot_complete` was always advertised there and
139
+ // every call threw. It is now gated on the stop URL exactly like every
140
+ // other lifecycle tool below (`applyLifecycleVisibility`) — a real
141
+ // dispatch always has the URL, so nothing is lost there; an operator
142
+ // session simply never sees a tool it could never successfully call.
143
+ const danxbotComplete = strictTool("danxbot_complete", "Finalize the current dispatch. Releases the worker's per-dispatch scope and ends the dispatch row. " +
137
144
  "Does NOT move any card — if you're working an issue-bound dispatch and want the card to move " +
138
145
  "(Done / Blocked / Cancelled / etc.), transition the card FIRST through danxbot_api " +
139
146
  "({method:'POST', path:'/api/issues/:id/transition', body:{action:...}}), then call this tool. " +
@@ -409,6 +416,7 @@ export function registerLifecycleTools(strictTool) {
409
416
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
410
417
  });
411
418
  return {
419
+ danxbotComplete,
412
420
  slackReply,
413
421
  slackPostUpdate,
414
422
  linkThreadToIssue,
@@ -424,8 +432,11 @@ export function registerLifecycleTools(strictTool) {
424
432
  /**
425
433
  * Disable every conditionally-visible lifecycle tool whose URL is absent
426
434
  * from `urls` — the same advertise-filter contract `buildActiveTools` used
427
- * to enforce in the old standalone server. `danxbot_complete` is never in
428
- * this map — it is unconditionally visible (every dispatch ends with it).
435
+ * to enforce in the old standalone server. `danxbot_complete` (DX-3465,
436
+ * code-quality gate finding, at 275f82c0d) is gated on `urls.stop` the same
437
+ * way — a real dispatch always carries `DANXBOT_STOP_URL`, so it stays
438
+ * visible there; an operator session (no stop URL) never advertises a tool
439
+ * it could never successfully call.
429
440
  */
430
441
  export function applyLifecycleVisibility(tools, urls) {
431
442
  const setVisible = (tool, visible) => {
@@ -434,6 +445,7 @@ export function applyLifecycleVisibility(tools, urls) {
434
445
  else
435
446
  tool.disable();
436
447
  };
448
+ setVisible(tools.danxbotComplete, !!urls.stop);
437
449
  setVisible(tools.slackReply, !!urls.slackReply);
438
450
  setVisible(tools.slackPostUpdate, !!urls.slackUpdate);
439
451
  setVisible(tools.linkThreadToIssue, !!urls.linkThreadToIssue);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.164",
4
- "description": "Stdio MCP server wrapping danxbot's dashboard /api/issues/* normalized DB-backed HTTP routes for dispatched agents (DX-704 Phase 2).",
3
+ "version": "0.1.165",
4
+ "description": "Stdio MCP server fronting danxbot's dashboard API for agents (operator sessions and dispatched workers alike): the generic danxbot_api/danxbot_api_list/danxbot_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (plan_connect, danxbot_complete, and other per-dispatch lifecycle callbacks) (DX-3465, PLN-11 R-25).",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -1,43 +0,0 @@
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
- });
package/dist/mantra.js DELETED
@@ -1,128 +0,0 @@
1
- /**
2
- * `danx-dashboard-mcp mantra` — the ONE-SHOT client of
3
- * `GET /api/reminders/mantra.session_start` (DX-3366).
4
- *
5
- * WHO RUNS IT. The claude-plugins `danxbot` plugin's `mantra.sh` SessionStart
6
- * hook, via the SAME `npx -y @thehammer/danx-dashboard-mcp@<pin>` path the
7
- * plugin's other dashboard calls already use (`plan-state`, `bridge`) — never
8
- * a separate install, never a deep import of this package's `dist/`
9
- * internals.
10
- *
11
- * THE CREDENTIAL — the SAME resolver `bridge` / `plan-state` use
12
- * (`resolveBridgeOptions`, `bridge.ts`): the dashboard URL, credential source
13
- * and credential all come from the connection record this session's OWN
14
- * danx-dashboard MCP server wrote on its last successful `plan_connect`
15
- * (`session-connection.ts`), never from this process's own ambient env. The
16
- * mantra is only ever fetched once a session is already connected (`mantra.sh`
17
- * checks that first via `plan-connection.mjs` and stays with the short nudge
18
- * otherwise), so a connection record always exists by the time this runs.
19
- *
20
- * THE CONTRACT — unlike `plan-state` (which must stay silent for a hook, so it
21
- * always exits 0), this subcommand's caller (`mantra.sh`) needs to tell
22
- * success from failure so it can fall back to the git-committed `mantra.md` +
23
- * a one-line notice (DX-3366 AC: "Dashboard unreachable → the hook prints the
24
- * committed file and says so in one line; never silently"). So:
25
- *
26
- * - success: the reminder's EFFECTIVE text (`override_text ?? default_text`)
27
- * on stdout, nothing else, exit 0.
28
- * - failure (missing env, no connection record, network error, timeout,
29
- * non-2xx, 404, bad shape): NOTHING on stdout, one short reason on
30
- * stderr, exit 1. `mantra.sh` is the one that decides what to print for
31
- * a human — this module only reports whether the fetch worked.
32
- *
33
- * A HARD TIMEOUT (mirrors `plan-state.ts`) — a SessionStart hook must never
34
- * hang on a wedged dashboard.
35
- */
36
- import { SESSION_ID_HEADER, resolveBridgeOptions } from "./bridge.js";
37
- export const MANTRA_SUBCOMMAND = "mantra";
38
- export const MANTRA_REMINDER_KEY = "mantra.session_start";
39
- export const MANTRA_REQUEST_TIMEOUT_MS = 5_000;
40
- const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${MANTRA_SUBCOMMAND}`;
41
- function reminderPath(key) {
42
- return `/api/reminders/${encodeURIComponent(key)}`;
43
- }
44
- /** One authenticated GET, with a hard timeout — never throws, never bubbles a non-2xx. */
45
- export async function fetchMantraText(options, deps) {
46
- const controller = new AbortController();
47
- const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
48
- let status;
49
- let text;
50
- try {
51
- try {
52
- const response = await deps.fetch(`${options.dashboardUrl}${reminderPath(MANTRA_REMINDER_KEY)}`, {
53
- method: "GET",
54
- headers: {
55
- Authorization: `Bearer ${options.token}`,
56
- Accept: "application/json",
57
- [SESSION_ID_HEADER]: options.sessionId,
58
- },
59
- signal: controller.signal,
60
- });
61
- status = response.status;
62
- text = await response.text();
63
- }
64
- catch {
65
- return { ok: false, reason: controller.signal.aborted ? "timeout" : "request_failed" };
66
- }
67
- }
68
- finally {
69
- clearTimeout(timer);
70
- }
71
- if (status === 401 || status === 403)
72
- return { ok: false, reason: "unauthorized" };
73
- if (status === 404)
74
- return { ok: false, reason: "not_found" };
75
- if (status < 200 || status >= 300)
76
- return { ok: false, reason: "http_error" };
77
- let body;
78
- try {
79
- body = text === "" ? null : JSON.parse(text);
80
- }
81
- catch {
82
- return { ok: false, reason: "bad_response" };
83
- }
84
- const effectiveText = typeof body === "object" && body !== null && typeof body.effective_text === "string"
85
- ? body.effective_text
86
- : null;
87
- if (effectiveText === null)
88
- return { ok: false, reason: "bad_response" };
89
- return { ok: true, text: effectiveText };
90
- }
91
- /**
92
- * The bin's `mantra` subcommand, wired to the real process. Success prints
93
- * the effective text on stdout and returns 0; every failure prints nothing on
94
- * stdout, one reason on stderr, and returns 1 — `mantra.sh` reads the exit
95
- * code, never parses stdout to guess.
96
- */
97
- export async function runMantraCommand(argv, env = process.env,
98
- /** Test seam: the home the session's connection record is read from. */
99
- resolveFrom = {}) {
100
- if (argv.length !== 0) {
101
- process.stderr.write(`unrecognized arguments\n${USAGE}\n`);
102
- return 1;
103
- }
104
- const sessionId = env.CLAUDE_CODE_SESSION_ID;
105
- if (!sessionId) {
106
- process.stderr.write(`no_session_id: CLAUDE_CODE_SESSION_ID is not set\n${USAGE}\n`);
107
- return 1;
108
- }
109
- let options;
110
- try {
111
- options = resolveBridgeOptions({ sessionId, resumeIds: [] }, env, resolveFrom);
112
- }
113
- catch (err) {
114
- const start = err;
115
- process.stderr.write(`${start.reason}: ${start.message}. Fix: ${start.fix}.\n`);
116
- return 1;
117
- }
118
- const output = await fetchMantraText(options, {
119
- fetch: (input, init) => fetch(input, init),
120
- requestTimeoutMs: MANTRA_REQUEST_TIMEOUT_MS,
121
- });
122
- if (!output.ok) {
123
- process.stderr.write(`${output.reason}: could not fetch the effective mantra text from the dashboard\n`);
124
- return 1;
125
- }
126
- process.stdout.write(output.text);
127
- return 0;
128
- }