@thehammer/danx-dashboard-mcp 0.1.79 → 0.1.81

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/index.js CHANGED
@@ -94,6 +94,7 @@
94
94
  */
95
95
  import { isEntrypointModule } from "./entrypoint.js";
96
96
  import { BRIDGE_SUBCOMMAND, runBridgeCommand } from "./bridge.js";
97
+ import { PLAN_STATE_SUBCOMMAND, runPlanStateCommand } from "./plan-state.js";
97
98
  import { resolveDeclaredCredential } from "./credential.js";
98
99
  import { recordSessionConnectionAfterConnect } from "./session-connection.js";
99
100
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -881,16 +882,25 @@ async function main() {
881
882
  // symlink-aware (DX-1647) so it holds under the symlinked `npx` bin the worker
882
883
  // spawns, not just a direct `node dist/index.js`.
883
884
  //
884
- // `bridge` is the one subcommand: the session event stream client the danxbot
885
- // plugin's plan event bridge runs. It never boots the MCP server; its only
886
- // input is `CLAUDE_CODE_SESSION_ID` (read from its environment) plus
887
- // `--resume-ids` — the dashboard URL, credential source, and credential itself
888
- // come from the session-connection record this server's own `plan_connect`
889
- // wrote (`session-connection.ts`), never from the bridge's own environment
890
- // (DX-2862: a bridge trusting its own ambient `DANXBOT_DISPATCH_TOKEN` is the
891
- // exact bug this repo fixed). Any OTHER argument is refused rather than
892
- // ignored, so a typo cannot silently start an MCP server on stdio where the
893
- // bridge was meant to run.
885
+ // `bridge` and `plan-state` are the two subcommands. `bridge` is the session
886
+ // event stream client the danxbot plugin's plan event bridge runs. It never
887
+ // boots the MCP server; its only input is `CLAUDE_CODE_SESSION_ID` (read from
888
+ // its environment) plus `--resume-ids` — the dashboard URL, credential source,
889
+ // and credential itself come from the session-connection record this server's
890
+ // own `plan_connect` wrote (`session-connection.ts`), never from the bridge's
891
+ // own environment (DX-2862: a bridge trusting its own ambient
892
+ // `DANXBOT_DISPATCH_TOKEN` is the exact bug this repo fixed).
893
+ //
894
+ // `plan-state` (DX-2921) is the ONE-SHOT client of
895
+ // `GET /api/plan-sessions/me/turn-state`, run by the DX-2888 plugin's
896
+ // turn-start / turn-end hooks — see `plan-state.ts`'s own doc comment for the
897
+ // full contract. It ALWAYS exits 0 (success or failure alike: the hook must
898
+ // stay silent), so even an unexpected rejection here is caught and reported
899
+ // the same `{ok:false, reason}` way rather than propagating a non-zero exit
900
+ // or an uncaught stack trace into the hook.
901
+ //
902
+ // Any OTHER argument is refused rather than ignored, so a typo cannot
903
+ // silently start an MCP server on stdio where a subcommand was meant to run.
894
904
  if (isEntrypointModule(import.meta.url, process.argv[1])) {
895
905
  const [subcommand, ...rest] = process.argv.slice(2);
896
906
  if (subcommand === BRIDGE_SUBCOMMAND) {
@@ -899,8 +909,16 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
899
909
  process.exit(1);
900
910
  });
901
911
  }
912
+ else if (subcommand === PLAN_STATE_SUBCOMMAND) {
913
+ runPlanStateCommand(rest).then((code) => process.exit(code), (err) => {
914
+ console.error(`[danx-dashboard-mcp] plan-state fatal: ${err.message}`);
915
+ process.stdout.write(`${JSON.stringify({ ok: false, reason: "fatal" })}\n`);
916
+ process.exit(0);
917
+ });
918
+ }
902
919
  else if (subcommand !== undefined) {
903
- console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only one is "${BRIDGE_SUBCOMMAND}")`);
920
+ console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only ones are ` +
921
+ `"${BRIDGE_SUBCOMMAND}" and "${PLAN_STATE_SUBCOMMAND}")`);
904
922
  process.exit(2);
905
923
  }
906
924
  else {
@@ -0,0 +1,194 @@
1
+ /**
2
+ * `danx-dashboard-mcp plan-state` — the ONE-SHOT client of
3
+ * `GET /api/plan-sessions/me/turn-state` (DX-2921).
4
+ *
5
+ * WHO RUNS IT. The danxbot Claude Code plugin's DX-2888 turn-start /
6
+ * turn-end hooks, via the plugin's existing `bridgeCommand()` npx path — the
7
+ * SAME package pin, no separate install, no deep import of this package's
8
+ * `dist/` internals (that was DX-2921's whole reason to exist: the DX-2888
9
+ * architecture review found the first build of this logic vendored a private
10
+ * copy of the MCP package and hardcoded the dashboard HTTP contract inside
11
+ * the PLUGIN). The plugin decides what to DO with the answer (inject plan
12
+ * numbers at turn start, refuse to let a turn end while the session could
13
+ * legally start work and has nothing running); this module only fetches it.
14
+ *
15
+ * THE CONTRACT (agreed with the DX-2888 plugin side, issue comment 4245 on
16
+ * DX-2921 — binding, because the plugin is already built against it):
17
+ * - print EXACTLY ONE JSON line on stdout, and ALWAYS exit 0 — success or
18
+ * failure alike, because the hook must stay silent rather than spam the
19
+ * session's own turn with a stack trace;
20
+ * - a session that is not connected to a plan prints
21
+ * `{"ok":false,"reason":"session_not_connected"}` — that EXACT literal,
22
+ * because the plugin keys its no-log-spam rule on it (it is also the
23
+ * dashboard's own 409 error code for this case — see
24
+ * `requireConnectedPlanId`, `plan-session-context.ts`);
25
+ * - on success, every count is a finite number, `plan` carries `id` +
26
+ * `name`, and every `startable` / `held` entry carries a string `id` +
27
+ * `title` — the plugin rejects anything else as `bad_response`, so this
28
+ * module validates the server's response BEFORE printing it, rather than
29
+ * trusting a 200 status to mean a well-shaped body;
30
+ * - human-readable diagnostics go to stderr only, never stdout.
31
+ *
32
+ * THE CREDENTIAL — the SAME resolver `bridge` uses (DX-2862 / DX-2921 AC),
33
+ * via `resolveBridgeOptions` (`bridge.ts`): the dashboard URL, credential
34
+ * source and credential all come from the connection record this session's
35
+ * OWN danx-dashboard MCP server wrote on its last successful `plan_connect`
36
+ * (`session-connection.ts`), never from this process's own ambient env. That
37
+ * is deliberate here for the identical reason it is deliberate for `bridge`:
38
+ * a hook process trusting whatever `DANXBOT_DISPATCH_TOKEN` happens to be in
39
+ * its environment is precisely the DX-2862 bug (a hook's ambient credential
40
+ * silently pointed at a different dashboard from the one the session's tools
41
+ * actually use).
42
+ *
43
+ * A HARD TIMEOUT (AC 30672) — `PLAN_STATE_REQUEST_TIMEOUT_MS`. This call
44
+ * blocks a turn boundary the plugin is holding open, so it must never hang:
45
+ * a slow or wedged dashboard is `{ok:false, reason:"timeout"}`, not a stuck
46
+ * hook.
47
+ */
48
+ import { SESSION_ID_HEADER, resolveBridgeOptions } from "./bridge.js";
49
+ export const PLAN_STATE_SUBCOMMAND = "plan-state";
50
+ export const TURN_STATE_PATH = "/api/plan-sessions/me/turn-state";
51
+ /** How long this one-shot call may take before it counts as a timeout. */
52
+ export const PLAN_STATE_REQUEST_TIMEOUT_MS = 5_000;
53
+ const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${PLAN_STATE_SUBCOMMAND}`;
54
+ function errorCodeOf(body) {
55
+ return typeof body === "object" && body !== null && typeof body.error === "string"
56
+ ? body.error
57
+ : null;
58
+ }
59
+ function isFiniteNumber(v) {
60
+ return typeof v === "number" && Number.isFinite(v);
61
+ }
62
+ function isCardRef(v) {
63
+ return (typeof v === "object" &&
64
+ v !== null &&
65
+ typeof v.id === "string" &&
66
+ typeof v.title === "string");
67
+ }
68
+ /**
69
+ * The strict shape check the contract pins (comment 4245): finite counts, a
70
+ * `plan` with `id` + `name`, and a string `id` + `title` on every
71
+ * `startable` / `held` entry. `null` on anything short of that — a 200 whose
72
+ * body does not match is `bad_response`, exactly like a non-2xx.
73
+ */
74
+ export function parseTurnStateBody(body) {
75
+ if (typeof body !== "object" || body === null)
76
+ return null;
77
+ const b = body;
78
+ const plan = b.plan;
79
+ if (typeof plan !== "object" || plan === null)
80
+ return null;
81
+ const p = plan;
82
+ if (!isFiniteNumber(p.id) || typeof p.name !== "string")
83
+ return null;
84
+ const counts = b.counts;
85
+ if (typeof counts !== "object" || counts === null)
86
+ return null;
87
+ const c = counts;
88
+ if (!isFiniteNumber(c.open) || !isFiniteNumber(c.inProgress) || !isFiniteNumber(c.needsYou))
89
+ return null;
90
+ if (!Array.isArray(b.startable) || !b.startable.every(isCardRef))
91
+ return null;
92
+ if (!Array.isArray(b.held) || !b.held.every(isCardRef))
93
+ return null;
94
+ return {
95
+ plan: { id: p.id, name: p.name },
96
+ counts: { open: c.open, inProgress: c.inProgress, needsYou: c.needsYou },
97
+ startable: b.startable,
98
+ held: b.held,
99
+ };
100
+ }
101
+ /**
102
+ * One authenticated GET, with a hard timeout, classified into the output
103
+ * shape this subcommand always prints — never a throw, never a non-2xx
104
+ * bubbling past this function.
105
+ */
106
+ export async function fetchTurnState(options, deps) {
107
+ const controller = new AbortController();
108
+ const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
109
+ let status;
110
+ let text;
111
+ try {
112
+ try {
113
+ const response = await deps.fetch(`${options.dashboardUrl}${TURN_STATE_PATH}`, {
114
+ method: "GET",
115
+ headers: {
116
+ Authorization: `Bearer ${options.token}`,
117
+ Accept: "application/json",
118
+ [SESSION_ID_HEADER]: options.sessionId,
119
+ },
120
+ signal: controller.signal,
121
+ });
122
+ status = response.status;
123
+ text = await response.text();
124
+ }
125
+ catch (err) {
126
+ return { ok: false, reason: controller.signal.aborted ? "timeout" : "request_failed" };
127
+ }
128
+ }
129
+ finally {
130
+ clearTimeout(timer);
131
+ }
132
+ let body = null;
133
+ try {
134
+ body = text === "" ? null : JSON.parse(text);
135
+ }
136
+ catch {
137
+ return { ok: false, reason: "bad_response" };
138
+ }
139
+ // The exact literal the plugin's no-log-spam rule keys on (AC 30672 /
140
+ // comment 4245) — the dashboard's own 409 error code for an unconnected
141
+ // session, passed through verbatim rather than renamed.
142
+ if (status === 409 && errorCodeOf(body) === "session_not_connected") {
143
+ return { ok: false, reason: "session_not_connected" };
144
+ }
145
+ if (status === 401 || status === 403) {
146
+ return { ok: false, reason: "unauthorized" };
147
+ }
148
+ if (status < 200 || status >= 300) {
149
+ return { ok: false, reason: "http_error" };
150
+ }
151
+ const parsed = parseTurnStateBody(body);
152
+ if (parsed === null) {
153
+ return { ok: false, reason: "bad_response" };
154
+ }
155
+ return { ok: true, ...parsed };
156
+ }
157
+ /** The bin's `plan-state` subcommand, wired to the real process. Always resolves to exit code 0. */
158
+ export async function runPlanStateCommand(argv, env = process.env,
159
+ /** Test seam: the home the session's connection record is read from. */
160
+ resolveFrom = {}) {
161
+ const write = (output) => {
162
+ process.stdout.write(`${JSON.stringify(output)}\n`);
163
+ };
164
+ if (argv.length !== 0) {
165
+ // A malformed invocation is still a "the hook must stay silent" failure,
166
+ // not a crash — same ok:false/exit-0 contract as every other failure
167
+ // path here, with the usage line reserved for a human reading stderr.
168
+ write({ ok: false, reason: "usage" });
169
+ process.stderr.write(`${USAGE}\n`);
170
+ return 0;
171
+ }
172
+ const sessionId = env.CLAUDE_CODE_SESSION_ID;
173
+ if (!sessionId) {
174
+ write({ ok: false, reason: "no_session_id" });
175
+ process.stderr.write(`${USAGE}\n`);
176
+ return 0;
177
+ }
178
+ let options;
179
+ try {
180
+ options = resolveBridgeOptions({ sessionId, resumeIds: [] }, env, resolveFrom);
181
+ }
182
+ catch (err) {
183
+ const start = err;
184
+ write({ ok: false, reason: start.reason });
185
+ process.stderr.write(`${start.reason}: ${start.message}. Fix: ${start.fix}.\n`);
186
+ return 0;
187
+ }
188
+ const output = await fetchTurnState(options, {
189
+ fetch: (input, init) => fetch(input, init),
190
+ requestTimeoutMs: PLAN_STATE_REQUEST_TIMEOUT_MS,
191
+ });
192
+ write(output);
193
+ return 0;
194
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.79",
3
+ "version": "0.1.81",
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",