@thehammer/danx-dashboard-mcp 0.1.212 → 0.1.214

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.
@@ -1,38 +1,5 @@
1
+ import { isBoardScopedRoute } from "./route-board-scope.js";
1
2
  import { assertVersionFloorSatisfied, MCP_MIN_VERSION_HEADER } from "./version-floor.js";
2
- /**
3
- * DX-3750 — route-path prefixes (`<basePath><path>`, BEFORE any query
4
- * string) that are NEVER board-scoped: the dashboard route serving them
5
- * takes no `?board=` at all — either because the resource is GLOBAL
6
- * (plans — see `plan_list`'s own doc: "Plans are GLOBAL, not board-scoped")
7
- * or because it scopes some other way a `board` param would be meaningless
8
- * for (dispatches scope by `repo`; a board resolves from a PATH segment on
9
- * every route but its own list, which itself scopes by `repo`; `/api/routes`
10
- * and `/api/openapi` are cross-board discovery). Server-side, each of these
11
- * families' LIST route validates its query params with its own
12
- * `rejectUnknownParams(params, TOP_LEVEL_..._PARAM_KEYS)` allowlist
13
- * (`src/issues/plans-routes.ts`, `src/dashboard/dispatches-routes.ts`,
14
- * `src/issues/boards-routes.ts`) — none of those allowlists names `board`
15
- * (DX-3725), so stamping it 400s. THIS is the one list; `isBoardScopedRoute`
16
- * is the one function that reads it — no second, independently-drifting
17
- * copy anywhere else in this package.
18
- *
19
- * Deliberately prefix-matched, not exact-matched: every sub-route of a
20
- * non-board-scoped family (`/api/plans/:id/cards`, `/api/boards/:id/export`,
21
- * `/api/dispatches/:id/logs`, …) is equally never board-scoped — a plan, a
22
- * dispatch and a board-export all resolve their target from a PATH segment
23
- * or a `repo`/global scope, never from `?board=`.
24
- */
25
- const NON_BOARD_SCOPED_PATH_PREFIXES = [
26
- "/api/plans",
27
- "/api/dispatches",
28
- "/api/boards",
29
- "/api/routes",
30
- "/api/openapi",
31
- ];
32
- /** DX-3750 — does `routePath` (`<basePath><path>`, no query string) take a `?board=` at all? See `NON_BOARD_SCOPED_PATH_PREFIXES`'s own doc. */
33
- export function isBoardScopedRoute(routePath) {
34
- return !NON_BOARD_SCOPED_PATH_PREFIXES.some((prefix) => routePath === prefix || routePath.startsWith(`${prefix}/`));
35
- }
36
3
  export class DashboardHttpClient {
37
4
  config;
38
5
  fetchImpl;
@@ -44,7 +11,7 @@ export class DashboardHttpClient {
44
11
  this.fetchImpl = fetchImpl;
45
12
  }
46
13
  async request(args) {
47
- const url = this.buildUrl(args.path, args.query, args.board, args.basePath);
14
+ const url = this.buildUrl(args.method, args.path, args.query, args.board, args.basePath);
48
15
  const headers = {
49
16
  Authorization: `Bearer ${this.config.token}`,
50
17
  Accept: "application/json",
@@ -120,7 +87,7 @@ export class DashboardHttpClient {
120
87
  }
121
88
  return { ok: false, status: res.status, body: parsed };
122
89
  }
123
- buildUrl(path, extraQuery, boardOverride, basePath = "/api/issues") {
90
+ buildUrl(method, path, extraQuery, boardOverride, basePath = "/api/issues") {
124
91
  const base = this.config.baseUrl.replace(/\/+$/, "");
125
92
  const [rawPath, existingQs] = path.split("?", 2);
126
93
  let cleanPath;
@@ -131,10 +98,8 @@ export class DashboardHttpClient {
131
98
  cleanPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
132
99
  }
133
100
  const params = new URLSearchParams(existingQs ?? "");
134
- // DX-3750 — only a board-scoped route takes `?board=` at all; see
135
- // `isBoardScopedRoute`'s own doc for why an unconditional stamp 400s
136
- // plans/dispatches/boards-list through `danxbot_api`.
137
- if (isBoardScopedRoute(`${basePath}${cleanPath}`)) {
101
+ // DX-3750 / DX-4303 — only a route the dashboard takes `?board=` on gets the stamp; see `route-board-scope.ts`.
102
+ if (isBoardScopedRoute(method, `${basePath}${cleanPath}`)) {
138
103
  params.set("board", boardOverride ?? this.config.board);
139
104
  }
140
105
  if (extraQuery) {
@@ -0,0 +1,49 @@
1
+ /**
2
+ * GENERATED by src/dashboard/mcp-board-scope.ts (DX-4303) - do not edit by hand.
3
+ * Regenerate: DANX_REGEN_MCP_BOARD_SCOPE=1 npx vitest run src/dashboard/mcp-board-scope.test.ts
4
+ *
5
+ * The GET /api/* route templates the client must know to stamp `?board=` correctly, and whether each takes it. The server refuses an
6
+ * unknown key on a list read whose vocabulary omits `board` and on a route declaring `strictQuery`; a path no template names is stamped.
7
+ */
8
+ export const GET_ROUTE_BOARD_SCOPE = [
9
+ ["/api/account/sessions", false],
10
+ ["/api/audit-events", false],
11
+ ["/api/boards", false],
12
+ ["/api/boards/:boardId", false],
13
+ ["/api/catalog/:catalogId", false],
14
+ ["/api/dispatches", false],
15
+ ["/api/dispatches/:dispatchId", true],
16
+ ["/api/dispatches/:dispatchId/logs", false],
17
+ ["/api/dispatches/:dispatchId/usage-events", false],
18
+ ["/api/dispatches/dead-letter-ids", false],
19
+ ["/api/docs/:repo/changes", false],
20
+ ["/api/failure-categories", false],
21
+ ["/api/failure-categories/ambiguous", false],
22
+ ["/api/failure-categories/uncategorized", false],
23
+ ["/api/failure-signatures", false],
24
+ ["/api/issues/:id/problems", false],
25
+ ["/api/issues/:id/usage-runs", false],
26
+ ["/api/issues/:id/usage-series", false],
27
+ ["/api/issues/:issueId/dispatches", false],
28
+ ["/api/notifications", false],
29
+ ["/api/plan-sessions", false],
30
+ ["/api/plans", false],
31
+ ["/api/plans/:planId/architecture/sections", false],
32
+ ["/api/plans/:planId/cards", false],
33
+ ["/api/plans/:planId/notes", false],
34
+ ["/api/plans/:planId/records", false],
35
+ ["/api/plans/:planId/usage-series", false],
36
+ ["/api/plans/mine/architecture/sections", false],
37
+ ["/api/platform/signup-codes", false],
38
+ ["/api/platform/teams", false],
39
+ ["/api/reminders", false],
40
+ ["/api/repos", false],
41
+ ["/api/self-repair/errors", false],
42
+ ["/api/sync-root", false],
43
+ ["/api/tasks", false],
44
+ ["/api/team/invitations", false],
45
+ ["/api/team/members", false],
46
+ ["/api/team/roles", false],
47
+ ["/api/users", false],
48
+ ["/api/workers", false],
49
+ ];
@@ -0,0 +1,51 @@
1
+ /**
2
+ * DX-4303 - does the dashboard route a request reaches take a `?board=` query key?
3
+ *
4
+ * The answer comes from the dashboard's own route table, shipped in this package as `route-board-scope.generated.ts` (generated by
5
+ * the server's `src/dashboard/mcp-board-scope.ts`; a root test fails when it drifts). This file only matches a concrete request
6
+ * path against those templates, the way the server's `RouteTable` does: segments compared left to right, a literal beating a
7
+ * `:param` beating a trailing `*`. It holds no route knowledge of its own.
8
+ *
9
+ * Only GET routes are listed - the server refuses an unknown query key on a read alone - so every other method takes the key. A
10
+ * GET path no template matches takes it too: the server answers it with a 404 whatever the query.
11
+ */
12
+ import { GET_ROUTE_BOARD_SCOPE } from "./route-board-scope.generated.js";
13
+ const RANK = { literal: 2, param: 1, rest: 0 };
14
+ function compile(template, acceptsBoard) {
15
+ const segments = template
16
+ .slice(1)
17
+ .split("/")
18
+ .map((s) => (s === "*" ? { kind: "rest" } : s.startsWith(":") ? { kind: "param" } : { kind: "literal", value: s }));
19
+ return { segments, rank: segments.map((s) => RANK[s.kind]), acceptsBoard };
20
+ }
21
+ function compareRank(a, b) {
22
+ const n = Math.max(a.length, b.length);
23
+ for (let i = 0; i < n; i++) {
24
+ const x = a[i] ?? -1;
25
+ const y = b[i] ?? -1;
26
+ if (x !== y)
27
+ return y - x;
28
+ }
29
+ return 0;
30
+ }
31
+ const ROUTES = GET_ROUTE_BOARD_SCOPE.map(([template, accepts]) => compile(template, accepts)).sort((a, b) => compareRank(a.rank, b.rank));
32
+ function matches(route, parts) {
33
+ for (let i = 0; i < route.segments.length; i++) {
34
+ const seg = route.segments[i];
35
+ if (seg.kind === "rest")
36
+ return true;
37
+ const part = parts[i];
38
+ if (part === undefined)
39
+ return false;
40
+ if (seg.kind === "literal" ? part !== seg.value : part === "")
41
+ return false;
42
+ }
43
+ return parts.length === route.segments.length;
44
+ }
45
+ /** Does the dashboard route `<method> <routePath>` (no query string) take a `?board=` query key? */
46
+ export function isBoardScopedRoute(method, routePath) {
47
+ if (method !== "GET")
48
+ return true;
49
+ const parts = routePath.slice(1).split("/");
50
+ return ROUTES.find((route) => matches(route, parts))?.acceptsBoard ?? true;
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.212",
3
+ "version": "0.1.214",
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",