@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 +29 -11
- package/dist/plan-state.js +194 -0
- package/package.json +1 -1
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`
|
|
885
|
-
// plugin's plan event bridge runs. It never
|
|
886
|
-
// input is `CLAUDE_CODE_SESSION_ID` (read from
|
|
887
|
-
// `--resume-ids` — the dashboard URL, credential source,
|
|
888
|
-
// come from the session-connection record this server's
|
|
889
|
-
// wrote (`session-connection.ts`), never from the bridge's
|
|
890
|
-
// (DX-2862: a bridge trusting its own ambient
|
|
891
|
-
// exact bug this repo fixed).
|
|
892
|
-
//
|
|
893
|
-
//
|
|
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
|
|
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.
|
|
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",
|