@thehammer/danx-dashboard-mcp 0.1.252 → 0.1.256

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
@@ -11,8 +11,8 @@ Stdio MCP server fronting danxbot's dashboard HTTP API. Since DX-3465 it exposes
11
11
  | `DANXBOT_DASHBOARD_URL` | Dashboard base, e.g. `http://danxbot-dashboard:5555` (inside compose) or `http://localhost:5555` (host) |
12
12
  | `DANX_DASHBOARD_CREDENTIAL` | A DISPATCH's credential: the bearer verbatim, substituted into the materialized `.mcp.json` from its overlay (DX-2862, `src/credential.ts`). Authoritative when set; it never signs out. An operator session sets nothing: with no credential the server boots SIGNED OUT, every tool except `plan_connect` returns one "not signed in" halt, and `plan_connect` requests access (below). The same resolver serves the `bridge` subcommand, so a session's tools and its event stream can never sign in as two different identities |
13
13
  | `DANXBOT_TEAM` | OPTIONAL, non-secret (DX-4446): the team slug (or id) whose approvers see this session's access request in their danxbot site. Set it in the repo's `.mcp.json` env block or the shell. Unset, the request is answered by link only. A value that is not a team slug fails at boot; a team the dashboard does not know makes `plan_connect` answer `unknown_team` (fix `DANXBOT_TEAM`), never a silent link fallback |
14
- | `DANX_REPO_NAME` | Repo half of the qualified board id |
15
- | `DANXBOT_BOARD_NAME` | Board-slug half of the qualified board id (the dispatch injects `board.slug` here) |
14
+ | `DANX_REPO_NAME` | OPTIONAL (DX-4555): repo half of the qualified board id; sent as the access request's repo label |
15
+ | `DANXBOT_BOARD_NAME` | OPTIONAL (DX-4555; needs `DANX_REPO_NAME`): board-slug half of the qualified board id (the dispatch injects `board.slug` here). With neither variable set (the danxbot plugin's own server) the server has no default board: a board-scoped call must carry its `board` argument or it fails loud |
16
16
 
17
17
  BOARD-ONLY (DX-1171): board is the first-level concept; repo is DERIVED from board server-side, never passed. At boot the package composes the dispatch's qualified board id `<repo>:<slug>` from `DANX_REPO_NAME` + `DANXBOT_BOARD_NAME` and appends it as `?board=<id>` (and `body.board` on create) to every `/api/issues/*` request. Each tool exposes an optional `board` arg — a FULL qualified id `<repo>:<slug>` (e.g. `platform:the-supply-operations-hub`) — that overrides it per-call for cross-board reads/writes (unknown board → 404). The dashboard owns board→repo resolution; this package never sends `repo`.
18
18
 
@@ -111,7 +111,12 @@ export class DashboardHttpClient {
111
111
  const params = new URLSearchParams(existingQs ?? "");
112
112
  // DX-3750 / DX-4303 — only a route the dashboard takes `?board=` on gets the stamp; see `route-board-scope.ts`.
113
113
  if (isBoardScopedRoute(method, `${basePath}${cleanPath}`)) {
114
- params.set("board", boardOverride ?? this.config.board);
114
+ const board = boardOverride ?? this.config.board;
115
+ // DX-4555: a board-less server (the danxbot plugin's, no DANX_REPO_NAME/DANXBOT_BOARD_NAME) has no board to stamp.
116
+ if (board === undefined) {
117
+ throw new Error(`[danx-dashboard-mcp] ${method} ${basePath}${cleanPath} is board-scoped and this server has no configured board: pass the \`board\` argument (a qualified id, <repo>:<slug>)`);
118
+ }
119
+ params.set("board", board);
115
120
  }
116
121
  if (extraQuery) {
117
122
  for (const [k, v] of Object.entries(extraQuery)) {
package/dist/index.js CHANGED
@@ -52,7 +52,7 @@
52
52
  * DANX_REPO_NAME + DANXBOT_BOARD_NAME and sends it as `board` — the
53
53
  * dashboard owns board→repo resolution.
54
54
  *
55
- * Env at boot (validated fail-loud — missing → process.exit(1)):
55
+ * Env at boot (validated fail-loud — a missing required var → process.exit(1)):
56
56
  * DANXBOT_DASHBOARD_URL dashboard base (e.g. http://danxbot-dashboard:5555)
57
57
  * the CREDENTIAL (DX-4390), resolved by the ONE resolver this package's event bridge
58
58
  * also uses (`credential.ts`, DX-2862), and never a reason to exit:
@@ -67,8 +67,8 @@
67
67
  * Unset: the request is answered by link only. Set but not a
68
68
  * valid ref, or unknown to the dashboard: fails loud, never a
69
69
  * silent fall back to the link flow.
70
- * DANX_REPO_NAME repo half of the qualified board id
71
- * DANXBOT_BOARD_NAME board-slug half of the qualified board id
70
+ * DANX_REPO_NAME OPTIONAL (DX-4555): repo half of the qualified board id
71
+ * DANXBOT_BOARD_NAME OPTIONAL (DX-4555; needs DANX_REPO_NAME): board-slug half of the qualified board id
72
72
  * (the dispatch injects `board.slug` here);
73
73
  * composed as `<repo>:<slug>` and appended as
74
74
  * ?board=<id>. Each tool's optional `board` arg
@@ -172,17 +172,25 @@ let permissionWatch;
172
172
  * halves the worker injects and build the HTTP client. DANXBOT_BOARD_NAME
173
173
  * already carries the board SLUG at the spawn site (src/dispatch/core.ts sets it
174
174
  * from `board.slug`), so a plain join yields the canonical id — no transform.
175
- * Both halves are fail-loud required (Core Principle 1 — no fallback).
175
+ * Both unset (DX-4555) leaves the server board-less: a board-scoped call must then carry its own `board`
176
+ * argument (`DashboardHttpClient.buildUrl` refuses otherwise). A slug without its repo fails loud.
176
177
  */
177
178
  function boot() {
178
179
  const dashboardUrl = readEnvOrDie("DANXBOT_DASHBOARD_URL");
179
- const repo = readEnvOrDie("DANX_REPO_NAME");
180
+ const repo = readEnvOptional("DANX_REPO_NAME");
181
+ const boardName = readEnvOptional("DANXBOT_BOARD_NAME");
182
+ // DX-4555: a session started from the danxbot plugin's own server names no board; each call then
183
+ // carries a `board` argument. A board slug with no repo half is a half-written .mcp.json: fail loud.
184
+ if (boardName !== undefined && repo === undefined) {
185
+ console.error("[danx-dashboard-mcp] DANXBOT_BOARD_NAME is set but DANX_REPO_NAME is missing or empty");
186
+ process.exit(1);
187
+ }
180
188
  const session = readSessionConfig();
181
189
  config = {
182
190
  baseUrl: dashboardUrl,
183
191
  // DX-4390 — set by `SessionAccess` below; "" while signed out.
184
192
  token: "",
185
- board: `${repo}:${readEnvOrDie("DANXBOT_BOARD_NAME")}`,
193
+ board: boardName === undefined ? undefined : `${repo}:${boardName}`,
186
194
  // DX-1398 — OPTIONAL cross-process trace context. Present → stamped on every
187
195
  // outbound call so the agent's card writes chain under the launch; absent
188
196
  // (untraced dispatch) → omitted, and the dashboard mints a fresh root.
@@ -195,7 +203,7 @@ function boot() {
195
203
  access = new SessionAccess({
196
204
  dashboardUrl,
197
205
  sessionId: session?.id,
198
- repo,
206
+ repo: repo ?? null,
199
207
  clientVersion: readOwnVersion(),
200
208
  team: readTeamRef(),
201
209
  setToken: (token) => dashboardClient.setToken(token),
@@ -724,7 +732,7 @@ async function main() {
724
732
  process.stdin.once("end", () => permissionWatch.stop());
725
733
  const transport = new StdioServerTransport();
726
734
  await server.connect(transport);
727
- console.error(`danx-dashboard-mcp running on stdio (dashboard=${config.baseUrl}, board=${config.board})`);
735
+ console.error(`danx-dashboard-mcp running on stdio (dashboard=${config.baseUrl}, board=${config.board ?? "none, each call names its own"})`);
728
736
  }
729
737
  // Boot the stdio server ONLY when run as the entrypoint (the published bin).
730
738
  // Importing this module (the tool-defs generator + its drift test) registers
package/dist/listen.js CHANGED
@@ -12,9 +12,10 @@
12
12
  * `text` is what the session reads; an event it cannot read still produces one
13
13
  * (`could not read event …`), never silence. `id` is `null` only for a frame
14
14
  * that carried no usable id — which includes DX-2885's `nudge` frame
15
- * (`formatNudgeLine`): it is advisory, re-derived fresh every tick by the
16
- * dashboard's own keep-alive tick, and never replayed, so it rides this
17
- * same shape with `id: null` rather than growing a THIRD output variant.
15
+ * (`formatNudgeLine`) and DX-4340's `pacing` frame (`formatPacingLine`):
16
+ * both are advisory, written fresh by the dashboard's own keep-alive tick,
17
+ * and never replayed, so they ride this same shape with `id: null` rather
18
+ * than growing a THIRD output variant.
18
19
  * - `{type:"stopped", reason, detail, refusal?}` — once, last. `reason`
19
20
  * is the dashboard's own end reason (`superseded`, `replaced`, `revoked`,
20
21
  * `not_connected`), `refused` (the ticket was not admitted), or
@@ -329,6 +330,28 @@ export function formatNudgeLine(event) {
329
330
  "Work these cards; any you cannot work, update to its true state: open a problem, add depends_on or " +
330
331
  "conflict_on, block it with the real reason, or rollback_pickup.");
331
332
  }
333
+ const PACING_LEVELS = ["on_pace", "over_pace", "critical"];
334
+ /** Why a pacing message cannot be read, or `null` when it can. */
335
+ export function invalidPacingReason(value) {
336
+ if (!isRecord(value))
337
+ return "the pacing message is not an object";
338
+ if (!PACING_LEVELS.includes(value.level))
339
+ return "level is not on_pace, over_pace or critical";
340
+ if (value.budget !== null && !(typeof value.budget === "number" && Number.isInteger(value.budget) && value.budget >= 0))
341
+ return "budget is not a count or null";
342
+ if (typeof value.resets_at !== "string" || Number.isNaN(Date.parse(value.resets_at)))
343
+ return "resets_at is not a timestamp";
344
+ if (typeof value.text !== "string" || value.text.trim() === "")
345
+ return "text is empty";
346
+ return null;
347
+ }
348
+ /** The one readable line for a pacing message: the dashboard's text, on one line. Throws, naming the fault, on a malformed message. */
349
+ export function formatPacingLine(event) {
350
+ const invalid = invalidPacingReason(event);
351
+ if (invalid !== null)
352
+ throw new Error(`malformed pacing message: ${invalid}`);
353
+ return oneLine(`${LINE_PREFIX} ${event.text}`, 1000);
354
+ }
332
355
  /**
333
356
  * Incremental SSE parser. Comment lines (keep-alives, the connect marker) and
334
357
  * field-less blocks produce no message, which is what keeps them out of the output.
@@ -416,6 +439,30 @@ export async function runListener(options, deps) {
416
439
  deps.write({ type: "stopped", reason, detail, refusal: extra?.refusal });
417
440
  return code;
418
441
  }
442
+ /**
443
+ * The shared path of an ADVISORY frame (DX-2885 `nudge`, DX-4340 `pacing`): it carries no usable id (re-derived or written fresh by the
444
+ * dashboard's keep-alive tick, never replayed - see `plan-session-stream.ts`'s module doc), so it rides the SAME `{type:"event", id:null, text}`
445
+ * shape an ordinary activity line uses. That is what lets `bridge.ts` and the plugin's relay (`danxbot/scripts/plan-event-bridge.mjs`, outside
446
+ * this repo) forward it with NO changes: both forward any `{type:"event", id, text}` line keyed only on `type`.
447
+ *
448
+ * `origin: null` is DX-2730 D4's exemption: the frame is delivered at once, never held for a digest flush (a nudge exists to interrupt a quiet
449
+ * session; a pacing change is an instruction to act now). A frame that is not JSON or not the right shape is reported from an explicit check
450
+ * naming the fault, and the next frame still emits.
451
+ */
452
+ const emitAdvisory = (message, what, invalidReason, format) => {
453
+ let parsed;
454
+ let invalid;
455
+ try {
456
+ parsed = JSON.parse(message.data);
457
+ invalid = invalidReason(parsed);
458
+ }
459
+ catch (err) {
460
+ invalid = `not JSON: ${err.message}`;
461
+ }
462
+ const text = invalid === null ? format(parsed) : `${LINE_PREFIX} could not read ${what} (${invalid}): ${oneLine(message.data, 300)}`;
463
+ deps.write({ type: "event", id: null, text, origin: null });
464
+ return null;
465
+ };
419
466
  const handle = (message) => {
420
467
  if (message.event === "end") {
421
468
  // DX-2920 (round 3) — a JSON.parse failure here falls through to the
@@ -435,40 +482,11 @@ export async function runListener(options, deps) {
435
482
  // `Outcome`'s "ended" reason is a closed set (`EndedReason`).
436
483
  return { kind: "ended", reason: isKnownWireEndReason(reason) ? reason : "unknown" };
437
484
  }
438
- if (message.event === "nudge") {
439
- // DX-2885 — a nudge carries no usable id (it is advisory, re-derived
440
- // fresh every tick, never replayed — see `plan-session-stream.ts`'s
441
- // module doc), so it rides the SAME `{type:"event", id:null, text}`
442
- // shape an ordinary activity line uses (`id` is `null` only for a frame
443
- // that carried no usable id — a nudge is exactly that case). That is
444
- // what lets `bridge.ts` and the plugin's relay
445
- // (`danxbot/scripts/plan-event-bridge.mjs`, outside this repo) forward
446
- // it with NO changes at all: both already forward any `{type:"event",
447
- // id, text}` line generically, keyed only on `type`.
448
- //
449
- // DX-2885 — this frame must stay exempt from DX-2730 D4's digest-
450
- // batching when that lands (see DX-2730 AC 29939, and the matching note
451
- // at the dashboard's own emit site): it must keep delivering
452
- // immediately, never held for a later digest flush.
453
- let parsed;
454
- let invalid;
455
- try {
456
- parsed = JSON.parse(message.data);
457
- invalid = invalidNudgeReason(parsed);
458
- }
459
- catch (err) {
460
- invalid = `not JSON: ${err.message}`;
461
- }
462
- const text = invalid === null
463
- ? formatNudgeLine(parsed)
464
- : `${LINE_PREFIX} could not read nudge (${invalid}): ${oneLine(message.data, 300)}`;
465
- // DX-2730 D4 — `origin: null` here, not the parsed event's own origin
466
- // (a nudge has none): this is the exemption the module doc above
467
- // describes, letting `bridge.ts` treat it exactly like an operator
468
- // event (immediate) without needing a fourth vocabulary member.
469
- deps.write({ type: "event", id: null, text, origin: null });
470
- return null;
471
- }
485
+ if (message.event === "nudge")
486
+ return emitAdvisory(message, "nudge", invalidNudgeReason, (p) => formatNudgeLine(p));
487
+ // DX-4340 - the pacing tick's one message for this session's Claude account (level or budget changed): advisory like a nudge.
488
+ if (message.event === "pacing")
489
+ return emitAdvisory(message, "pacing message", invalidPacingReason, (p) => formatPacingLine(p));
472
490
  if (message.event !== "activity")
473
491
  return null;
474
492
  // `Number(null)` is 0, so a frame with no id must never be read as event 0: that id
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.252",
3
+ "version": "0.1.256",
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",