@thehammer/danx-dashboard-mcp 0.1.193 → 0.1.195
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 +1 -1
- package/dist/index.js +19 -2
- package/dist/listener-reminder.js +119 -0
- package/dist/session-stats.js +9 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `m
|
|
|
21
21
|
|
|
22
22
|
| Tool | Purpose |
|
|
23
23
|
|---|---|
|
|
24
|
-
| `danxbot_api` | `{method, path, query?, body?}` — calls any dashboard route the caller's credential may reach (card/plan CRUD, comments, dependencies, problems/solutions, quality gates, retro, triage, transitions, …). Returns `{ok, status, body}`; `body` carries the route's own response, or its structured error verbatim when `ok:false`. |
|
|
24
|
+
| `danxbot_api` | `{method, path, query?, body?}` — calls any dashboard route the caller's credential may reach (card/plan CRUD, comments, dependencies, problems/solutions, quality gates, retro, triage, transitions, …). Returns `{ok, status, body}`; `body` carries the route's own response, or its structured error verbatim when `ok:false`. While this session is plan-connected, the dashboard's own reminders for it (today: `plan_list.listener_unattached`, attached by the server to `GET /api/plans` when the event listener is unattached past the startup grace, DX-3997) are relayed into the body's `reminders[]`; the MCP carries no reminder text or state of its own and re-reads at most every 30s. |
|
|
25
25
|
| `danxbot_api_list` | No args. Lists the routes this caller's credential may reach, one line each (`{method, path, summary, permission}`) — call this first when you don't know the route for what you need to do. |
|
|
26
26
|
| `danxbot_api_spec` | `{endpoints}` (comma-joined `METHOD /path` list, required, min 1). Returns the exact OpenAPI contract for those routes — call this when you know the route but not its exact request/response shape. |
|
|
27
27
|
|
package/dist/index.js
CHANGED
|
@@ -79,6 +79,7 @@ import { ACTIVITY_SUBCOMMAND, runActivityCommand } from "./activity.js";
|
|
|
79
79
|
import { EVENT_TEXT_SUBCOMMAND, runEventTextCommand } from "./event-text.js";
|
|
80
80
|
import { resolveDeclaredCredential } from "./credential.js";
|
|
81
81
|
import { recordSessionConnectionAfterConnect } from "./session-connection.js";
|
|
82
|
+
import { createListenerReminder, isPlanConnectedOnThisMachine, } from "./listener-reminder.js";
|
|
82
83
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
83
84
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
84
85
|
import { z } from "zod";
|
|
@@ -114,6 +115,8 @@ function readEnvOptional(name) {
|
|
|
114
115
|
// while a real run still fails loud on missing env via `boot()`.
|
|
115
116
|
let config;
|
|
116
117
|
let client;
|
|
118
|
+
// DX-3997 — late-bound with `client`; see `listener-reminder.ts`.
|
|
119
|
+
let listenerReminder;
|
|
117
120
|
/**
|
|
118
121
|
* DX-2862 — what this server would tell the session's plan event bridge about
|
|
119
122
|
* how to reach the dashboard AS THIS SERVER: the URL, where the credential came
|
|
@@ -147,6 +150,14 @@ function boot() {
|
|
|
147
150
|
fingerprint: credential.fingerprint,
|
|
148
151
|
};
|
|
149
152
|
client = new DashboardHttpClient(config);
|
|
153
|
+
const dashboardClient = client;
|
|
154
|
+
listenerReminder = createListenerReminder({
|
|
155
|
+
sessionId: config.session?.id,
|
|
156
|
+
fetchHealth: () => dashboardClient.request({ method: "GET", basePath: "", path: "/api/plans", query: { limit: 1 } }),
|
|
157
|
+
isPlanConnected: isPlanConnectedOnThisMachine,
|
|
158
|
+
now: Date.now,
|
|
159
|
+
logLine: (line) => console.error(line),
|
|
160
|
+
});
|
|
150
161
|
// DX-3465 (PLN-11 R-25) — the lifecycle tool set's per-tool visibility is
|
|
151
162
|
// per-dispatch, not static: a tool whose callback URL wasn't injected for
|
|
152
163
|
// THIS dispatch must not even be advertised (mirrors the old standalone
|
|
@@ -330,7 +341,8 @@ const boardField = {
|
|
|
330
341
|
strictTool("danxbot_api", "Call any dashboard API route your credential may reach. `method` + `path` (a FULL path, e.g. " +
|
|
331
342
|
'"/api/issues/DX-1/transition" — substitute real ids yourself, never a route template with `:id` in it) + ' +
|
|
332
343
|
"optional `query`/`body`. The response is the route's JSON body verbatim, `reminders[]` included when the " +
|
|
333
|
-
"route attaches one
|
|
344
|
+
"route attaches one — a plan-connected session whose event listener the dashboard reports unattached also gets " +
|
|
345
|
+
"the dashboard's own `plan_list.listener_unattached` reminder relayed there (DX-3997). `board` overrides this dispatch's configured board for a board-scoped route only " +
|
|
334
346
|
"(ignored by a route that isn't board-scoped). See this server's own instructions (shown once, at connect) " +
|
|
335
347
|
"for the shared resource shape, the stale-hash rule, and how to read a refusal — don't know the route? call " +
|
|
336
348
|
"`danxbot_api_list` first; unsure of its exact shape? call `danxbot_api_spec` first. " +
|
|
@@ -364,7 +376,10 @@ strictTool("danxbot_api", "Call any dashboard API route your credential may reac
|
|
|
364
376
|
// instead of reaching the dashboard malformed.
|
|
365
377
|
body: bodyValueSchema.optional(),
|
|
366
378
|
...boardField,
|
|
367
|
-
},
|
|
379
|
+
},
|
|
380
|
+
// DX-3997 — the result rides back verbatim, plus the dashboard's own `reminders[]` entries
|
|
381
|
+
// for this plan-connected session (the server decides; `listener-reminder.ts` only relays).
|
|
382
|
+
async (args) => jsonResult(await listenerReminder.apply(await danxbotApi(client, args))));
|
|
368
383
|
// ---------------- danxbot_api_list (DX-3465 A5, PLN-11 R-25) ----------------
|
|
369
384
|
strictTool("danxbot_api_list", "Lists the dashboard API routes your credential may call right now — `[{method, path, summary, permission}]`, " +
|
|
370
385
|
"one line each, in the route TEMPLATE form `danxbot_api_spec` also expects (e.g. " +
|
|
@@ -441,6 +456,8 @@ strictTool("plan_connect",
|
|
|
441
456
|
// whose credential the stream must be minted with.
|
|
442
457
|
async (args) => {
|
|
443
458
|
const result = await planConnect(client, args);
|
|
459
|
+
// DX-3997 — a (re)connect restarts the bridge; the next `danxbot_api` must re-read health.
|
|
460
|
+
listenerReminder.invalidate();
|
|
444
461
|
return jsonResult({
|
|
445
462
|
...result,
|
|
446
463
|
...recordSessionConnectionAfterConnect(result, {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { readSessionConnection } from "./session-connection.js";
|
|
2
|
+
/** DX-3997 — how long one health read serves every response; per server process. */
|
|
3
|
+
export const HEALTH_CACHE_WINDOW_MS = 30_000;
|
|
4
|
+
function isReminder(value) {
|
|
5
|
+
const r = value;
|
|
6
|
+
return typeof r === "object" && r !== null && typeof r.key === "string" && typeof r.text === "string";
|
|
7
|
+
}
|
|
8
|
+
/** The reminders the health response carries, or a thrown Error naming why it cannot be read. */
|
|
9
|
+
function readReminders(result) {
|
|
10
|
+
if (!result.ok)
|
|
11
|
+
throw new Error(`GET /api/plans answered ${result.status}`);
|
|
12
|
+
const body = result.body;
|
|
13
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
14
|
+
throw new Error("GET /api/plans did not answer a JSON object");
|
|
15
|
+
}
|
|
16
|
+
const reminders = body.reminders;
|
|
17
|
+
if (reminders === undefined)
|
|
18
|
+
return [];
|
|
19
|
+
if (!Array.isArray(reminders) || !reminders.every(isReminder)) {
|
|
20
|
+
throw new Error("GET /api/plans carried a `reminders` field that is not an array of {key, text} strings");
|
|
21
|
+
}
|
|
22
|
+
return reminders.map(({ key, text }) => ({ key, text }));
|
|
23
|
+
}
|
|
24
|
+
export function createListenerReminder(deps) {
|
|
25
|
+
let cached = null;
|
|
26
|
+
let inFlight = null;
|
|
27
|
+
// Bumped by `invalidate()`: a read that started under an older value must not refill the cache.
|
|
28
|
+
let generation = 0;
|
|
29
|
+
let failureLogged = false;
|
|
30
|
+
let shapeLogged = false;
|
|
31
|
+
const logFailure = (reason) => {
|
|
32
|
+
if (failureLogged)
|
|
33
|
+
return;
|
|
34
|
+
failureLogged = true;
|
|
35
|
+
deps.logLine(`[danx-dashboard-mcp] plan listener health check failed (${reason}); nothing relayed`);
|
|
36
|
+
};
|
|
37
|
+
async function read() {
|
|
38
|
+
try {
|
|
39
|
+
const reminders = readReminders(await deps.fetchHealth());
|
|
40
|
+
failureLogged = false;
|
|
41
|
+
return reminders;
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
logFailure(err.message);
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function remindersNow() {
|
|
49
|
+
if (cached !== null && deps.now() - cached.at < HEALTH_CACHE_WINDOW_MS)
|
|
50
|
+
return cached.reminders;
|
|
51
|
+
// One read serves every caller that arrives while it is outstanding.
|
|
52
|
+
if (inFlight !== null)
|
|
53
|
+
return inFlight;
|
|
54
|
+
const startedUnder = generation;
|
|
55
|
+
const started = read()
|
|
56
|
+
.then((reminders) => {
|
|
57
|
+
if (generation === startedUnder)
|
|
58
|
+
cached = { reminders, at: deps.now() };
|
|
59
|
+
return reminders;
|
|
60
|
+
})
|
|
61
|
+
.finally(() => {
|
|
62
|
+
if (inFlight === started)
|
|
63
|
+
inFlight = null;
|
|
64
|
+
});
|
|
65
|
+
inFlight = started;
|
|
66
|
+
return started;
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
async apply(result) {
|
|
70
|
+
const sessionId = deps.sessionId;
|
|
71
|
+
if (sessionId === undefined || sessionId === "")
|
|
72
|
+
return result;
|
|
73
|
+
const body = result.body;
|
|
74
|
+
// Never wrap or alter a body that is not a JSON object.
|
|
75
|
+
if (typeof body !== "object" || body === null || Array.isArray(body))
|
|
76
|
+
return result;
|
|
77
|
+
// Read per call (a file read): a connect made after this process started takes effect on
|
|
78
|
+
// the next call, and an unconnected session costs zero health reads.
|
|
79
|
+
let connected;
|
|
80
|
+
try {
|
|
81
|
+
connected = deps.isPlanConnected(sessionId);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
logFailure(err.message);
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
if (!connected)
|
|
88
|
+
return result;
|
|
89
|
+
const existing = body.reminders;
|
|
90
|
+
if (existing !== undefined && !Array.isArray(existing)) {
|
|
91
|
+
if (!shapeLogged) {
|
|
92
|
+
shapeLogged = true;
|
|
93
|
+
deps.logLine("[danx-dashboard-mcp] listener reminder not relayed: the response's own `reminders` is not an array");
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
const relayed = await remindersNow();
|
|
98
|
+
const present = new Set((existing ?? []).map((r) => (isReminder(r) ? r.key : null)));
|
|
99
|
+
const fresh = relayed.filter((r) => {
|
|
100
|
+
if (present.has(r.key))
|
|
101
|
+
return false;
|
|
102
|
+
present.add(r.key);
|
|
103
|
+
return true;
|
|
104
|
+
});
|
|
105
|
+
if (fresh.length === 0)
|
|
106
|
+
return result;
|
|
107
|
+
return { ...result, body: { ...body, reminders: [...(existing ?? []), ...fresh] } };
|
|
108
|
+
},
|
|
109
|
+
invalidate() {
|
|
110
|
+
generation += 1;
|
|
111
|
+
cached = null;
|
|
112
|
+
inFlight = null;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** The production wiring: the real local record. */
|
|
117
|
+
export function isPlanConnectedOnThisMachine(sessionId) {
|
|
118
|
+
return readSessionConnection(sessionId) !== null;
|
|
119
|
+
}
|
package/dist/session-stats.js
CHANGED
|
@@ -59,10 +59,17 @@ export function transcriptPathFromEnv(env) {
|
|
|
59
59
|
const realWatcher = (options) => new SessionLogWatcher(options);
|
|
60
60
|
/** The collector's dispatch-id argument; the heartbeat is addressed by session, not dispatch. */
|
|
61
61
|
const OPERATOR_DISPATCH_REF = "operator-session";
|
|
62
|
+
/**
|
|
63
|
+
* DX-3962 - the bridge's collector. `"operator-bridge"` is the ONE mode that credits a sub-agent's replies to the card
|
|
64
|
+
* it claimed (from its own transcript's pickup); a worker builds its collector with attribution off.
|
|
65
|
+
*/
|
|
66
|
+
function newCollector() {
|
|
67
|
+
return new DispatchUsage("operator-bridge");
|
|
68
|
+
}
|
|
62
69
|
export class OperatorSessionStats {
|
|
63
70
|
transcriptPath;
|
|
64
71
|
makeWatcher;
|
|
65
|
-
usage =
|
|
72
|
+
usage = newCollector();
|
|
66
73
|
watcher = null;
|
|
67
74
|
stopped = false;
|
|
68
75
|
constructor(transcriptPath, makeWatcher = realWatcher) {
|
|
@@ -153,7 +160,7 @@ export class OperatorSessionStats {
|
|
|
153
160
|
return;
|
|
154
161
|
watcher.stop();
|
|
155
162
|
this.watcher = null;
|
|
156
|
-
this.usage =
|
|
163
|
+
this.usage = newCollector();
|
|
157
164
|
})
|
|
158
165
|
.catch((err) => {
|
|
159
166
|
process.stderr.write(`operator session stats: transcript watcher failed: ${err.message}\n`);
|
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.195",
|
|
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",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
34
|
-
"@thehammer/danx-session-telemetry": "0.1.
|
|
34
|
+
"@thehammer/danx-session-telemetry": "0.1.21",
|
|
35
35
|
"zod": "^3.25.76"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|