@thehammer/danx-dashboard-mcp 0.1.215 → 0.1.217

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,65 +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 list and `NON_BOARD_SCOPED_PATH_PATTERNS` below are the only
16
- * classification; `isBoardScopedRoute` is the one function that reads them — 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
- // DX-4261: install- or team-global families, whose list reads declare no `board` (the engine 400s an unknown key).
32
- "/api/team",
33
- "/api/users",
34
- "/api/account",
35
- "/api/platform",
36
- "/api/failure-categories",
37
- "/api/failure-signatures",
38
- "/api/notifications",
39
- "/api/self-repair",
40
- "/api/audit-events",
41
- "/api/docs",
42
- "/api/repos",
43
- "/api/sync-root",
44
- "/api/workers",
45
- "/api/tasks",
46
- "/api/reminders",
47
- ];
48
- /**
49
- * DX-4261: non-board-scoped routes a prefix cannot express, because their family root (`/api/issues`, `/api/catalog`) is board-scoped.
50
- * `/api/agents` stays stamped: `?board=` there selects the roster read (DX-4220).
51
- */
52
- const NON_BOARD_SCOPED_PATH_PATTERNS = [
53
- /^\/api\/issues\/[^/]+\/(dispatches|problems)$/,
54
- /^\/api\/catalog\/[^/]+$/,
55
- // The LIST read only: `/api/plan-sessions/me/plan` reads the stamped board to pin the operator dispatch (DX-2954).
56
- /^\/api\/plan-sessions$/,
57
- ];
58
- /** DX-3750 — does `routePath` (`<basePath><path>`, no query string) take a `?board=` at all? See `NON_BOARD_SCOPED_PATH_PREFIXES`'s own doc. */
59
- export function isBoardScopedRoute(routePath) {
60
- return !(NON_BOARD_SCOPED_PATH_PREFIXES.some((prefix) => routePath === prefix || routePath.startsWith(`${prefix}/`)) ||
61
- NON_BOARD_SCOPED_PATH_PATTERNS.some((pattern) => pattern.test(routePath)));
62
- }
63
3
  export class DashboardHttpClient {
64
4
  config;
65
5
  fetchImpl;
@@ -71,7 +11,7 @@ export class DashboardHttpClient {
71
11
  this.fetchImpl = fetchImpl;
72
12
  }
73
13
  async request(args) {
74
- 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);
75
15
  const headers = {
76
16
  Authorization: `Bearer ${this.config.token}`,
77
17
  Accept: "application/json",
@@ -147,7 +87,7 @@ export class DashboardHttpClient {
147
87
  }
148
88
  return { ok: false, status: res.status, body: parsed };
149
89
  }
150
- buildUrl(path, extraQuery, boardOverride, basePath = "/api/issues") {
90
+ buildUrl(method, path, extraQuery, boardOverride, basePath = "/api/issues") {
151
91
  const base = this.config.baseUrl.replace(/\/+$/, "");
152
92
  const [rawPath, existingQs] = path.split("?", 2);
153
93
  let cleanPath;
@@ -158,10 +98,8 @@ export class DashboardHttpClient {
158
98
  cleanPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
159
99
  }
160
100
  const params = new URLSearchParams(existingQs ?? "");
161
- // DX-3750 — only a board-scoped route takes `?board=` at all; see
162
- // `isBoardScopedRoute`'s own doc for why an unconditional stamp 400s
163
- // plans/dispatches/boards-list through `danxbot_api`.
164
- 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}`)) {
165
103
  params.set("board", boardOverride ?? this.config.board);
166
104
  }
167
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
+ }
@@ -1,7 +1,9 @@
1
1
  const TERMS_ACCEPTANCE_PATH = "/api/account/terms-acceptance";
2
2
  // Mirrors `termsAcceptUrl("/")` in src/lib/terms-accept-path.ts (a published package cannot import it);
3
3
  // terms-gate.integration.test.ts pins the server's link to the same literal. Only meaningful for an
4
- // operator session: a dispatch's DANXBOT_DASHBOARD_URL is internal, and a dispatch's own agent credential is never terms-gated.
4
+ // operator session: a dispatch's DANXBOT_DASHBOARD_URL is internal, and a dispatch's own key is its
5
+ // owner's API key (DX-4191), so a dispatched agent can meet this refusal too and hands the link to the
6
+ // owner's operator.
5
7
  const ACCEPT_SCREEN_PATH = "/terms/accept?return=%2F";
6
8
  /** Keep in step with `termsRefusalInstruction` in src/dashboard/route-policy.ts. */
7
9
  export function haltText(acceptUrl, owner) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.215",
3
+ "version": "0.1.217",
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",