@thehammer/danx-dashboard-mcp 0.1.159 → 0.1.162
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/http-client.js +16 -0
- package/dist/index.js +3 -3
- package/dist/version-floor.js +128 -0
- package/package.json +1 -1
package/dist/http-client.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { assertVersionFloorSatisfied, MCP_MIN_VERSION_HEADER } from "./version-floor.js";
|
|
1
2
|
export class DashboardHttpClient {
|
|
2
3
|
config;
|
|
3
4
|
fetchImpl;
|
|
5
|
+
// DX-3597 — true once this instance has made ONE request, regardless of
|
|
6
|
+
// outcome. Drives the "first call OR any 4xx" version-floor check below.
|
|
7
|
+
hasCheckedVersionFloor = false;
|
|
4
8
|
constructor(config, fetchImpl = fetch) {
|
|
5
9
|
this.config = config;
|
|
6
10
|
this.fetchImpl = fetchImpl;
|
|
@@ -50,6 +54,18 @@ export class DashboardHttpClient {
|
|
|
50
54
|
catch (err) {
|
|
51
55
|
throw new Error(`[danx-dashboard-mcp] network failure on ${args.method} ${url}: ${err instanceof Error ? err.message : String(err)}`);
|
|
52
56
|
}
|
|
57
|
+
// DX-3597 — proactive on the first call this instance ever makes
|
|
58
|
+
// (regardless of outcome: a stale client can otherwise look fine for a
|
|
59
|
+
// while on routes whose shape didn't change), and on every subsequent
|
|
60
|
+
// 4xx (a response that might otherwise read as an ordinary caller
|
|
61
|
+
// error when it's really "you're running an old MCP"). Throws
|
|
62
|
+
// McpOutdatedError instead of returning below when this package is
|
|
63
|
+
// behind the floor — see version-floor.ts's own doc.
|
|
64
|
+
const isFirstCall = !this.hasCheckedVersionFloor;
|
|
65
|
+
this.hasCheckedVersionFloor = true;
|
|
66
|
+
if (isFirstCall || (res.status >= 400 && res.status < 500)) {
|
|
67
|
+
assertVersionFloorSatisfied(res.headers.get(MCP_MIN_VERSION_HEADER));
|
|
68
|
+
}
|
|
53
69
|
const text = await res.text();
|
|
54
70
|
let parsed = null;
|
|
55
71
|
if (text !== "") {
|
package/dist/index.js
CHANGED
|
@@ -879,7 +879,7 @@ strictTool("issue_attach", "Attach a LOCAL file to an issue card: `id` (the card
|
|
|
879
879
|
// plan id — and it can only ever bind the caller's own session. `plan_create`
|
|
880
880
|
// also takes no plan id, but for a different reason: it MAKES a plan rather
|
|
881
881
|
// than acting on one, so there is no existing plan for an id to name yet.
|
|
882
|
-
strictTool("plan_list", "List every plan, and learn which plan THIS session is connected to. Plans are GLOBAL, not board-scoped — a plan is a named, dated set of cards an operator assembled by hand, from any repo. Returns `{plans: [{id, ref, name, createdAt, cardCount, boards, status, signedOffAt, signedOffBy, autoSignOff}], session, sessionListenerAttached}` — `ref` is the plan's short reference (`
|
|
882
|
+
strictTool("plan_list", "List every plan, and learn which plan THIS session is connected to. Plans are GLOBAL, not board-scoped — a plan is a named, dated set of cards an operator assembled by hand, from any repo. Returns `{plans: [{id, ref, name, createdAt, cardCount, boards, status, signedOffAt, signedOffBy, autoSignOff}], session, sessionListenerAttached}` — `ref` is the plan's short reference (`PLAN-<id>`), cite it rather than the bare id. `status` is computed fresh on every read, never stored — except the sign-off pair itself: `awaiting-session` (no live session — a session row isn't released just because a session ended), `planning` (no cards, or only Review/Backlog/terminal ones with ≥1 not Done/Cancelled), `building` (a live session AND ≥1 card ToDo/In Progress or stuck-but-active — Blocked/Needs Help), `awaiting-sign-off` (≥1 card, all Done/Cancelled, but nobody has signed the plan off yet — DX-3519), `complete` (≥1 card, all Done/Cancelled, AND signed off — wins even with no session). `signedOffAt`/`signedOffBy` are `null` until a human (`signedOffBy` = their identity) or the `autoSignOff` toggle (`signedOffBy: 'auto'`) signs the plan off; sign-off itself is a human-only write, not exposed through this MCP surface — direct the operator to the dashboard's plan page. Pass `status` to filter. `session` is your own registration (`{sessionId, title, planId, planName, firstSeenAt, lastActiveAt}`) or null outside a Claude Code session; `planId: null` means connected to no plan — `plan_get` to browse, `plan_connect` to bind. `sessionListenerAttached` says whether your event stream is attached (the plugin's plan bridge); false for a few seconds right after connect is normal, false after that means events aren't reaching you — tell the operator.", {
|
|
883
883
|
status: z
|
|
884
884
|
.enum(PLAN_STATUSES)
|
|
885
885
|
.optional()
|
|
@@ -891,7 +891,7 @@ strictTool("plan_get",
|
|
|
891
891
|
// TREE against the plan resource — the SAME `fieldTreeSchema` `issue_get`/
|
|
892
892
|
// `issue_list` already take (`./field-tree.ts`). Every former query param
|
|
893
893
|
// now lives INSIDE the tree as a relation argument.
|
|
894
|
-
"Read a plan. Pass `plan_id` to read ANY plan; OMIT to read the plan this session is connected to — omitting while connected to none fails loud `{error: \"session_not_connected\"}`, connect first. A BARE call (no `fields`) returns ONLY the plan's cheap scalars: `{id, ref, name, created_at, signed_off_at, signed_off_by, auto_sign_off, boards, card_count, bucket_counts, status, session, sessionListenerAttached}` — no cards, records, or architecture body. `ref` is the plan's short reference (`
|
|
894
|
+
"Read a plan. Pass `plan_id` to read ANY plan; OMIT to read the plan this session is connected to — omitting while connected to none fails loud `{error: \"session_not_connected\"}`, connect first. A BARE call (no `fields`) returns ONLY the plan's cheap scalars: `{id, ref, name, created_at, signed_off_at, signed_off_by, auto_sign_off, boards, card_count, bucket_counts, status, session, sessionListenerAttached}` — no cards, records, or architecture body. `ref` is the plan's short reference (`PLAN-<id>`) — cite that, not the bare id. `status` is computed fresh on every read, never stored — see `plan_list` for the four values and what each means. Pass a `fields` tree to opt into: `cards` (member cards — the ISSUE resource itself, so any of `issue_get`'s own fields/relations may be nested under it, e.g. `{\"cards\": {\"title\": true, \"status\": true}}`; cursor-paged via `{\"limit\": N, \"before\": \"<cursor>\"}`, response carries a sibling `cards_page: {limit, total, next_cursor}`), `records` (every goal+rule+caveat) or `{\"records\": {\"kind\": \"goal\"}}` / `{\"kind\": [\"goal\",\"rule\"]}` (narrow to one or more kinds, cheaper), `architecture_sections` (`[{id, plan_id, content_hash, title, content, sort_order, created_at, updated_at}]`), `sessions` (every session connected to the plan), `notes` (the latest milestone-timeline page), `events` (the plan's durable event ledger — every human action and bridge message; cursor-paged via `{\"limit\": N, \"before\": \"<cursor>\"}` plus the filter args `{\"kinds\": [...], \"origin\": \"...\", \"writer\": \"...\"}`; response carries `events` rows plus a sibling `events_page: {limit, total, next_cursor}`; `next_cursor` null on the last page; an event on a card whose board you cannot read is left out, plan-level events are always visible). Call `resource_fields({resource:\"plan\"})` for the full, current list of what a tree may name — never guess a name; unknown → 400 `unknown_field`. `session`/`sessionListenerAttached` ride every response regardless (not part of the tree — they describe YOUR session, not the plan). `sessionListenerAttached` is `false` for a few seconds right after `plan_connect` while the plugin's event bridge starts; still `false` after that while connected means the plan's card events are not reaching you — tell the operator. ALWAYS `plan_get`/`plan_get_architecture_section` immediately before `plan_update_architecture_section`/`plan_delete_architecture_section` and pass the returned `content_hash`/`contentHash` back as `base_hash`.", {
|
|
895
895
|
plan_id: z
|
|
896
896
|
.number()
|
|
897
897
|
.int()
|
|
@@ -902,7 +902,7 @@ strictTool("plan_get",
|
|
|
902
902
|
.optional()
|
|
903
903
|
.describe("A field tree (see tool description); absent/empty = cheap scalars only. `resource_fields({resource:\"plan\"})` names every valid key."),
|
|
904
904
|
}, async (args) => jsonResult(await planGet(client, args)));
|
|
905
|
-
strictTool("plan_create", "Create a new, empty plan. Global — not board-scoped. Adds no cards, records, or architecture sections, and does not connect any session (call `plan_connect` separately). Returns `{plan: {id, ref, name, createdAt}}` — `ref` is the short reference (`
|
|
905
|
+
strictTool("plan_create", "Create a new, empty plan. Global — not board-scoped. Adds no cards, records, or architecture sections, and does not connect any session (call `plan_connect` separately). Returns `{plan: {id, ref, name, createdAt}}` — `ref` is the short reference (`PLAN-<id>`). Use `plan.id` with `plan_connect` to start working on it, or `plan_get({plan_id})` to browse.", {
|
|
906
906
|
name: z.string().min(1).describe("The plan's name — shown in the Plans list."),
|
|
907
907
|
}, async (args) => jsonResult(await planCreate(client, args)));
|
|
908
908
|
strictTool("plan_connect",
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-3597 — this package's half of the MCP-version-floor guard whose
|
|
3
|
+
* server-side half shipped in DX-3525 (`src/mcp-contract.ts`,
|
|
4
|
+
* `src/dashboard/serve-request.ts`, `src/resources/field-tree.ts`, all in
|
|
5
|
+
* the main danxbot repo — this package cannot import them, see below).
|
|
6
|
+
*
|
|
7
|
+
* The dashboard stamps `X-Danx-Mcp-Min-Version` on every routed `/api/*`
|
|
8
|
+
* response with its currently-advertised floor (`MIN_MCP_VERSION` in
|
|
9
|
+
* `src/mcp-contract.ts` — DX-3597 made that floor track the version where
|
|
10
|
+
* an MCP-exposed contract last changed, not "whatever's currently
|
|
11
|
+
* published"; see that file's module doc for the full decision).
|
|
12
|
+
*
|
|
13
|
+
* `DashboardHttpClient.request()` (`./http-client.ts`) reads this header on
|
|
14
|
+
* the FIRST call this process makes (regardless of outcome — a proactive
|
|
15
|
+
* check, since a stale client can otherwise appear to work for a while on
|
|
16
|
+
* routes whose shape didn't change) and on every subsequent 4xx (a response
|
|
17
|
+
* that might otherwise read as an ordinary caller error, when it's really
|
|
18
|
+
* "you're running an old MCP"). When this package's own version is behind
|
|
19
|
+
* the advertised floor, it throws `McpOutdatedError` instead of returning
|
|
20
|
+
* the ordinary envelope — the same "throw rather than fabricate/silently
|
|
21
|
+
* pass through" contract `http-client.ts` already applies to a 5xx/network
|
|
22
|
+
* failure (see that file's own doc): an outdated MCP is exactly that class
|
|
23
|
+
* of failure, nothing the calling tool can meaningfully work around.
|
|
24
|
+
*
|
|
25
|
+
* Everything here is duplicated, not imported, from the server's
|
|
26
|
+
* `src/mcp-contract.ts` — this package is an INDEPENDENTLY PUBLISHED npm
|
|
27
|
+
* artifact (DX-2103) and cannot depend on the danxbot service's own `src/`
|
|
28
|
+
* tree. Keep the header name, the package name, and the version-compare
|
|
29
|
+
* behavior in agreement with that file by hand when either changes.
|
|
30
|
+
*/
|
|
31
|
+
import { readFileSync } from "node:fs";
|
|
32
|
+
import { dirname, join } from "node:path";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
/** Must match `MCP_MIN_VERSION_HEADER` in danxbot's `src/mcp-contract.ts` verbatim. */
|
|
35
|
+
export const MCP_MIN_VERSION_HEADER = "X-Danx-Mcp-Min-Version";
|
|
36
|
+
/** Must match `MCP_PACKAGE_NAME` in danxbot's `src/mcp-contract.ts` verbatim. */
|
|
37
|
+
export const MCP_PACKAGE_NAME = "@thehammer/danx-dashboard-mcp";
|
|
38
|
+
/**
|
|
39
|
+
* Numeric-dotted version compare (major.minor.patch, ...) — mirrors
|
|
40
|
+
* `compareMcpVersions` in danxbot's `src/mcp-contract.ts` behavior
|
|
41
|
+
* byte-for-byte (duplicated, not imported — see module doc). Both sides of
|
|
42
|
+
* this contract have only ever used plain numeric dotted versions, so this
|
|
43
|
+
* throws rather than silently falling back to a lexical compare, which
|
|
44
|
+
* would misorder "0.1.9" vs "0.1.10".
|
|
45
|
+
*/
|
|
46
|
+
export function compareMcpVersions(a, b) {
|
|
47
|
+
const parse = (v) => {
|
|
48
|
+
if (!/^\d+(\.\d+)*$/.test(v)) {
|
|
49
|
+
throw new Error(`[danx-dashboard-mcp] "${v}" is not a plain numeric dotted version (major.minor.patch)`);
|
|
50
|
+
}
|
|
51
|
+
return v.split(".").map(Number);
|
|
52
|
+
};
|
|
53
|
+
const pa = parse(a);
|
|
54
|
+
const pb = parse(b);
|
|
55
|
+
const len = Math.max(pa.length, pb.length);
|
|
56
|
+
for (let i = 0; i < len; i++) {
|
|
57
|
+
const da = pa[i] ?? 0;
|
|
58
|
+
const db = pb[i] ?? 0;
|
|
59
|
+
if (da !== db)
|
|
60
|
+
return da - db;
|
|
61
|
+
}
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
let cachedOwnVersion;
|
|
65
|
+
/**
|
|
66
|
+
* Reads this package's OWN version from its `package.json` — always present
|
|
67
|
+
* next to `dist/` (or `src/`, under `npm run dev`'s `tsx`) at runtime even
|
|
68
|
+
* though `files: ["dist","README.md"]` excludes it from the tarball
|
|
69
|
+
* MANIFEST: npm always includes `package.json` in what it publishes and
|
|
70
|
+
* what it installs, regardless of `files` (the same guarantee `README` /
|
|
71
|
+
* `LICENSE` / the `main` entry get) — this reads the copy npm actually
|
|
72
|
+
* installed next to this module, not danxbot's own source tree. Both
|
|
73
|
+
* `dist/version-floor.js` and `src/version-floor.ts` sit exactly one
|
|
74
|
+
* directory below the package root, so `../package.json` resolves
|
|
75
|
+
* correctly under both the published (dist) and dev (`tsx src/index.ts`)
|
|
76
|
+
* shapes. Memoized — the version cannot change mid-process.
|
|
77
|
+
*/
|
|
78
|
+
export function readOwnVersion() {
|
|
79
|
+
if (cachedOwnVersion !== undefined)
|
|
80
|
+
return cachedOwnVersion;
|
|
81
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
82
|
+
const pkgJsonPath = join(here, "..", "package.json");
|
|
83
|
+
const raw = readFileSync(pkgJsonPath, "utf8");
|
|
84
|
+
const parsed = JSON.parse(raw);
|
|
85
|
+
if (typeof parsed.version !== "string") {
|
|
86
|
+
throw new Error(`[danx-dashboard-mcp] could not read this package's own version from ${pkgJsonPath}`);
|
|
87
|
+
}
|
|
88
|
+
cachedOwnVersion = parsed.version;
|
|
89
|
+
return cachedOwnVersion;
|
|
90
|
+
}
|
|
91
|
+
/** Test-only escape hatch — real callers never need to override the memoized read. */
|
|
92
|
+
export function __resetOwnVersionCacheForTests() {
|
|
93
|
+
cachedOwnVersion = undefined;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Thrown by `assertVersionFloorSatisfied` when this package is running
|
|
97
|
+
* behind the server's advertised floor. Carries structured `have`/`need`
|
|
98
|
+
* fields (not just a prose message) so a caller that wants to branch on
|
|
99
|
+
* "is this specifically an outdated-MCP failure" can check `error.code`
|
|
100
|
+
* rather than pattern-match the message text.
|
|
101
|
+
*/
|
|
102
|
+
export class McpOutdatedError extends Error {
|
|
103
|
+
code = "mcp_outdated";
|
|
104
|
+
have;
|
|
105
|
+
need;
|
|
106
|
+
constructor(have, need) {
|
|
107
|
+
super(`[danx-dashboard-mcp] mcp_outdated: this MCP is running ${MCP_PACKAGE_NAME}@${have}, but the dashboard ` +
|
|
108
|
+
`requires >= ${need}. Restart your Claude Code session to pick up the published update.`);
|
|
109
|
+
this.name = "McpOutdatedError";
|
|
110
|
+
this.have = have;
|
|
111
|
+
this.need = need;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Compares this package's own version against the floor a response header
|
|
116
|
+
* advertised. No-ops (returns) when compatible, or when `headerValue` is
|
|
117
|
+
* `null`/empty (an old-enough server, or a test stub, that never stamped
|
|
118
|
+
* one — nothing to compare against). Throws `McpOutdatedError` when this
|
|
119
|
+
* package is behind.
|
|
120
|
+
*/
|
|
121
|
+
export function assertVersionFloorSatisfied(headerValue) {
|
|
122
|
+
if (!headerValue)
|
|
123
|
+
return;
|
|
124
|
+
const have = readOwnVersion();
|
|
125
|
+
if (compareMcpVersions(have, headerValue) < 0) {
|
|
126
|
+
throw new McpOutdatedError(have, headerValue);
|
|
127
|
+
}
|
|
128
|
+
}
|
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.162",
|
|
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",
|