@thehammer/danx-dashboard-mcp 0.1.192 → 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 +1 -1
- package/dist/danxbot-api-examples.js +59 -0
- package/dist/index.js +25 -3
- package/dist/listener-reminder.js +119 -0
- package/package.json +1 -1
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
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-3923 — correct first calls for the routes every card session uses,
|
|
3
|
+
* rendered into the `danxbot_api` tool description (index.ts). The DX-3678
|
|
4
|
+
* grading run found the same avoidable 4xx in session after session (a card
|
|
5
|
+
* read's `fields` sent as a comma string, `fields.parent`, top-level list
|
|
6
|
+
* filters, a card edit sent to `PATCH /api/issues/:id`, a comment keyed
|
|
7
|
+
* `body`, retro `tests[].kind: "unit"`, the AC-tick route hunted through a
|
|
8
|
+
* 36 KB `danxbot_api_list`) because nothing an agent
|
|
9
|
+
* reads before its first call showed the right shape. The tool description
|
|
10
|
+
* is the one surface every profile and every operator session carries.
|
|
11
|
+
*
|
|
12
|
+
* Kept as data, not prose, so danxbot's own tests can check each example
|
|
13
|
+
* against the live server contract (route table, resource fields, body
|
|
14
|
+
* schemas) and fail when one drifts — this package cannot import the
|
|
15
|
+
* server's constants itself (it is published on its own).
|
|
16
|
+
*/
|
|
17
|
+
export const DANXBOT_API_EXAMPLES = {
|
|
18
|
+
cardRead: {
|
|
19
|
+
method: "GET",
|
|
20
|
+
path: "/api/issues/<id>",
|
|
21
|
+
query: { fields: { description: true, ac: true, comments: true, checklists: true, parent_title: true } },
|
|
22
|
+
},
|
|
23
|
+
cardList: {
|
|
24
|
+
method: "GET",
|
|
25
|
+
path: "/api/issues",
|
|
26
|
+
query: { filter: { status_derived: "ToDo", q: "<text>" } },
|
|
27
|
+
},
|
|
28
|
+
cardEdit: {
|
|
29
|
+
method: "PATCH",
|
|
30
|
+
path: "/api/issues/<id>/edit",
|
|
31
|
+
body: { description: "<markdown>", content_hash: "<content_hash from your read>" },
|
|
32
|
+
},
|
|
33
|
+
comment: {
|
|
34
|
+
method: "POST",
|
|
35
|
+
path: "/api/issues/<id>/comments",
|
|
36
|
+
body: { text: "<markdown>" },
|
|
37
|
+
},
|
|
38
|
+
acTick: {
|
|
39
|
+
method: "PATCH",
|
|
40
|
+
path: "/api/issues/<id>/checklists/<checklists[].id>/items/<items[].id>",
|
|
41
|
+
body: { status: "passing" },
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
/** The retro route's accepted `tests[].kind` values. */
|
|
45
|
+
export const RETRO_TEST_KIND_EXAMPLES = ["group", "e2e"];
|
|
46
|
+
function render(example) {
|
|
47
|
+
const { method, path, query, body } = example;
|
|
48
|
+
return JSON.stringify({ method, path, ...(query ? { query } : {}), ...(body ? { body } : {}) });
|
|
49
|
+
}
|
|
50
|
+
/** The description sentence `danxbot_api` carries. */
|
|
51
|
+
export function danxbotApiExamplesText() {
|
|
52
|
+
const e = DANXBOT_API_EXAMPLES;
|
|
53
|
+
return (`Correct first calls: card read ${render(e.cardRead)} (\`fields\` is an object, never a comma string); ` +
|
|
54
|
+
`card list ${render(e.cardList)} (filters only inside \`filter\`); ` +
|
|
55
|
+
`card edit ${render(e.cardEdit)}; ` +
|
|
56
|
+
`comment ${render(e.comment)}; ` +
|
|
57
|
+
`AC tick ${render(e.acTick)}; ` +
|
|
58
|
+
`retro \`tests[].kind\` is ${RETRO_TEST_KIND_EXAMPLES.map((k) => `"${k}"`).join(" or ")}.`);
|
|
59
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -79,12 +79,14 @@ 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";
|
|
85
86
|
import { DashboardHttpClient } from "./http-client.js";
|
|
86
87
|
import { planConnect, danxbotApi, danxbotApiList, danxbotApiSpec, attachFile, DANXBOT_API_METHODS, } from "./handlers.js";
|
|
87
88
|
import { bodyValueSchema, queryValueSchema } from "./types.js";
|
|
89
|
+
import { danxbotApiExamplesText } from "./danxbot-api-examples.js";
|
|
88
90
|
import { registerLifecycleTools, applyLifecycleVisibility, readLifecycleUrlsFromEnv, } from "./lifecycle.js";
|
|
89
91
|
function readEnvOrDie(name) {
|
|
90
92
|
const v = process.env[name];
|
|
@@ -113,6 +115,8 @@ function readEnvOptional(name) {
|
|
|
113
115
|
// while a real run still fails loud on missing env via `boot()`.
|
|
114
116
|
let config;
|
|
115
117
|
let client;
|
|
118
|
+
// DX-3997 — late-bound with `client`; see `listener-reminder.ts`.
|
|
119
|
+
let listenerReminder;
|
|
116
120
|
/**
|
|
117
121
|
* DX-2862 — what this server would tell the session's plan event bridge about
|
|
118
122
|
* how to reach the dashboard AS THIS SERVER: the URL, where the credential came
|
|
@@ -146,6 +150,14 @@ function boot() {
|
|
|
146
150
|
fingerprint: credential.fingerprint,
|
|
147
151
|
};
|
|
148
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
|
+
});
|
|
149
161
|
// DX-3465 (PLN-11 R-25) — the lifecycle tool set's per-tool visibility is
|
|
150
162
|
// per-dispatch, not static: a tool whose callback URL wasn't injected for
|
|
151
163
|
// THIS dispatch must not even be advertised (mirrors the old standalone
|
|
@@ -227,6 +239,7 @@ same four verbs:
|
|
|
227
239
|
- create POST <collection> — the resource's own required fields; a 400 names any missing/invalid one.
|
|
228
240
|
- update PATCH/PUT <collection>/:id — send only the fields you are changing; most resources require the
|
|
229
241
|
concurrency token below when the edit touches contested text fields.
|
|
242
|
+
A card is the exception: PATCH /api/issues/<id>/edit.
|
|
230
243
|
- delete DELETE <collection>/:id — usually a soft-delete; the resource's own docs say so.
|
|
231
244
|
Don't know the route for what you need to do? Call \`danxbot_api_list()\` — it lists exactly the routes your
|
|
232
245
|
credential may call, one line each. Then call \`danxbot_api_spec({endpoints: ["METHOD /path/template"]})\` for the
|
|
@@ -328,10 +341,14 @@ const boardField = {
|
|
|
328
341
|
strictTool("danxbot_api", "Call any dashboard API route your credential may reach. `method` + `path` (a FULL path, e.g. " +
|
|
329
342
|
'"/api/issues/DX-1/transition" — substitute real ids yourself, never a route template with `:id` in it) + ' +
|
|
330
343
|
"optional `query`/`body`. The response is the route's JSON body verbatim, `reminders[]` included when the " +
|
|
331
|
-
"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 " +
|
|
332
346
|
"(ignored by a route that isn't board-scoped). See this server's own instructions (shown once, at connect) " +
|
|
333
347
|
"for the shared resource shape, the stale-hash rule, and how to read a refusal — don't know the route? call " +
|
|
334
|
-
"`danxbot_api_list` first; unsure of its exact shape? call `danxbot_api_spec` first."
|
|
348
|
+
"`danxbot_api_list` first; unsure of its exact shape? call `danxbot_api_spec` first. " +
|
|
349
|
+
// DX-3923 — the shapes agents guessed wrong most often, shown before the
|
|
350
|
+
// first call instead of learned from a 4xx.
|
|
351
|
+
danxbotApiExamplesText(), {
|
|
335
352
|
method: z.enum(DANXBOT_API_METHODS),
|
|
336
353
|
path: z
|
|
337
354
|
.string()
|
|
@@ -359,7 +376,10 @@ strictTool("danxbot_api", "Call any dashboard API route your credential may reac
|
|
|
359
376
|
// instead of reaching the dashboard malformed.
|
|
360
377
|
body: bodyValueSchema.optional(),
|
|
361
378
|
...boardField,
|
|
362
|
-
},
|
|
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))));
|
|
363
383
|
// ---------------- danxbot_api_list (DX-3465 A5, PLN-11 R-25) ----------------
|
|
364
384
|
strictTool("danxbot_api_list", "Lists the dashboard API routes your credential may call right now — `[{method, path, summary, permission}]`, " +
|
|
365
385
|
"one line each, in the route TEMPLATE form `danxbot_api_spec` also expects (e.g. " +
|
|
@@ -436,6 +456,8 @@ strictTool("plan_connect",
|
|
|
436
456
|
// whose credential the stream must be minted with.
|
|
437
457
|
async (args) => {
|
|
438
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();
|
|
439
461
|
return jsonResult({
|
|
440
462
|
...result,
|
|
441
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.
|
|
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",
|