@thehammer/danx-dashboard-mcp 0.1.88 → 0.1.89

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.
@@ -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.88",
3
+ "version": "0.1.89",
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",