@thehammer/danx-dashboard-mcp 0.1.189 → 0.1.192

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
@@ -9,7 +9,7 @@ Stdio MCP server fronting danxbot's dashboard HTTP API. Since DX-3465 it exposes
9
9
  | Var | Purpose |
10
10
  |---|---|
11
11
  | `DANXBOT_DASHBOARD_URL` | Dashboard base, e.g. `http://danxbot-dashboard:5555` (inside compose) or `http://localhost:5555` (host) |
12
- | `DANXBOT_DASHBOARD_TOKEN_FILE` **or** `DANXBOT_DISPATCH_TOKEN` | The credential, declared exactly one of two ways (DX-2862, `src/credential.ts`). A FILE holding the bearer (a leading `~` is the user's home) — how an interactive operator session declares one, since its `.mcp.json` is git-tracked and can never carry the token itself. Or the bearer VERBATIM — how a dispatch declares one, substituted from its overlay. A declared file is AUTHORITATIVE and its failure is FATAL: an inherited `DANXBOT_DISPATCH_TOKEN` is never consulted instead, because on an operator machine that value routinely names a different (local-dev) dashboard, which answers healthily and empty. The same resolver serves the `bridge` subcommand below, so a session's tools and its event stream can never sign in as two different identities |
12
+ | `DANXBOT_DASHBOARD_TOKEN_FILE` **or** `DANX_DASHBOARD_CREDENTIAL` | The credential, declared exactly one of two ways (DX-2862, `src/credential.ts`). A FILE holding the bearer (a leading `~` is the user's home) — how an interactive operator session declares one, since its `.mcp.json` is git-tracked and can never carry the token itself. Or the bearer VERBATIM — how a dispatch declares one, substituted from its overlay. A declared file is AUTHORITATIVE and its failure is FATAL: an inherited `DANX_DASHBOARD_CREDENTIAL` is never consulted instead, because on an operator machine that value routinely names a different (local-dev) dashboard, which answers healthily and empty. The same resolver serves the `bridge` subcommand below, so a session's tools and its event stream can never sign in as two different identities |
13
13
  | `DANX_REPO_NAME` | Repo half of the qualified board id |
14
14
  | `DANXBOT_BOARD_NAME` | Board-slug half of the qualified board id (the dispatch injects `board.slug` here) |
15
15
 
@@ -39,7 +39,7 @@ A program runs this, not an agent: the danxbot Claude Code plugin's plan event b
39
39
  CLAUDE_CODE_SESSION_ID=<session> npx -y @thehammer/danx-dashboard-mcp@<version> bridge [--resume-ids <id>,<id>,...]
40
40
  ```
41
41
 
42
- - **Credential — the session's own, never the ambient one (DX-2862).** The session id is the only thing `bridge` takes from its environment; `--resume-ids` is the only argument and holds no secret. Everything else comes from the connection record this session's own danx-dashboard MCP server wrote on `plan_connect` (`src/session-connection.ts`): the dashboard URL, the credential's SOURCE, and its FINGERPRINT. `bridge` resolves that source through the same resolver the server used and refuses to run unless the result fingerprints identically — `credential_mismatch`. Before that fix it used whatever `DANXBOT_DISPATCH_TOKEN` the session's environment held, and on a machine where that differed from the server's credential the dashboard admitted the stream and dropped every event, with nothing logged anywhere.
42
+ - **Credential — the session's own, never the ambient one (DX-2862).** The session id is the only thing `bridge` takes from its environment; `--resume-ids` is the only argument and holds no secret. Everything else comes from the connection record this session's own danx-dashboard MCP server wrote on `plan_connect` (`src/session-connection.ts`): the dashboard URL, the credential's SOURCE, and its FINGERPRINT. `bridge` resolves that source through the same resolver the server used and refuses to run unless the result fingerprints identically — `credential_mismatch`. Before that fix it used whatever `DANX_DASHBOARD_CREDENTIAL` the session's environment held, and on a machine where that differed from the server's credential the dashboard admitted the stream and dropped every event, with nothing logged anywhere.
43
43
  - **No client-side reach check (DX-2920).** `bridge` used to prove board reach itself, before ever opening the stream (`GET /api/plans/mine?fields=cards` + a per-board `GET /api/issues/:id/problems`). That check is DELETED. The dashboard briefly enforced the identical requirement server-side (DX-2863: refusing a mint or stream admission with `403 issuer_cannot_read_plan_boards`, and ending a live stream `{"reason":"scope_narrowed","boards":[…]}`), but DX-3468 removed that mechanism too — DX-3409 made every credential team-bound, so a plan session's issuer and every board its connected plan covers are always in the same team, and the check could never fire any more. `bridge` no longer independently verifies board reach at all, client- or server-side. Once a ticket is minted, `bridge` emits, once, `{"type":"ready"}` — no `boards` field, since reach is no longer proven client-side.
44
44
  - **Ticket.** `bridge` mints the session's listener ticket (`POST /api/plan-sessions/me/stream-ticket`, 10 s timeout) and keeps it in the process. A ticket authorizes reading that one session's event stream and nothing else, and is only issued while the session is connected to a plan. Minting a new one ends the previous listener.
45
45
  - **Output — JSON Lines.** One `{"type":"event","id":<id|null>,"text":"…"}` per event on the connected plan's cards, where `text` is `[DX-8 "Title" repo:board] newms87 answered "Which rollout order?": chose "Pause E2E" — note: "only this week"`, `… answered "…": "<free-form answer>"`, `… commented: "…"`, `… opened a problem: "…"`, `… blocked the card: "…"`, or `… unblocked the card`. An event it cannot read still produces one, with a `could not read event` text. Nothing for keep-alives, reconnects or re-mints. The session's own writes are never echoed back to it.
package/dist/bridge.js CHANGED
@@ -34,7 +34,7 @@
34
34
  * 5. Streams through `runListener` (`listen.ts`), which reconnects within the
35
35
  * ticket, and re-mints when that ticket's lease runs out or a fresh ticket
36
36
  * is refused once.
37
- * 6. Writes JSON Lines to stdout: ONE `{type:"ready", boards, cardCount, degraded}`
37
+ * 6. Writes JSON Lines to stdout: ONE `{type:"ready", boards, cardCount, degraded, inventoryError}`
38
38
  * once a ticket has been minted and streaming is about to start —
39
39
  * DX-2970's `fetchPlanInventory`, a single BEST-EFFORT (never a gate)
40
40
  * read of the connected plan's real board/card counts, purely so a
@@ -140,6 +140,13 @@ function formatDigest(entries) {
140
140
  return [header, ...entries.map((e) => e.text)].join("\n");
141
141
  }
142
142
  export const BRIDGE_SUBCOMMAND = "bridge";
143
+ /**
144
+ * DX-3912 — the longest heartbeat `reason` the bridge sends: one under the dashboard's own
145
+ * limit (`HEARTBEAT_REASON_MAX_CHARS` = 200, `src/issues/db/plan-session-heartbeat.ts`),
146
+ * because `oneLine` appends a "…" to a truncated string and a longer reason refuses the whole
147
+ * heartbeat. `bridge.test.ts` asserts the cap.
148
+ */
149
+ const HEARTBEAT_REASON_MAX_CODE_POINTS = 199;
143
150
  export const STREAM_TICKET_PATH = "/api/plan-sessions/me/stream-ticket";
144
151
  // DX-2970 — the bare (no `fields=`) cheap-scalars read, used ONLY to report the
145
152
  // connected plan's real `boards`/`cardCount` on the `ready` record (see
@@ -276,7 +283,7 @@ export class BridgeStartError extends Error {
276
283
  /**
277
284
  * The session's connection, as its own MCP server recorded it — and the proof
278
285
  * that this process holds the SAME credential that server does. Never falls back
279
- * to an ambient `DANXBOT_DISPATCH_TOKEN`: that fallback is precisely DX-2862.
286
+ * to an ambient env credential: that fallback is precisely DX-2862.
280
287
  */
281
288
  const INSTANCE_ID_ENV = "DANX_BRIDGE_INSTANCE_ID";
282
289
  /**
@@ -449,15 +456,29 @@ export async function mintTicket(options, deps) {
449
456
  leaseMs: ticket.leaseMs,
450
457
  };
451
458
  }
452
- /** What is wrong with a 2xx bare `/api/plans/mine` body, or `null` when it carries a usable inventory. */
459
+ /**
460
+ * What is wrong with a 2xx bare `/api/plans/mine` body, or `null` when it carries a usable inventory.
461
+ *
462
+ * DX-3912 — the field names here are the plan resource's WIRE names (`boards`, `card_count`,
463
+ * snake_case since DX-3428), the same ones `src/resources/plan/plan-resource.ts` emits; the
464
+ * contract test `src/issues/__tests__/plan-inventory-bridge-contract.test.ts` runs this parser
465
+ * on the server's own response, so a rename there fails that test instead of silently
466
+ * degrading every bridge. A missing field lists the keys the body DID carry, so a rename is
467
+ * named in the first message anyone reads.
468
+ */
453
469
  function badInventoryReason(body) {
454
- if (typeof body !== "object" || body === null)
470
+ if (!isRecord(body))
455
471
  return "the body is not a JSON object";
456
- const b = body;
457
- if (!Array.isArray(b.boards) || b.boards.some((x) => typeof x !== "string"))
458
- return "boards is not a string array";
459
- if (typeof b.cardCount !== "number" || !Number.isSafeInteger(b.cardCount) || b.cardCount < 0) {
460
- return "cardCount is not a non-negative integer";
472
+ if (!("boards" in body))
473
+ return `boards is missing (response keys: ${Object.keys(body).join(", ")})`;
474
+ if (!Array.isArray(body.boards) || body.boards.some((x) => typeof x !== "string")) {
475
+ return "boards is not an array of strings";
476
+ }
477
+ if (!("card_count" in body))
478
+ return `card_count is missing (response keys: ${Object.keys(body).join(", ")})`;
479
+ const count = body.card_count;
480
+ if (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0) {
481
+ return `card_count is not a non-negative integer (got ${typeof count === "number" ? count : typeof count})`;
461
482
  }
462
483
  return null;
463
484
  }
@@ -485,7 +506,7 @@ export async function fetchPlanInventory(options, deps) {
485
506
  return { kind: "unavailable", detail: `plan inventory read HTTP ${status}: ${bad}` };
486
507
  }
487
508
  const b = body;
488
- return { kind: "ok", boards: b.boards, cardCount: b.cardCount };
509
+ return { kind: "ok", boards: b.boards, cardCount: b.card_count };
489
510
  }
490
511
  /**
491
512
  * DX-2920 — a stream admission refusal (`listen.ts`'s STRUCTURED `refused`
@@ -604,7 +625,8 @@ export async function runBridge(options, deps) {
604
625
  let connectionState = "reconnecting";
605
626
  let stoppedReason = null;
606
627
  let heartbeatSuppressed = false;
607
- let lastInventoryOk = true;
628
+ // DX-3912 — `null` is "the inventory read succeeded"; a string is why it did not.
629
+ let lastInventoryError = null;
608
630
  // DX-3099 — consecutive heartbeat POSTs that failed to LAND while this
609
631
  // process still believed `connectionState === "streaming"`. Reset to 0 the
610
632
  // moment a heartbeat lands, or the moment state leaves "streaming" for any
@@ -619,7 +641,9 @@ export async function runBridge(options, deps) {
619
641
  // process, so an in-process re-mint never touches its state.
620
642
  const sessionStats = new OperatorSessionStats(options.transcriptPath ?? null, deps.makeStatsWatcher);
621
643
  const connectionNow = () => connectionState === "streaming"
622
- ? { state: "streaming", inventory: lastInventoryOk ? "ok" : "unavailable" }
644
+ ? lastInventoryError === null
645
+ ? { state: "streaming", inventory: "ok" }
646
+ : { state: "streaming", inventory: "unavailable", reason: oneLine(lastInventoryError, HEARTBEAT_REASON_MAX_CODE_POINTS) }
623
647
  : connectionState === "stopped"
624
648
  ? { state: "stopped", reason: stoppedReason ?? undefined }
625
649
  : { state: "reconnecting" };
@@ -822,23 +846,24 @@ export async function runBridge(options, deps) {
822
846
  readyEmitted = true;
823
847
  const inventory = await fetchPlanInventory(options, deps);
824
848
  // DX-3028 (AC1, code-review + architecture-review finding) — this is
825
- // the ONE place `lastInventoryOk` is ever written; the 15s heartbeat
849
+ // the ONE place `lastInventoryError` is ever written; the 15s heartbeat
826
850
  // reads it on every "streaming" tick (see `heartbeatTick` above). Must
827
851
  // agree with the SAME `ready` record's own `degraded` decision below —
828
852
  // a heartbeat reporting `inventory:"ok"` while the `ready` record just
829
853
  // told the operator `degraded:true` would be exactly the untruthful
830
854
  // state AC1 exists to prevent.
831
- lastInventoryOk = inventory.kind === "ok";
855
+ lastInventoryError = inventory.kind === "ok" ? null : inventory.detail;
832
856
  if (inventory.kind === "ok") {
833
857
  deps.write({
834
858
  type: "ready",
835
859
  boards: inventory.boards,
836
860
  cardCount: inventory.cardCount,
837
861
  degraded: inventory.boards.length === 0 || inventory.cardCount === 0,
862
+ inventoryError: null,
838
863
  });
839
864
  }
840
865
  else {
841
- deps.write({ type: "ready", boards: null, cardCount: null, degraded: true });
866
+ deps.write({ type: "ready", boards: null, cardCount: null, degraded: true, inventoryError: inventory.detail });
842
867
  }
843
868
  }
844
869
  backoff = MINT_INITIAL_BACKOFF_MS;
@@ -6,7 +6,7 @@
6
6
  * and `danx-dashboard-mcp bridge` (the session's event stream, run by the
7
7
  * danxbot plugin's plan event bridge). Before this module they resolved their
8
8
  * credential independently — the server through danxbot's operator launcher,
9
- * which read a token file, and the bridge from whatever `DANXBOT_DISPATCH_TOKEN`
9
+ * which read a token file, and the bridge from whatever credential variable
10
10
  * happened to be in the session's environment. On a machine where those two
11
11
  * differ (DX-2862: `ffe9f8a882f0…` in the environment, `6f9bc6e94b83…` in
12
12
  * `~/.config/danxbot/dashboard-token-gpt`) the session's tools worked, the
@@ -21,15 +21,15 @@
21
21
  * home). The file's trimmed contents are the credential. This is how an
22
22
  * interactive operator session declares one, because its `.mcp.json` is
23
23
  * git-tracked and can never carry the bearer itself.
24
- * - `DANXBOT_DISPATCH_TOKEN` — the credential verbatim. This is how a
24
+ * - `DANX_DASHBOARD_CREDENTIAL` — the credential verbatim. This is how a
25
25
  * DISPATCH declares one: the worker substitutes it into the materialized
26
26
  * `.mcp.json` from the dispatch overlay.
27
27
  *
28
28
  * A DECLARED FILE IS AUTHORITATIVE, AND ITS FAILURE IS FATAL. When the file
29
- * variable is set, `DANXBOT_DISPATCH_TOKEN` is never consulted — not even when
29
+ * variable is set, `DANX_DASHBOARD_CREDENTIAL` is never consulted — not even when
30
30
  * the file is missing or empty. That is the DX-2483 rule, moved here from the
31
31
  * launcher it used to live in: an operator machine routinely carries an
32
- * inherited `DANXBOT_DISPATCH_TOKEN` naming the LOCAL dev dashboard, which
32
+ * inherited env credential naming the LOCAL dev dashboard, which
33
33
  * answers healthily and empty for a board it does not carry, so preferring it
34
34
  * after a failed file read would turn a loud, fixable error into exactly the
35
35
  * silent wrong-dashboard answer this whole module exists to prevent.
@@ -39,7 +39,7 @@ import { readFileSync } from "node:fs";
39
39
  import { homedir } from "node:os";
40
40
  import { isAbsolute, join, resolve } from "node:path";
41
41
  export const CREDENTIAL_FILE_ENV = "DANXBOT_DASHBOARD_TOKEN_FILE";
42
- export const CREDENTIAL_TOKEN_ENV = "DANXBOT_DISPATCH_TOKEN";
42
+ export const CREDENTIAL_TOKEN_ENV = "DANX_DASHBOARD_CREDENTIAL";
43
43
  /**
44
44
  * A credential that cannot be resolved. Carries the remedy beside the reason,
45
45
  * because both of this module's callers report failures to a human who is not
package/dist/handlers.js CHANGED
@@ -81,7 +81,7 @@ export function isDanxbotApiMethod(value) {
81
81
  *
82
82
  * The credential lives inside `client` (resolved once at boot — the
83
83
  * operator's declared token file for a session, the per-dispatch
84
- * `DANXBOT_DISPATCH_TOKEN` for a worker) and never appears in this
84
+ * `DANX_DASHBOARD_CREDENTIAL` for a worker) and never appears in this
85
85
  * function's arguments or return value — nothing in `DanxbotApiArgs` can
86
86
  * carry one. The session id (`x-danx-session-id`) rides every call the SAME
87
87
  * way it already does for every other tool on this server
package/dist/index.js CHANGED
@@ -55,8 +55,8 @@
55
55
  * session, whose `.mcp.json` is git-tracked and so
56
56
  * can never carry one); authoritative when set, and
57
57
  * its failure is fatal — an inherited
58
- * DANXBOT_DISPATCH_TOKEN is never consulted instead
59
- * DANXBOT_DISPATCH_TOKEN the bearer verbatim (a dispatch, substituted into
58
+ * DANX_DASHBOARD_CREDENTIAL is never consulted instead
59
+ * DANX_DASHBOARD_CREDENTIAL the bearer verbatim (a dispatch, substituted into
60
60
  * the materialized `.mcp.json` from its overlay)
61
61
  * DANX_REPO_NAME repo half of the qualified board id
62
62
  * DANXBOT_BOARD_NAME board-slug half of the qualified board id
@@ -466,7 +466,7 @@ async function main() {
466
466
  // the dashboard URL, credential source, and credential itself come from the
467
467
  // session-connection record this server's own `plan_connect` wrote
468
468
  // (`session-connection.ts`), never from the bridge's own environment
469
- // (DX-2862: a bridge trusting its own ambient `DANXBOT_DISPATCH_TOKEN` is the
469
+ // (DX-2862: a bridge trusting its own ambient env credential is the
470
470
  // exact bug this repo fixed).
471
471
  //
472
472
  // Any OTHER argument is refused rather than ignored, so a typo cannot
@@ -6,7 +6,7 @@
6
6
  * in the SESSION's environment — not in the MCP server's, which is the only
7
7
  * place the server's `.mcp.json` env block exists. So the bridge cannot see
8
8
  * what the server was configured with, and before this it simply used its own
9
- * ambient `DANXBOT_DISPATCH_TOKEN`. When that was a different credential from
9
+ * ambient env credential. When that was a different credential from
10
10
  * the server's, every event was dropped silently (the card's incident).
11
11
  *
12
12
  * WHAT IS WRITTEN, AND WHAT IS NOT. On a successful `plan_connect` the server
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.189",
3
+ "version": "0.1.192",
4
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",