@thehammer/danx-dashboard-mcp 0.1.249 → 0.1.250
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 +11 -1
- package/dist/bounded-retry.js +25 -0
- package/dist/index.js +108 -13
- package/dist/lifecycle.js +29 -81
- package/dist/permission-watch.js +143 -0
- package/dist/session-access.js +4 -0
- package/dist/tool-access.js +87 -0
- package/dist/tool-surface.js +61 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,6 +30,16 @@ A call the dashboard refuses `403 {error: "forbidden", permission}` means the ke
|
|
|
30
30
|
|
|
31
31
|
The hint: every dashboard result that is `403 {error: "forbidden", permission}` (and only that) gains one `reminders[]` entry naming the permission, `request_permission`, and saying to raise a problem on the card if blocked (`src/forbidden-hint.ts`, applied once in `guardedCall` in `src/index.ts`).
|
|
32
32
|
|
|
33
|
+
### Which tools a session sees (DX-4523)
|
|
34
|
+
|
|
35
|
+
A session is shown only the tools it can call. `src/tool-access.ts` declares, for every tool, what makes it usable, and its one function `toolsFor({permissions, callbacks})` maps what a session holds to the tool names it advertises. `src/tool-surface.ts` applies it with the SDK's `tool.enable()` / `disable()`, so `notifications/tools/list_changed` is sent and Claude Code refetches; nothing else decides visibility, and the danxbot service's budgets (`src/tokens/mcp-surface.ts`) read the same function.
|
|
36
|
+
|
|
37
|
+
- **Bootstrap set**, for every session and the whole of a signed-out one: `plan_connect`, `request_permission` and the `danxbot_api` trio.
|
|
38
|
+
- **Permission tools** (`attach_file` needs `cards.edit`): shown once the key holds the permission the tool's dashboard route needs.
|
|
39
|
+
- **Callback tools**: shown when the worker injected the tool's per-dispatch URL (`DANXBOT_*_URL`). The six that post to dashboard routes (`danxbot_slack_*`, `link_thread_to_issue`, `slack_recovery_*`) also need the route's permission; the six that post to a worker route (`danxbot_complete`, `quality_gate_complete`, `request_code_review`, `danxbot_set_evaluator_summary`, `save_template_app`, `load_template_app`) authenticate with the dispatch's agent token and need only the URL.
|
|
40
|
+
- **When it is applied.** At boot, as a session with no key; then, if the session holds a key (a dispatch's env credential, or a stored session key), right away from `GET /api/auth/me`; a dispatch retries that read three times and fails the boot rather than start without its tools. After `plan_connect` signs a session in. And again when a person approves a `request_permission` grant: the dashboard ends the key's open `/api/stream` with `event: end {reason: "permissions_changed"}`, this process holds one stream open on the key-self topic `key:self` (`src/permission-watch.ts`; any key may subscribe with no permission, because a dispatch's agent role holds no read-floor key), and after EVERY (re)connect re-reads the key's permissions (a failed read is retried with bounded backoff) and enables what is newly allowed in ONE `list_changed`. It reconnects with backoff after a drop, and stops when stdin closes. A grant that adds no tool, and a repeated read, switch nothing and send no notification, because a changed tool list costs the session its prompt cache.
|
|
41
|
+
- A stream the dashboard refuses (any 4xx) is logged and ends the watch; that session's grants then show their tools at its next `plan_connect`. A permission read the dashboard refuses (401/403, other than the Terms halt) fails a dispatch's boot rather than quietly narrowing its tools.
|
|
42
|
+
|
|
33
43
|
## Tool surface (DX-3465 — one generic tool, not one per operation)
|
|
34
44
|
|
|
35
45
|
All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `mcp.template.json`. There is no per-operation tool any more (no `issue_get`, `issue_create`, `plan_add_note`, …) — three tools cover the whole dashboard API (a fourth, `request_permission`, asks for more access):
|
|
@@ -39,7 +49,7 @@ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `m
|
|
|
39
49
|
| `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. |
|
|
40
50
|
| `danxbot_api_list` | No args. Lists the routes this caller's credential may reach as `{routes: ["METHOD /path"], external: [{route, served_by}]}` (one bare string per route, exactly what `danxbot_api_spec` takes) — call this first when you don't know the route for what you need to do. |
|
|
41
51
|
| `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. |
|
|
42
|
-
| `attach_file` | `{card_id, path, replaces?, primary?, filename?, content_type?}` — the server reads the LOCAL file at `path` (absolute, `/tmp/a.png` or `C:\x\a.png`) and uploads it, so its bytes never pass through the agent's context. `replaces: <attachment id>` swaps that attachment's file in place (`PUT /api/issues/:id/attachments/:aid`: same id, name and cover flag) instead of adding one; `primary: true` makes it the card's single cover, atomically with the write. Reply: `{issue: <compact row>, attachment_id, url, thumbnail_url, is_primary}`.
|
|
52
|
+
| `attach_file` | `{card_id, path, replaces?, primary?, filename?, content_type?}` — the server reads the LOCAL file at `path` (absolute, `/tmp/a.png` or `C:\x\a.png`) and uploads it, so its bytes never pass through the agent's context. `replaces: <attachment id>` swaps that attachment's file in place (`PUT /api/issues/:id/attachments/:aid`: same id, name and cover flag) instead of adding one; `primary: true` makes it the card's single cover, atomically with the write. Reply: `{issue: <compact row>, attachment_id, url, thumbnail_url, is_primary}`. Shown to a key holding `cards.edit` (DX-4523); a dispatch profile still receives it only when its catalog selection names it (`slack-agent` and `work`, DX-4386, through `extraTools`). A screenshot of browser-visible work: first `primary: true`, then `replaces` at each milestone. |
|
|
43
53
|
| `request_permission` | `{permissions, reason}` — asks a person to grant this key ONE MORE permission (see "Asking for one more permission" above). Answers at once with the approval URL and confirm code; the grant is picked up in the background. |
|
|
44
54
|
|
|
45
55
|
There is no dedicated `resource_fields` tool (removed DX-3465 — it was a thin wrapper `danxbot_api` already reaches with no agent-specific behavior earning it its own tool). What an opt-in field TREE may name for one resource (`{resource, description, always, hashes, fields, relations}`) is `danxbot_api({method:"GET", path:"/api/resources/<resource>/fields"})` — install-global, no `board`; `resource: "issue"` is the root, and a relation's own `resource` in the response is what to call this again with, one level deeper.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4523 - the one bounded retry the tool-access sync uses, at boot (`index.ts`) and on every permission-stream connect
|
|
3
|
+
* (`permission-watch.ts`): a few attempts with a growing wait, insurance against a dashboard blip, never the primary mechanism.
|
|
4
|
+
*/
|
|
5
|
+
export const SYNC_ATTEMPTS = 3;
|
|
6
|
+
export const SYNC_RETRY_BACKOFF_MS = 500;
|
|
7
|
+
/**
|
|
8
|
+
* Run `run` up to `attempts` times, waiting `backoffMs * attempt` between tries. The last failure is rethrown for the caller to
|
|
9
|
+
* decide how loud it is. `shouldStop` ends the loop quietly (the watch was stopped).
|
|
10
|
+
*/
|
|
11
|
+
export async function retryBounded(run, opts) {
|
|
12
|
+
const backoffMs = opts.backoffMs ?? SYNC_RETRY_BACKOFF_MS;
|
|
13
|
+
for (let attempt = 1;; attempt++) {
|
|
14
|
+
if (opts.shouldStop?.())
|
|
15
|
+
return undefined;
|
|
16
|
+
try {
|
|
17
|
+
return await run();
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
if (attempt >= opts.attempts)
|
|
21
|
+
throw err;
|
|
22
|
+
await opts.sleep(backoffMs * attempt);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -108,7 +108,11 @@ import { bodyValueSchema, queryValueSchema } from "./types.js";
|
|
|
108
108
|
import { termsAcceptanceCallHalt, termsHaltFor } from "./terms-halt.js";
|
|
109
109
|
import { danxbotApiExamplesText } from "./danxbot-api-examples.js";
|
|
110
110
|
import { assertWithinTextLimit } from "./text-limit.js";
|
|
111
|
-
import { registerLifecycleTools,
|
|
111
|
+
import { registerLifecycleTools, presentCallbacks, readLifecycleUrlsFromEnv, } from "./lifecycle.js";
|
|
112
|
+
import { TOOL_ACCESS } from "./tool-access.js";
|
|
113
|
+
import { ToolAccessSync, ToolSurface } from "./tool-surface.js";
|
|
114
|
+
import { PermissionWatch } from "./permission-watch.js";
|
|
115
|
+
import { SYNC_ATTEMPTS, retryBounded } from "./bounded-retry.js";
|
|
112
116
|
function readEnvOrDie(name) {
|
|
113
117
|
const v = process.env[name];
|
|
114
118
|
if (typeof v !== "string" || v === "") {
|
|
@@ -160,6 +164,9 @@ let listenerReminder;
|
|
|
160
164
|
let access;
|
|
161
165
|
// DX-4434 — late-bound with `access` and `client`; see `permission-request.ts`.
|
|
162
166
|
let permissionRequests;
|
|
167
|
+
// DX-4523 - late-bound with `access` and `client`; see `tool-surface.ts` and `permission-watch.ts`.
|
|
168
|
+
let toolAccessSync;
|
|
169
|
+
let permissionWatch;
|
|
163
170
|
/**
|
|
164
171
|
* Compose the dispatch's qualified board id (`<repo>:<slug>`) from the two env
|
|
165
172
|
* halves the worker injects and build the HTTP client. DANXBOT_BOARD_NAME
|
|
@@ -207,15 +214,86 @@ function boot() {
|
|
|
207
214
|
now: Date.now,
|
|
208
215
|
logLine: (line) => console.error(line),
|
|
209
216
|
});
|
|
210
|
-
// DX-
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
217
|
+
// DX-4523 - a session sees only the tools it can call (`toolsFor`, `tool-access.ts`). Applied here (inside `boot()`,
|
|
218
|
+
// before `server.connect()`) as a session with no key: the bootstrap set plus the callback tools this dispatch's worker
|
|
219
|
+
// gave a URL for. `syncToolsAfterSignIn` widens it once the key's permissions are read. The DX-1606 tool-defs
|
|
220
|
+
// generator imports this module WITHOUT calling `boot()`, so it still sees every tool enabled.
|
|
221
|
+
const surface = new ToolSurface(registeredTools, presentCallbacks(readLifecycleUrlsFromEnv()), () => server.sendToolListChanged());
|
|
222
|
+
surface.apply(new Set());
|
|
223
|
+
toolAccessSync = new ToolAccessSync({
|
|
224
|
+
surface,
|
|
225
|
+
readPermissions: readHeldPermissions,
|
|
226
|
+
log: (line) => console.error(line),
|
|
227
|
+
});
|
|
228
|
+
permissionWatch = new PermissionWatch({
|
|
229
|
+
dashboardUrl,
|
|
230
|
+
token: () => access.currentToken(),
|
|
231
|
+
sync: () => toolAccessSync.sync(),
|
|
232
|
+
log: (line) => console.error(line),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* DX-4523 - this key's current permissions (`GET /api/auth/me`). A dashboard failure throws, a 401 or 403 included: a key the
|
|
237
|
+
* dashboard refuses holds nothing to advertise, and narrowing the tool list quietly would hide that.
|
|
238
|
+
*/
|
|
239
|
+
async function readKeyPermissions() {
|
|
240
|
+
if (!access.isSignedIn())
|
|
241
|
+
return { outcome: "no-key" };
|
|
242
|
+
const guarded = await access.guard(() => client.request({ method: "GET", basePath: "", path: "/api/auth/me" }));
|
|
243
|
+
if ("halt" in guarded)
|
|
244
|
+
return { outcome: "no-key" };
|
|
245
|
+
const { result } = guarded;
|
|
246
|
+
if (termsHaltFor(result) !== null)
|
|
247
|
+
return { outcome: "terms-pending" };
|
|
248
|
+
if (!result.ok) {
|
|
249
|
+
throw new Error(`GET /api/auth/me answered ${result.status}: ${JSON.stringify(result.body)}`);
|
|
250
|
+
}
|
|
251
|
+
const permissions = result.body?.permissions;
|
|
252
|
+
if (!Array.isArray(permissions) || !permissions.every((p) => typeof p === "string")) {
|
|
253
|
+
throw new Error(`GET /api/auth/me answered without a permissions list: ${JSON.stringify(result.body)}`);
|
|
254
|
+
}
|
|
255
|
+
return { outcome: "held", permissions: new Set(permissions) };
|
|
256
|
+
}
|
|
257
|
+
/** The tool surface's input: the key's permissions, or null for a session that holds none (bootstrap set only). */
|
|
258
|
+
async function readHeldPermissions() {
|
|
259
|
+
const key = await readKeyPermissions();
|
|
260
|
+
return key.outcome === "held" ? key.permissions : null;
|
|
218
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* How hard a failed permission read is for this session. A dispatch cannot work without its tools, so it retries a few times (a
|
|
264
|
+
* dashboard blip at spawn must not cost it its MCP server) and then fails the boot; an operator session logs it and keeps the
|
|
265
|
+
* bootstrap set until its next `plan_connect` or the permission stream's next connect.
|
|
266
|
+
*/
|
|
267
|
+
function syncPolicy() {
|
|
268
|
+
const isDispatch = access.connection()?.source.kind === "env";
|
|
269
|
+
return { attempts: isDispatch ? SYNC_ATTEMPTS : 1, fatal: isDispatch };
|
|
270
|
+
}
|
|
271
|
+
async function syncToolsWithPolicy() {
|
|
272
|
+
const { attempts, fatal } = syncPolicy();
|
|
273
|
+
try {
|
|
274
|
+
await retryBounded(() => toolAccessSync.sync(), { attempts, sleep: sleepHoldingProcess });
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
if (fatal)
|
|
278
|
+
throw err;
|
|
279
|
+
console.error(`[danx-dashboard-mcp] could not read this key's permissions, tools stay at the bootstrap set: ${err.message}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* DX-4523 - show the tools the key's permissions allow, and watch for a grant. Called at boot and again whenever a
|
|
284
|
+
* `plan_connect` finds the session signed in, which is the moment a first approval or a re-approved key takes effect.
|
|
285
|
+
*/
|
|
286
|
+
async function syncToolsAfterSignIn() {
|
|
287
|
+
if (!access.isSignedIn())
|
|
288
|
+
return;
|
|
289
|
+
await syncToolsWithPolicy();
|
|
290
|
+
permissionWatch.start();
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Before `server.connect` nothing reads stdin, so an unref'd timer (`defaultSleep`) would let the process exit silently with
|
|
294
|
+
* code 0 in the middle of the retry. This one keeps the process alive until it fires.
|
|
295
|
+
*/
|
|
296
|
+
const sleepHoldingProcess = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
219
297
|
/**
|
|
220
298
|
* DX-2862 / DX-4390 — the credential this server boots with, through the ONE resolver the
|
|
221
299
|
* `bridge` subcommand also uses, so a session's tools and its event stream can never sign in as
|
|
@@ -356,6 +434,8 @@ function jsonResult(value) {
|
|
|
356
434
|
function boundedJsonResult(result) {
|
|
357
435
|
return { content: [{ type: "text", text: boundedResultText(result) }] };
|
|
358
436
|
}
|
|
437
|
+
/** DX-4523 - every tool `strictTool` registered, by name: what `ToolSurface` switches. */
|
|
438
|
+
const registeredTools = new Map();
|
|
359
439
|
/**
|
|
360
440
|
* DX-3238 — every tool on this server MUST reject an undeclared/mistyped
|
|
361
441
|
* argument instead of silently stripping it. `server.tool(name, description,
|
|
@@ -380,15 +460,20 @@ function boundedJsonResult(result) {
|
|
|
380
460
|
function strictTool(name, description, shape, cb) {
|
|
381
461
|
// DX-4318 — the one registration path, so no tool can ship a description an agent would receive cut.
|
|
382
462
|
assertWithinTextLimit(`the "${name}" tool description`, description);
|
|
383
|
-
|
|
463
|
+
// DX-4523 - the same path, so no tool can ship without declaring what makes it usable.
|
|
464
|
+
if (!Object.hasOwn(TOOL_ACCESS, name)) {
|
|
465
|
+
throw new Error(`the "${name}" tool has no entry in TOOL_ACCESS (tool-access.ts): declare what makes it usable`);
|
|
466
|
+
}
|
|
467
|
+
const tool = server.registerTool(name, { description, inputSchema: z.object(shape).strict() }, cb);
|
|
468
|
+
registeredTools.set(name, tool);
|
|
469
|
+
return tool;
|
|
384
470
|
}
|
|
385
471
|
// ---------------- lifecycle tool set (DX-3465 / PLN-11 R-25) ----------------
|
|
386
472
|
// Merged from `src/mcp/danxbot-server.ts` — see `lifecycle.ts` for the full
|
|
387
473
|
// per-tool doc comments and the DX-3394 problem 1682 cleanup this merge
|
|
388
474
|
// folded in. `danxbot_complete` registers unconditionally-visible; the rest
|
|
389
|
-
// register here
|
|
390
|
-
|
|
391
|
-
const lifecycleTools = registerLifecycleTools(strictTool);
|
|
475
|
+
// register here; `toolsFor` (`tool-access.ts`) decides which a session sees.
|
|
476
|
+
registerLifecycleTools(strictTool);
|
|
392
477
|
// Optional per-call board override shared by every tool. Omitted → the
|
|
393
478
|
// dispatch's env-derived board (`<repo>:<slug>`) is used; provided → that
|
|
394
479
|
// board is targeted instead (the dashboard owns board→repo and 404s an
|
|
@@ -588,6 +673,8 @@ async (args) => {
|
|
|
588
673
|
if (!ready.signedIn)
|
|
589
674
|
return accessReply(ready);
|
|
590
675
|
if (args.plan_id === undefined) {
|
|
676
|
+
// DX-4523 - signed in (possibly just now): show the tools this key's permissions allow.
|
|
677
|
+
await syncToolsAfterSignIn();
|
|
591
678
|
return jsonResult({
|
|
592
679
|
state: "signed_in",
|
|
593
680
|
instruction: "This session is signed in. Call `plan_connect` with `plan_id` to connect it to a plan.",
|
|
@@ -602,6 +689,9 @@ async (args) => {
|
|
|
602
689
|
if (result.status === 401 && access.dropKey()) {
|
|
603
690
|
return accessReply(await access.ensureAccess(args.title), "the dashboard no longer accepted this session's key");
|
|
604
691
|
}
|
|
692
|
+
// DX-4523 - the key just worked: show the tools its permissions allow. After the refusals above, so a revoked or lapsed
|
|
693
|
+
// key is found by the connect itself and answered as before.
|
|
694
|
+
await syncToolsAfterSignIn();
|
|
605
695
|
// DX-3997 — a (re)connect restarts the bridge; the next `danxbot_api` must re-read health.
|
|
606
696
|
listenerReminder.invalidate();
|
|
607
697
|
// DX-2985 — a leave drops the local connection record (the bridge exits on `not_connected`; a later connect writes a fresh one).
|
|
@@ -626,6 +716,11 @@ async (args) => {
|
|
|
626
716
|
// ---------------- main ----------------
|
|
627
717
|
async function main() {
|
|
628
718
|
boot();
|
|
719
|
+
await syncToolsAfterSignIn();
|
|
720
|
+
// DX-4523 - the open permission stream would keep the process alive after the client goes away. The SDK's stdio transport
|
|
721
|
+
// does not watch stdin for its end (only a transport `close()` reaches `onclose`), so the end of stdin stops the watch too.
|
|
722
|
+
server.server.onclose = () => permissionWatch.stop();
|
|
723
|
+
process.stdin.once("end", () => permissionWatch.stop());
|
|
629
724
|
const transport = new StdioServerTransport();
|
|
630
725
|
await server.connect(transport);
|
|
631
726
|
console.error(`danx-dashboard-mcp running on stdio (dashboard=${config.baseUrl}, board=${config.board})`);
|
package/dist/lifecycle.js
CHANGED
|
@@ -11,12 +11,9 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Registered UNCONDITIONALLY at module load (so the DX-1606 tool-defs
|
|
13
13
|
* generator — which never calls `boot()` — still introspects every one of
|
|
14
|
-
* them for token-cost accounting)
|
|
15
|
-
* `
|
|
16
|
-
*
|
|
17
|
-
* `buildActiveTools`/`callTool` used to enforce in the old standalone
|
|
18
|
-
* server: a tool whose URL wasn't injected for this dispatch is not just
|
|
19
|
-
* inert, it is invisible to `tools/list`.
|
|
14
|
+
* them for token-cost accounting). Which of them a session SEES is decided by
|
|
15
|
+
* `toolsFor` (`tool-access.ts`) from the key's permissions and the callback
|
|
16
|
+
* URLs present (`presentCallbacks`), applied by `tool-surface.ts` (DX-4523).
|
|
20
17
|
*
|
|
21
18
|
* DX-3394 problem 1682 cleanup folded into this merge:
|
|
22
19
|
* - `danxbot_restart_worker` — already removed on `main` before this card
|
|
@@ -39,6 +36,7 @@
|
|
|
39
36
|
import { readFile, stat } from "node:fs/promises";
|
|
40
37
|
import { basename, isAbsolute } from "node:path";
|
|
41
38
|
import { z } from "zod";
|
|
39
|
+
import { CALLBACK_ENV } from "./tool-access.js";
|
|
42
40
|
/**
|
|
43
41
|
* The five canonical agent-visible `danxbot_complete` statuses. DX-3394
|
|
44
42
|
* problem 1682 / Core Principle 1 — the pre-DX-770 deprecated aliases
|
|
@@ -64,26 +62,19 @@ export const QUALITY_GATE_SKIP_REASON_MIN_CHARS = 20;
|
|
|
64
62
|
export function hasAdequateBlockSummary(summary) {
|
|
65
63
|
return typeof summary === "string" && summary.trim().length >= ADEQUATE_SUMMARY_MIN_CHARS;
|
|
66
64
|
}
|
|
67
|
-
/**
|
|
65
|
+
/** The callbacks whose URL is present: what `toolsFor` takes as this dispatch's callbacks (`tool-access.ts`). */
|
|
66
|
+
export function presentCallbacks(urls) {
|
|
67
|
+
return new Set(Object.keys(urls).filter((name) => urls[name] !== undefined));
|
|
68
|
+
}
|
|
69
|
+
/** Read the lifecycle callback URLs straight from `process.env`; an empty value reads as absent. */
|
|
68
70
|
export function readLifecycleUrlsFromEnv() {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
slackUpdate: opt("DANXBOT_SLACK_UPDATE_URL"),
|
|
77
|
-
linkThreadToIssue: opt("DANXBOT_LINK_THREAD_TO_ISSUE_URL"),
|
|
78
|
-
slackRecoveryList: opt("DANXBOT_SLACK_RECOVERY_LIST_URL"),
|
|
79
|
-
slackRecoveryMessage: opt("DANXBOT_SLACK_RECOVERY_MESSAGE_URL"),
|
|
80
|
-
slackRecoveryCreateOrLink: opt("DANXBOT_SLACK_RECOVERY_CREATE_OR_LINK_URL"),
|
|
81
|
-
qualityGate: opt("DANXBOT_QUALITY_GATE_URL"),
|
|
82
|
-
requestCodeReview: opt("DANXBOT_REQUEST_CODE_REVIEW_URL"),
|
|
83
|
-
evaluatorSummary: opt("DANXBOT_EVALUATOR_SUMMARY_URL"),
|
|
84
|
-
saveTemplateApp: opt("DANXBOT_TEMPLATE_APP_SAVE_URL"),
|
|
85
|
-
loadTemplateApp: opt("DANXBOT_TEMPLATE_APP_LOAD_URL"),
|
|
86
|
-
};
|
|
71
|
+
const urls = {};
|
|
72
|
+
for (const name of Object.keys(CALLBACK_ENV)) {
|
|
73
|
+
const v = process.env[CALLBACK_ENV[name]];
|
|
74
|
+
if (typeof v === "string" && v !== "")
|
|
75
|
+
urls[name] = v;
|
|
76
|
+
}
|
|
77
|
+
return urls;
|
|
87
78
|
}
|
|
88
79
|
async function postJson(url, body, bearer) {
|
|
89
80
|
return fetch(url, {
|
|
@@ -162,10 +153,10 @@ export function registerLifecycleTools(strictTool) {
|
|
|
162
153
|
// it", but an OPERATOR session is not a dispatch: it has no
|
|
163
154
|
// DANXBOT_STOP_URL, so `danxbot_complete` was always advertised there and
|
|
164
155
|
// every call threw. It is now gated on the stop URL exactly like every
|
|
165
|
-
// other lifecycle tool below (`
|
|
156
|
+
// other lifecycle tool below (`toolsFor`, `tool-access.ts`) — a real
|
|
166
157
|
// dispatch always has the URL, so nothing is lost there; an operator
|
|
167
158
|
// session simply never sees a tool it could never successfully call.
|
|
168
|
-
|
|
159
|
+
strictTool("danxbot_complete", "Finalize the current dispatch. Releases the worker's per-dispatch scope and ends the dispatch row. " +
|
|
169
160
|
"Does NOT move any card — if you're working an issue-bound dispatch and want the card to move " +
|
|
170
161
|
"(Done / Blocked / Cancelled / etc.), transition the card FIRST through danxbot_api " +
|
|
171
162
|
"({method:'POST', path:'/api/issues/:id/transition', body:{action:...}}), then call this tool. " +
|
|
@@ -213,7 +204,7 @@ export function registerLifecycleTools(strictTool) {
|
|
|
213
204
|
};
|
|
214
205
|
});
|
|
215
206
|
// ---------------- danxbot_slack_reply ----------------
|
|
216
|
-
|
|
207
|
+
strictTool("danxbot_slack_reply",
|
|
217
208
|
// DX-4253 — no ordering against another tool here: the Slack persona (`src/agent/persona.ts`) and the
|
|
218
209
|
// `slack-agent` profile own the order (reply, link, attach, complete).
|
|
219
210
|
"Post the final user-facing reply to the originating Slack thread, once per dispatch. Files in `files[]` " +
|
|
@@ -246,7 +237,7 @@ export function registerLifecycleTools(strictTool) {
|
|
|
246
237
|
};
|
|
247
238
|
});
|
|
248
239
|
// ---------------- danxbot_slack_post_update ----------------
|
|
249
|
-
|
|
240
|
+
strictTool("danxbot_slack_post_update", "Post an intermediate progress update to the originating Slack thread. Use SPARINGLY — only for " +
|
|
250
241
|
"meaningful status the user cares about. Do NOT post for every file read or trivial step.", { text: z.string().describe("The intermediate status update text.") }, async (args) => {
|
|
251
242
|
const url = readLifecycleUrlsFromEnv().slackUpdate;
|
|
252
243
|
if (!url)
|
|
@@ -257,7 +248,7 @@ export function registerLifecycleTools(strictTool) {
|
|
|
257
248
|
return { content: [{ type: "text", text: "Update posted to Slack thread" }] };
|
|
258
249
|
});
|
|
259
250
|
// ---------------- link_thread_to_issue ----------------
|
|
260
|
-
|
|
251
|
+
strictTool("link_thread_to_issue", "Upgrade THIS Slack thread into a tracked card: bind the thread to ONE issue (immutable 1:1) and " +
|
|
261
252
|
"register the Slack mirror so future card updates re-render the thread's card-view message. Thread " +
|
|
262
253
|
"scope is IMPLICIT (channel + thread_ts come from the dispatch) — pass only the issue id. Call this " +
|
|
263
254
|
"AFTER posting your substantive answer with danxbot_slack_reply (that reply becomes the card-view " +
|
|
@@ -269,7 +260,7 @@ export function registerLifecycleTools(strictTool) {
|
|
|
269
260
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
270
261
|
});
|
|
271
262
|
// ---------------- slack_recovery_list ----------------
|
|
272
|
-
|
|
263
|
+
strictTool("slack_recovery_list", "Read-only. Returns the Slack thread's full stored message history plus failures[], the recorded " +
|
|
273
264
|
"router failures projected out of those same messages, so you can reason about what happened before " +
|
|
274
265
|
"mutating anything. Only available on a slack-recovery dispatch.", {}, async () => {
|
|
275
266
|
const url = readLifecycleUrlsFromEnv().slackRecoveryList;
|
|
@@ -279,7 +270,7 @@ export function registerLifecycleTools(strictTool) {
|
|
|
279
270
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
280
271
|
});
|
|
281
272
|
// ---------------- slack_recovery_message (merged replace+purge, DX-3394 problem 1682) ----------------
|
|
282
|
-
|
|
273
|
+
strictTool("slack_recovery_message", "Replace or delete ONE message you posted in this thread. `action: 'replace'` (`chat.update`) rewrites " +
|
|
283
274
|
"the message text — the replaced message becomes this thread's card-view anchor ('message #1'): a " +
|
|
284
275
|
"later link_thread_to_issue registers THIS ts, and danxbot re-renders the real card view into this " +
|
|
285
276
|
"exact slot, so write a short human line, never a hand-written card view, in `text`. `action: 'purge'` " +
|
|
@@ -309,7 +300,7 @@ export function registerLifecycleTools(strictTool) {
|
|
|
309
300
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
310
301
|
});
|
|
311
302
|
// ---------------- slack_recovery_create_or_link ----------------
|
|
312
|
-
|
|
303
|
+
strictTool("slack_recovery_create_or_link", "The ONLY way to get a card id for this thread on a slack-recovery dispatch (danxbot_api's card-create " +
|
|
313
304
|
"route, POST /api/issues, is not available here). Resolves SERVER-SIDE whether the thread already " +
|
|
314
305
|
"resolves to a card before creating " +
|
|
315
306
|
"anything: a hit returns that existing card's id (created:false) and creates NOTHING; a miss creates a " +
|
|
@@ -355,7 +346,7 @@ export function registerLifecycleTools(strictTool) {
|
|
|
355
346
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
356
347
|
});
|
|
357
348
|
// ---------------- quality_gate_complete (absorbs quality_gate_skip, DX-3394 problem 1682) ----------------
|
|
358
|
-
|
|
349
|
+
strictTool("quality_gate_complete",
|
|
359
350
|
// DX-3741 — every gate runs as its own gate-reviewer dispatch, the only one that sees this tool. DX-4318 —
|
|
360
351
|
// what a status does, per phase; how to review (read-only, no transition) is the gate-reviewer profile's
|
|
361
352
|
// (`required-profiles.yaml`). The "3-pass cap" mirrors CODE_GATE_MAX_REVIEW_PASSES
|
|
@@ -403,7 +394,7 @@ export function registerLifecycleTools(strictTool) {
|
|
|
403
394
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
404
395
|
});
|
|
405
396
|
// ---------------- request_code_review (DX-3741) ----------------
|
|
406
|
-
|
|
397
|
+
strictTool("request_code_review", "TERMINAL for this dispatch — call it when your diff is done and its own tests are green, instead of " +
|
|
407
398
|
"danxbot_complete. Pushes your branch (card/<id> to origin/card/<id>), then dispatches every code " +
|
|
408
399
|
"gate this card carries as its OWN independent gate-reviewer dispatch (you never sign or review your " +
|
|
409
400
|
"own diff — DX-3741). Ends this dispatch 'completed' and releases the card to ToDo; when the card is " +
|
|
@@ -418,7 +409,7 @@ export function registerLifecycleTools(strictTool) {
|
|
|
418
409
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
419
410
|
});
|
|
420
411
|
// ---------------- danxbot_set_evaluator_summary ----------------
|
|
421
|
-
|
|
412
|
+
strictTool("danxbot_set_evaluator_summary", "Write the root-cause summary for a 3-strike broken agent. ONLY the system-evaluator dispatch sees this " +
|
|
422
413
|
"tool. Call exactly once at the END of the dispatch, immediately before danxbot_complete. A dispatch " +
|
|
423
414
|
"that exits without calling this tool is treated as evaluator failure.", {
|
|
424
415
|
reason: z
|
|
@@ -437,7 +428,7 @@ export function registerLifecycleTools(strictTool) {
|
|
|
437
428
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
438
429
|
});
|
|
439
430
|
// ---------------- save_template_app ----------------
|
|
440
|
-
|
|
431
|
+
strictTool("save_template_app", "Build and ship ONE template app, keyed by template id. Walks <cwd>/templates/<template_id>/source/, " +
|
|
441
432
|
"builds, packs the bundle, then on success fires an async callback to the repo's configured save_url. " +
|
|
442
433
|
"Returns the synchronous build verdict. Call once per app after its edits are complete.", {
|
|
443
434
|
template_id: z
|
|
@@ -451,7 +442,7 @@ export function registerLifecycleTools(strictTool) {
|
|
|
451
442
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
452
443
|
});
|
|
453
444
|
// ---------------- load_template_app ----------------
|
|
454
|
-
|
|
445
|
+
strictTool("load_template_app", "Set up ONE template app on demand and bring its live HMR preview up. On a FRESH dispatch this WHOLESALE-" +
|
|
455
446
|
"RESETS <cwd>/templates/<template_id>/source/ (pulling the consumer's source or scaffolding a default) " +
|
|
456
447
|
"— call this BEFORE you start editing, never after. On a RESUMED dispatch it touches nothing. Returns " +
|
|
457
448
|
"{template_id, hmr_url, source_dir} — source_dir is the ONLY path to edit; the build reads it directly.", {
|
|
@@ -465,47 +456,4 @@ export function registerLifecycleTools(strictTool) {
|
|
|
465
456
|
const result = await postWorkerRoute(url, { template_id: args.template_id }, "load_template_app", agentBearer());
|
|
466
457
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
467
458
|
});
|
|
468
|
-
return {
|
|
469
|
-
danxbotComplete,
|
|
470
|
-
slackReply,
|
|
471
|
-
slackPostUpdate,
|
|
472
|
-
linkThreadToIssue,
|
|
473
|
-
slackRecoveryList,
|
|
474
|
-
slackRecoveryMessage,
|
|
475
|
-
slackRecoveryCreateOrLink,
|
|
476
|
-
qualityGateComplete,
|
|
477
|
-
requestCodeReview,
|
|
478
|
-
evaluatorSummary,
|
|
479
|
-
saveTemplateApp,
|
|
480
|
-
loadTemplateApp,
|
|
481
|
-
};
|
|
482
|
-
}
|
|
483
|
-
/**
|
|
484
|
-
* Disable every conditionally-visible lifecycle tool whose URL is absent
|
|
485
|
-
* from `urls` — the same advertise-filter contract `buildActiveTools` used
|
|
486
|
-
* to enforce in the old standalone server. `danxbot_complete` (DX-3465,
|
|
487
|
-
* code-quality gate finding, at 275f82c0d) is gated on `urls.stop` the same
|
|
488
|
-
* way — a real dispatch always carries `DANXBOT_STOP_URL`, so it stays
|
|
489
|
-
* visible there; an operator session (no stop URL) never advertises a tool
|
|
490
|
-
* it could never successfully call.
|
|
491
|
-
*/
|
|
492
|
-
export function applyLifecycleVisibility(tools, urls) {
|
|
493
|
-
const setVisible = (tool, visible) => {
|
|
494
|
-
if (visible)
|
|
495
|
-
tool.enable();
|
|
496
|
-
else
|
|
497
|
-
tool.disable();
|
|
498
|
-
};
|
|
499
|
-
setVisible(tools.danxbotComplete, !!urls.stop);
|
|
500
|
-
setVisible(tools.slackReply, !!urls.slackReply);
|
|
501
|
-
setVisible(tools.slackPostUpdate, !!urls.slackUpdate);
|
|
502
|
-
setVisible(tools.linkThreadToIssue, !!urls.linkThreadToIssue);
|
|
503
|
-
setVisible(tools.slackRecoveryList, !!urls.slackRecoveryList);
|
|
504
|
-
setVisible(tools.slackRecoveryMessage, !!urls.slackRecoveryMessage);
|
|
505
|
-
setVisible(tools.slackRecoveryCreateOrLink, !!urls.slackRecoveryCreateOrLink);
|
|
506
|
-
setVisible(tools.qualityGateComplete, !!urls.qualityGate);
|
|
507
|
-
setVisible(tools.requestCodeReview, !!urls.requestCodeReview);
|
|
508
|
-
setVisible(tools.evaluatorSummary, !!urls.evaluatorSummary);
|
|
509
|
-
setVisible(tools.saveTemplateApp, !!urls.saveTemplateApp);
|
|
510
|
-
setVisible(tools.loadTemplateApp, !!urls.loadTemplateApp);
|
|
511
459
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4523 - how this process learns that its key's permissions changed while it runs: the dashboard's live stream.
|
|
3
|
+
*
|
|
4
|
+
* `/api/stream` ends every open stream of a key with `event: end {reason: "permissions_changed"}` the moment a person approves a
|
|
5
|
+
* grant for it, revokes one, or revokes the key (`closeSessionStreams`, `src/dashboard/stream-routes.ts`). This watcher holds one
|
|
6
|
+
* stream open on the key-self topic `key:self` (`src/dashboard/sse-topic-policy.ts`), which any key may subscribe to with no
|
|
7
|
+
* permission: a dispatch's agent role holds none of the team read-floor keys, yet is exactly the key that is granted one mid-session.
|
|
8
|
+
*
|
|
9
|
+
* Reconnect FIRST, then re-read the permissions (`sync`), after EVERY (re)connect, so a change that landed while the stream was down
|
|
10
|
+
* is applied and a change after the connect ends the stream. A failed sync is retried with bounded backoff, then logged: the
|
|
11
|
+
* stream stays up and the next connect syncs again. No polling: the only timers are the reconnect backoff after a drop and the sync
|
|
12
|
+
* retry. The key is read on every connect, so a replaced key is the one used.
|
|
13
|
+
*
|
|
14
|
+
* A refusal (a 4xx other than 408 and 429, which are retried like a drop) ends the watch: a 401 means the key is gone (the next dashboard call halts the session as usual) and a 403
|
|
15
|
+
* means the dashboard predates the topic or refuses this principal. A 5xx or a dropped connection is retried with backoff, which
|
|
16
|
+
* starts over once a connection proved healthy (stayed open a while, or ended by a permission change).
|
|
17
|
+
*/
|
|
18
|
+
import { SYNC_ATTEMPTS, retryBounded } from "./bounded-retry.js";
|
|
19
|
+
import { SseParser } from "./listen.js";
|
|
20
|
+
const STREAM_PATH = "/api/stream?topics=key:self";
|
|
21
|
+
const PERMISSIONS_CHANGED = "permissions_changed";
|
|
22
|
+
export const WATCH_INITIAL_BACKOFF_MS = 1_000;
|
|
23
|
+
export const WATCH_MAX_BACKOFF_MS = 30_000;
|
|
24
|
+
/** A connection that stayed open this long was healthy, so the next drop starts the backoff over. */
|
|
25
|
+
export const WATCH_HEALTHY_CONNECTION_MS = 20_000;
|
|
26
|
+
/** A 4xx that means "try again later", not "no": the stream is retried with backoff like a drop. */
|
|
27
|
+
const RETRYABLE_REFUSALS = new Set([408, 429]);
|
|
28
|
+
const defaultSleep = (ms) => new Promise((resolve) => {
|
|
29
|
+
setTimeout(resolve, ms).unref();
|
|
30
|
+
});
|
|
31
|
+
export class PermissionWatch {
|
|
32
|
+
deps;
|
|
33
|
+
running = false;
|
|
34
|
+
stopRequested = false;
|
|
35
|
+
controller = null;
|
|
36
|
+
fetchImpl;
|
|
37
|
+
sleep;
|
|
38
|
+
now;
|
|
39
|
+
constructor(deps) {
|
|
40
|
+
this.deps = deps;
|
|
41
|
+
this.fetchImpl = deps.fetch ?? fetch;
|
|
42
|
+
this.sleep = deps.sleep ?? defaultSleep;
|
|
43
|
+
this.now = deps.now ?? Date.now;
|
|
44
|
+
}
|
|
45
|
+
/** Start watching. A watch already running is left alone, so every sign-in may call this. */
|
|
46
|
+
start() {
|
|
47
|
+
if (this.running)
|
|
48
|
+
return;
|
|
49
|
+
this.running = true;
|
|
50
|
+
this.stopRequested = false;
|
|
51
|
+
void this.run().finally(() => {
|
|
52
|
+
this.running = false;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/** Stop watching and close the open stream, so the process can exit. */
|
|
56
|
+
stop() {
|
|
57
|
+
this.stopRequested = true;
|
|
58
|
+
this.controller?.abort();
|
|
59
|
+
}
|
|
60
|
+
async run() {
|
|
61
|
+
let attempt = 0;
|
|
62
|
+
while (!this.stopRequested) {
|
|
63
|
+
const token = this.deps.token();
|
|
64
|
+
if (token === "")
|
|
65
|
+
return;
|
|
66
|
+
const outcome = await this.connectOnce(token);
|
|
67
|
+
if (outcome.kind === "refused")
|
|
68
|
+
return;
|
|
69
|
+
if (outcome.kind === "changed" || outcome.healthy)
|
|
70
|
+
attempt = 0;
|
|
71
|
+
// A permission change reconnects at once; a drop waits out the backoff first.
|
|
72
|
+
if (outcome.kind === "dropped" && !this.stopRequested) {
|
|
73
|
+
await this.sleep(Math.min(WATCH_MAX_BACKOFF_MS, WATCH_INITIAL_BACKOFF_MS * 2 ** attempt));
|
|
74
|
+
attempt += 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async connectOnce(token) {
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
this.controller = controller;
|
|
81
|
+
const openedAt = this.now();
|
|
82
|
+
let opened = false;
|
|
83
|
+
const healthy = () => opened && this.now() - openedAt >= WATCH_HEALTHY_CONNECTION_MS;
|
|
84
|
+
try {
|
|
85
|
+
const response = await this.fetchImpl(`${this.deps.dashboardUrl.replace(/\/+$/, "")}${STREAM_PATH}`, {
|
|
86
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" },
|
|
87
|
+
signal: controller.signal,
|
|
88
|
+
});
|
|
89
|
+
if (response.status >= 400 && response.status < 500 && !RETRYABLE_REFUSALS.has(response.status)) {
|
|
90
|
+
this.deps.log?.(`[danx-dashboard-mcp] the dashboard refused the permission stream (HTTP ${response.status}): a permission granted ` +
|
|
91
|
+
`mid-session shows its tools at the next plan_connect instead`);
|
|
92
|
+
await response.body?.cancel();
|
|
93
|
+
return { kind: "refused" };
|
|
94
|
+
}
|
|
95
|
+
if (!response.ok || response.body === null) {
|
|
96
|
+
await response.body?.cancel();
|
|
97
|
+
return { kind: "dropped", healthy: false };
|
|
98
|
+
}
|
|
99
|
+
opened = true;
|
|
100
|
+
await this.syncWithRetry();
|
|
101
|
+
const parser = new SseParser();
|
|
102
|
+
const decoder = new TextDecoder();
|
|
103
|
+
for await (const chunk of response.body) {
|
|
104
|
+
for (const message of parser.push(decoder.decode(chunk, { stream: true }))) {
|
|
105
|
+
if (message.event === "end" && endReason(message.data) === PERMISSIONS_CHANGED)
|
|
106
|
+
return { kind: "changed" };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { kind: "dropped", healthy: healthy() };
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
if (!this.stopRequested)
|
|
113
|
+
this.deps.log?.(`[danx-dashboard-mcp] permission stream dropped: ${err.message}`);
|
|
114
|
+
return { kind: "dropped", healthy: healthy() };
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
controller.abort();
|
|
118
|
+
if (this.controller === controller)
|
|
119
|
+
this.controller = null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** One sync, retried with bounded backoff; a sync that keeps failing is logged and the stream stays up. */
|
|
123
|
+
async syncWithRetry() {
|
|
124
|
+
try {
|
|
125
|
+
await retryBounded(() => this.deps.sync(), {
|
|
126
|
+
attempts: SYNC_ATTEMPTS,
|
|
127
|
+
sleep: this.sleep,
|
|
128
|
+
shouldStop: () => this.stopRequested,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
this.deps.log?.(`[danx-dashboard-mcp] tool list refresh failed after ${SYNC_ATTEMPTS} attempts, tools stay as they are until the next connect: ${err.message}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function endReason(data) {
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(data).reason;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
package/dist/session-access.js
CHANGED
|
@@ -89,6 +89,10 @@ export class SessionAccess {
|
|
|
89
89
|
isSignedIn() {
|
|
90
90
|
return this.credential !== null && this.revoked === null;
|
|
91
91
|
}
|
|
92
|
+
/** DX-4523: the key this session holds now, or "" when it holds none (the permission stream opens with it). */
|
|
93
|
+
currentToken() {
|
|
94
|
+
return this.credential === null || this.revoked !== null ? "" : this.credential.token;
|
|
95
|
+
}
|
|
92
96
|
/** DX-4418: the stop halt once a person revoked the key, else null. */
|
|
93
97
|
revokedHalt() {
|
|
94
98
|
return this.revoked === null ? null : keyRevokedHalt(this.revoked.revocation, this.revoked.dispatched);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4523 (parent DX-4522) - which MCP tool a session is shown, decided in ONE place.
|
|
3
|
+
*
|
|
4
|
+
* A session sees a tool only when it can call it. Every tool is declared here with what makes it callable, and
|
|
5
|
+
* `toolsFor` is the one function that turns what a session holds into the tool names it advertises. The server
|
|
6
|
+
* (`tool-surface.ts`) applies it, and the danxbot service's budgets (`src/tokens/mcp-surface.ts`) measure it, so no
|
|
7
|
+
* second list of "which session gets which tool" exists anywhere.
|
|
8
|
+
*
|
|
9
|
+
* Three kinds of tool, by what makes them usable:
|
|
10
|
+
*
|
|
11
|
+
* - bootstrap `{permission: null, callback: null}` - what a session needs BEFORE it holds a key, or to ask for more:
|
|
12
|
+
* `plan_connect` (sign in, bind to a plan), `request_permission`, and the `danxbot_api` trio.
|
|
13
|
+
* - permission `{permission}` - the dashboard route the tool calls needs this permission, so a key without it can
|
|
14
|
+
* never succeed. Shown once the key holds it, which includes a grant a person approves mid-session.
|
|
15
|
+
* - callback `{callback}` - the tool posts to a per-dispatch URL the worker injected (`lifecycle.ts`). The worker
|
|
16
|
+
* authenticates it with the dispatch's own agent token, so no dashboard permission is involved: the tool
|
|
17
|
+
* is usable exactly when its URL exists. A tool whose route is a dashboard route also names its
|
|
18
|
+
* `permission` (both must hold).
|
|
19
|
+
*
|
|
20
|
+
* This module is pure data and one function, with no imports, because the danxbot service imports it directly and
|
|
21
|
+
* cannot take this package's MCP-SDK or zod dependencies.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* The environment variable a worker sets to inject each callback's URL. `lifecycle.ts` reads them, and the worker's overlay
|
|
25
|
+
* (`src/dispatch/dispatch-callbacks.ts`) writes them, so the name of a callback exists once.
|
|
26
|
+
*/
|
|
27
|
+
export const CALLBACK_ENV = {
|
|
28
|
+
stop: "DANXBOT_STOP_URL",
|
|
29
|
+
slackReply: "DANXBOT_SLACK_REPLY_URL",
|
|
30
|
+
slackUpdate: "DANXBOT_SLACK_UPDATE_URL",
|
|
31
|
+
linkThreadToIssue: "DANXBOT_LINK_THREAD_TO_ISSUE_URL",
|
|
32
|
+
slackRecoveryList: "DANXBOT_SLACK_RECOVERY_LIST_URL",
|
|
33
|
+
slackRecoveryMessage: "DANXBOT_SLACK_RECOVERY_MESSAGE_URL",
|
|
34
|
+
slackRecoveryCreateOrLink: "DANXBOT_SLACK_RECOVERY_CREATE_OR_LINK_URL",
|
|
35
|
+
qualityGate: "DANXBOT_QUALITY_GATE_URL",
|
|
36
|
+
requestCodeReview: "DANXBOT_REQUEST_CODE_REVIEW_URL",
|
|
37
|
+
evaluatorSummary: "DANXBOT_EVALUATOR_SUMMARY_URL",
|
|
38
|
+
saveTemplateApp: "DANXBOT_TEMPLATE_APP_SAVE_URL",
|
|
39
|
+
loadTemplateApp: "DANXBOT_TEMPLATE_APP_LOAD_URL",
|
|
40
|
+
};
|
|
41
|
+
const BOOTSTRAP = { permission: null, callback: null };
|
|
42
|
+
const needs = (permission) => ({ permission, callback: null });
|
|
43
|
+
const postsTo = (callback) => ({ permission: null, callback });
|
|
44
|
+
const needsAndPostsTo = (permission, callback) => ({ permission, callback });
|
|
45
|
+
/**
|
|
46
|
+
* Every tool this server registers, with what makes it usable. `index.ts` refuses to register a tool that is not
|
|
47
|
+
* here, and `tool-access.test.ts` fails when an entry names no registered tool.
|
|
48
|
+
*/
|
|
49
|
+
export const TOOL_ACCESS = {
|
|
50
|
+
plan_connect: BOOTSTRAP,
|
|
51
|
+
request_permission: BOOTSTRAP,
|
|
52
|
+
danxbot_api: BOOTSTRAP,
|
|
53
|
+
danxbot_api_list: BOOTSTRAP,
|
|
54
|
+
danxbot_api_spec: BOOTSTRAP,
|
|
55
|
+
// `POST /api/issues/:id/attachments`
|
|
56
|
+
attach_file: needs("cards.edit"),
|
|
57
|
+
// The dispatch's own terminal and sign-off calls: worker routes, authenticated by the dispatch's agent token.
|
|
58
|
+
danxbot_complete: postsTo("stop"),
|
|
59
|
+
quality_gate_complete: postsTo("qualityGate"),
|
|
60
|
+
request_code_review: postsTo("requestCodeReview"),
|
|
61
|
+
danxbot_set_evaluator_summary: postsTo("evaluatorSummary"),
|
|
62
|
+
save_template_app: postsTo("saveTemplateApp"),
|
|
63
|
+
load_template_app: postsTo("loadTemplateApp"),
|
|
64
|
+
// Dashboard callback routes; each route's own policy names the permission (`route-policies/index.ts`).
|
|
65
|
+
danxbot_slack_reply: needsAndPostsTo("slack.threads.post", "slackReply"),
|
|
66
|
+
danxbot_slack_post_update: needsAndPostsTo("slack.threads.post", "slackUpdate"),
|
|
67
|
+
link_thread_to_issue: needsAndPostsTo("cards.edit", "linkThreadToIssue"),
|
|
68
|
+
slack_recovery_list: needsAndPostsTo("slack.recovery.manage", "slackRecoveryList"),
|
|
69
|
+
slack_recovery_message: needsAndPostsTo("slack.recovery.manage", "slackRecoveryMessage"),
|
|
70
|
+
slack_recovery_create_or_link: needsAndPostsTo("slack.recovery.manage", "slackRecoveryCreateOrLink"),
|
|
71
|
+
};
|
|
72
|
+
/** True when a session holding `grant` can call the tool declared by `access`. */
|
|
73
|
+
export function isUsable(access, grant) {
|
|
74
|
+
if (access.permission !== null && !grant.permissions.has(access.permission))
|
|
75
|
+
return false;
|
|
76
|
+
if (access.callback !== null && !grant.callbacks.has(access.callback))
|
|
77
|
+
return false;
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
/** The ONE mapping from what a session holds to the tool names it advertises, sorted. */
|
|
81
|
+
export function toolsFor(grant) {
|
|
82
|
+
return Object.keys(TOOL_ACCESS).filter((name) => isUsable(TOOL_ACCESS[name], grant)).sort();
|
|
83
|
+
}
|
|
84
|
+
/** The bootstrap set: usable with no key and no callback. */
|
|
85
|
+
export function bootstrapTools() {
|
|
86
|
+
return toolsFor({ permissions: new Set(), callbacks: new Set() });
|
|
87
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4523 - applies `toolsFor` (`tool-access.ts`) to the tools this server registered, and keeps it applied as what the
|
|
3
|
+
* session holds changes: at boot, when sign-in completes, and when a person approves a `request_permission` grant.
|
|
4
|
+
*
|
|
5
|
+
* A changed tool list makes Claude Code refetch it (`notifications/tools/list_changed`) and invalidates the prompt cache, so the
|
|
6
|
+
* surface switches a tool ONLY when its visibility really changes and announces the change ONCE per apply: a grant that adds no
|
|
7
|
+
* tool, or a repeated sync, sends nothing. The SDK's own `enable()` / `disable()` each send a notification, so the surface sets
|
|
8
|
+
* `RegisteredTool.enabled` (a plain field the SDK's list handler reads) and calls `onChanged` once instead.
|
|
9
|
+
*/
|
|
10
|
+
import { toolsFor } from "./tool-access.js";
|
|
11
|
+
export class ToolSurface {
|
|
12
|
+
tools;
|
|
13
|
+
callbacks;
|
|
14
|
+
onChanged;
|
|
15
|
+
constructor(tools, callbacks,
|
|
16
|
+
/** Announces that the tool list changed: the server's `sendToolListChanged` (a no-op until a client is connected). */
|
|
17
|
+
onChanged) {
|
|
18
|
+
this.tools = tools;
|
|
19
|
+
this.callbacks = callbacks;
|
|
20
|
+
this.onChanged = onChanged;
|
|
21
|
+
}
|
|
22
|
+
/** Show exactly the tools `permissions` and this dispatch's callbacks allow. Returns the names whose visibility changed. */
|
|
23
|
+
apply(permissions) {
|
|
24
|
+
const wanted = new Set(toolsFor({ permissions, callbacks: this.callbacks }));
|
|
25
|
+
const changed = [];
|
|
26
|
+
for (const [name, tool] of this.tools) {
|
|
27
|
+
const want = wanted.has(name);
|
|
28
|
+
if (tool.enabled === want)
|
|
29
|
+
continue;
|
|
30
|
+
tool.enabled = want;
|
|
31
|
+
changed.push(name);
|
|
32
|
+
}
|
|
33
|
+
if (changed.length > 0)
|
|
34
|
+
this.onChanged();
|
|
35
|
+
return changed.sort();
|
|
36
|
+
}
|
|
37
|
+
/** The tool names currently shown. */
|
|
38
|
+
advertised() {
|
|
39
|
+
return [...this.tools].filter(([, tool]) => tool.enabled).map(([name]) => name).sort();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Re-reads the key's permissions and applies them. Calls run one at a time, so two triggers never interleave. */
|
|
43
|
+
export class ToolAccessSync {
|
|
44
|
+
deps;
|
|
45
|
+
queue = Promise.resolve();
|
|
46
|
+
constructor(deps) {
|
|
47
|
+
this.deps = deps;
|
|
48
|
+
}
|
|
49
|
+
/** Resolves with the names whose visibility changed. A failed read rejects: the caller decides how loud that is. */
|
|
50
|
+
sync() {
|
|
51
|
+
const run = this.queue.then(async () => {
|
|
52
|
+
const permissions = (await this.deps.readPermissions()) ?? new Set();
|
|
53
|
+
const changed = this.deps.surface.apply(permissions);
|
|
54
|
+
if (changed.length > 0)
|
|
55
|
+
this.deps.log?.(`[danx-dashboard-mcp] tool list changed: ${changed.join(", ")}`);
|
|
56
|
+
return changed;
|
|
57
|
+
});
|
|
58
|
+
this.queue = run.catch(() => undefined);
|
|
59
|
+
return run;
|
|
60
|
+
}
|
|
61
|
+
}
|
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.250",
|
|
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",
|