@thehammer/danx-dashboard-mcp 0.1.193 → 0.1.194

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
@@ -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. `board` overrides this dispatch's configured board for a board-scoped route only " +
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
- }, async (args) => jsonResult(await danxbotApi(client, args)));
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.193",
3
+ "version": "0.1.194",
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",