@thehammer/danx-dashboard-mcp 0.1.229 → 0.1.230
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 +9 -2
- package/dist/forbidden-hint.js +23 -0
- package/dist/index.js +48 -3
- package/dist/permission-request.js +141 -0
- package/dist/session-access.js +2 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -23,19 +23,26 @@ An operator session has no stored credential. Signed out, `plan_connect` (option
|
|
|
23
23
|
- **A 401 on the stored key** deletes the file and returns the sign-in halt (in `plan_connect`: a new request in the same call). That is a key that lapsed. A key a PERSON revoked is answered `401 key_revoked` (DX-4418): the key file is deleted and EVERY dashboard tool, `plan_connect` included, returns one stop halt (who revoked it and when; commit work in progress and end; a dispatched agent ends with `danxbot_complete`), for the rest of the process. No access request is made. `danxbot_complete` and the other worker lifecycle tools do not use the dashboard key, so a dispatched agent can still end. The session's event bridge ends with the stop reason `key_revoked` the same way. **A 404 on create** means the dashboard predates request-and-approve and says so.
|
|
24
24
|
- A dispatch's `DANX_DASHBOARD_CREDENTIAL` skips all of this. There is no token-file credential source.
|
|
25
25
|
|
|
26
|
+
### Asking for one more permission (DX-4434)
|
|
27
|
+
|
|
28
|
+
A call the dashboard refuses `403 {error: "forbidden", permission}` means the key lacks that one permission. `request_permission({permissions, reason})` asks the key's owner to grant it: it posts `POST /api/permission-requests` (the key's own bearer, through `SessionAccess.guard`, so a revoked or signed-out session never asks) and answers at once `{state: "approval_required", approvalUrl, confirmCode, expiresAt, instruction}`. The agent shows the person the code and URL and keeps working; this process polls `POST /api/permission-requests/:publicId/claim` in the background (`src/permission-request.ts`, the same poll shape as above) and a grant is live on the key's very next call, with the same key. The dashboard does not dedupe, so this server keeps one in-flight request per permission set (sorted, de-duplicated): the same set again while pending answers `approval_pending` with the original code and URL and posts nothing. A decided, expired or reaped request frees its set so a later call asks again. A dashboard 4xx on the create (`unknown_permission`, `already_held`, `owner_lacks_permission`, `key_not_live`, 429) is returned verbatim as `{ok: false, status, body}`; 5xx throws.
|
|
29
|
+
|
|
30
|
+
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`).
|
|
31
|
+
|
|
26
32
|
## Tool surface (DX-3465 — one generic tool, not one per operation)
|
|
27
33
|
|
|
28
|
-
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:
|
|
34
|
+
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):
|
|
29
35
|
|
|
30
36
|
| Tool | Purpose |
|
|
31
37
|
|---|---|
|
|
32
38
|
| `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. |
|
|
33
39
|
| `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. |
|
|
34
40
|
| `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. |
|
|
41
|
+
| `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. |
|
|
35
42
|
|
|
36
43
|
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.
|
|
37
44
|
|
|
38
|
-
A dedicated tool survives only for something about the agent itself, never a card/plan operation: `plan_connect` (binds the session to a plan; the event-bridge hook keys on it) and the per-dispatch worker callbacks (`danxbot_complete`, `danxbot_slack_reply`, `danxbot_slack_post_update`, `load_template_app`/`save_template_app`, `quality_gate_complete`, `link_thread_to_issue`, `attach_file`, the `slack_recovery_*` trio, `danxbot_set_evaluator_summary`) — see `src/lifecycle.ts`. The live list is always `tool-defs.json` in this package, regenerated by `npm run gen-tool-defs` — read it rather than trusting a list copied into prose.
|
|
45
|
+
A dedicated tool survives only for something about the agent itself, never a card/plan operation: `request_permission` (asks for a permission its key lacks), `plan_connect` (binds the session to a plan; the event-bridge hook keys on it) and the per-dispatch worker callbacks (`danxbot_complete`, `danxbot_slack_reply`, `danxbot_slack_post_update`, `load_template_app`/`save_template_app`, `quality_gate_complete`, `link_thread_to_issue`, `attach_file`, the `slack_recovery_*` trio, `danxbot_set_evaluator_summary`) — see `src/lifecycle.ts`. The live list is always `tool-defs.json` in this package, regenerated by `npm run gen-tool-defs` — read it rather than trusting a list copied into prose.
|
|
39
46
|
|
|
40
47
|
Card and plan reads/writes go through `danxbot_api` against the issue/plan route families documented in `GET /api/openapi` (via `danxbot_api_spec`) — `POST /api/issues`, `GET /api/issues/:id` (or `/batch` for many at once, DX-2727), `PATCH /api/issues/:id/edit`, `POST /api/issues/:id/transition`, the problems/solutions/dependencies/comments/quality-gates/retro sub-resource routes, and the equivalent `/api/plans/*` family (`GET /api/plans/:planId` — cheap scalars by default, `fields` opts into `cards`/`records`/`architecture`/`session_history`/`worker_dispatches`; `GET /api/plans` for the list, with an optional `status` filter over the same computed `complete`/`awaiting-session`/`building`/`planning` value `GET /api/plans/:planId` always returns ungated).
|
|
41
48
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const FORBIDDEN_HINT_KEY = "forbidden.request_permission";
|
|
2
|
+
function hintText(permission) {
|
|
3
|
+
return (`Refused: your key lacks the permission \`${permission}\`. Call \`request_permission({permissions: ["${permission}"], reason})\` ` +
|
|
4
|
+
"to ask a person to grant it (it answers at once; keep working on anything else). If you are blocked without it, " +
|
|
5
|
+
"raise a problem on the card: a person sees problems in Needs You.");
|
|
6
|
+
}
|
|
7
|
+
/** `result` with the hint appended to its `reminders[]` when it is a `403 forbidden` naming a permission, else `result` itself. */
|
|
8
|
+
export function withForbiddenHint(result) {
|
|
9
|
+
if (result.ok || result.status !== 403)
|
|
10
|
+
return result;
|
|
11
|
+
const body = result.body;
|
|
12
|
+
if (typeof body !== "object" || body === null || Array.isArray(body))
|
|
13
|
+
return result;
|
|
14
|
+
const { error, permission, reminders } = body;
|
|
15
|
+
if (error !== "forbidden" || typeof permission !== "string" || permission === "")
|
|
16
|
+
return result;
|
|
17
|
+
if (reminders !== undefined && !Array.isArray(reminders))
|
|
18
|
+
return result;
|
|
19
|
+
const existing = (reminders ?? []);
|
|
20
|
+
if (existing.some((r) => r?.key === FORBIDDEN_HINT_KEY))
|
|
21
|
+
return result;
|
|
22
|
+
return { ...result, body: { ...body, reminders: [...existing, { key: FORBIDDEN_HINT_KEY, text: hintText(permission) }] } };
|
|
23
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
* first to find the route for something you need to do.
|
|
24
24
|
* - danxbot_api_spec discovers the exact request/response shape of any route
|
|
25
25
|
* `danxbot_api` can call, before calling it.
|
|
26
|
+
* - request_permission POST /api/permission-requests (DX-4434) — asks a person to grant
|
|
27
|
+
* this key ONE MORE permission while the agent works; answers at once
|
|
28
|
+
* with the approval URL and confirm code, and the grant is picked up in
|
|
29
|
+
* the background (`permission-request.ts`). A dashboard call refused
|
|
30
|
+
* `403 forbidden` carries a hint to call it (`forbidden-hint.ts`).
|
|
26
31
|
* - plan_connect POST /api/plan-sessions/me/plan — SESSION-BOUND (kept as a
|
|
27
32
|
* dedicated tool; every plan READ or WRITE operation goes
|
|
28
33
|
* through danxbot_api now, e.g. GET /api/plans/mine or
|
|
@@ -79,6 +84,8 @@ import { RESTART_NOTICE_SUBCOMMAND, runRestartNoticeCommand } from "./restart-no
|
|
|
79
84
|
import { resolveBootCredential } from "./credential.js";
|
|
80
85
|
import { SessionKeyUnusableError, deleteSessionKey } from "./session-key.js";
|
|
81
86
|
import { SessionAccess } from "./session-access.js";
|
|
87
|
+
import { PermissionRequests } from "./permission-request.js";
|
|
88
|
+
import { withForbiddenHint } from "./forbidden-hint.js";
|
|
82
89
|
import { readOwnVersion } from "./version-floor.js";
|
|
83
90
|
import { projectKeyOf, recordSessionConnectionAfterConnect, removeSessionConnectionAfterLeave } from "./session-connection.js";
|
|
84
91
|
import { createListenerReminder, isPlanConnectedOnThisMachine, } from "./listener-reminder.js";
|
|
@@ -129,6 +136,8 @@ let listenerReminder;
|
|
|
129
136
|
* credential itself), written to the session's connection record on a successful `plan_connect`.
|
|
130
137
|
*/
|
|
131
138
|
let access;
|
|
139
|
+
// DX-4434 — late-bound with `access` and `client`; see `permission-request.ts`.
|
|
140
|
+
let permissionRequests;
|
|
132
141
|
/**
|
|
133
142
|
* Compose the dispatch's qualified board id (`<repo>:<slug>`) from the two env
|
|
134
143
|
* halves the worker injects and build the HTTP client. DANXBOT_BOARD_NAME
|
|
@@ -163,6 +172,11 @@ function boot() {
|
|
|
163
172
|
initial: resolveInitialCredential(dashboardUrl, session?.id),
|
|
164
173
|
log: (line) => console.error(line),
|
|
165
174
|
});
|
|
175
|
+
permissionRequests = new PermissionRequests({
|
|
176
|
+
access,
|
|
177
|
+
request: (args) => dashboardClient.request(args),
|
|
178
|
+
log: (line) => console.error(line),
|
|
179
|
+
});
|
|
166
180
|
listenerReminder = createListenerReminder({
|
|
167
181
|
sessionId: config.session?.id,
|
|
168
182
|
fetchHealth: () => dashboardClient.request({ method: "GET", basePath: "", path: "/api/plans", query: { limit: 1 } }),
|
|
@@ -290,14 +304,22 @@ function accessReply(ready, note) {
|
|
|
290
304
|
const answer = note ? { ...ready.answer, note } : ready.answer;
|
|
291
305
|
return ready.isError ? haltResult(JSON.stringify(answer)) : jsonResult(answer);
|
|
292
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* DX-4390 — a dashboard call behind the sign-in halt (and the 401 sign-out). DX-4434: the ONE place a refused call's
|
|
309
|
+
* `403 forbidden` gains its `request_permission` hint, so every tool that returns a dashboard result carries it.
|
|
310
|
+
*/
|
|
311
|
+
async function guardedCall(run) {
|
|
312
|
+
const guarded = await access.guard(run);
|
|
313
|
+
return "halt" in guarded ? guarded : { result: withForbiddenHint(guarded.result) };
|
|
314
|
+
}
|
|
293
315
|
/** DX-4390 — a tool's dashboard call behind the sign-in halt (and the 401 sign-out). */
|
|
294
316
|
async function guardedJson(run) {
|
|
295
|
-
const guarded = await
|
|
317
|
+
const guarded = await guardedCall(run);
|
|
296
318
|
return "halt" in guarded ? haltResult(guarded.halt) : jsonResult(guarded.result);
|
|
297
319
|
}
|
|
298
320
|
/** DX-4400 — `guardedJson` for the read tools whose answer can be a whole document: bounded. */
|
|
299
321
|
async function guardedBoundedJson(run) {
|
|
300
|
-
const guarded = await
|
|
322
|
+
const guarded = await guardedCall(run);
|
|
301
323
|
return "halt" in guarded ? haltResult(guarded.halt) : boundedJsonResult(guarded.result);
|
|
302
324
|
}
|
|
303
325
|
function jsonResult(value) {
|
|
@@ -415,7 +437,7 @@ async (args) => {
|
|
|
415
437
|
const refused = termsAcceptanceCallHalt(args, config.baseUrl);
|
|
416
438
|
if (refused)
|
|
417
439
|
return haltResult(refused);
|
|
418
|
-
const guarded = await
|
|
440
|
+
const guarded = await guardedCall(() => danxbotApi(client, args));
|
|
419
441
|
if ("halt" in guarded)
|
|
420
442
|
return haltResult(guarded.halt);
|
|
421
443
|
const result = guarded.result;
|
|
@@ -463,6 +485,29 @@ strictTool("attach_file", "Attach a LOCAL file to a card. This server reads and
|
|
|
463
485
|
content_type: z.string().min(1).optional().describe("Override for the stored MIME type; default: an extension guess."),
|
|
464
486
|
...boardField,
|
|
465
487
|
}, async (args) => guardedJson(() => attachFile(client, args)));
|
|
488
|
+
// ---------------- request_permission (DX-4434, parent DX-4432) ----------------
|
|
489
|
+
strictTool("request_permission", "Ask a person to grant this key ONE MORE permission. Call it when a call was refused `403 forbidden` naming a " +
|
|
490
|
+
"`permission`, or when you know you need one more. It returns AT ONCE with `{state, approvalUrl, confirmCode, " +
|
|
491
|
+
"expiresAt, instruction}`: show the person the code and URL, and keep working on anything else. If they approve, a " +
|
|
492
|
+
"later call simply succeeds with the same key (no new key, no re-auth). The same permissions asked again while " +
|
|
493
|
+
"pending return the pending request. A refusal (unknown permission, already held, rate limited) comes back as " +
|
|
494
|
+
"`{ok:false, status, body}`. If you are blocked without it, raise a problem on the card.", {
|
|
495
|
+
permissions: z
|
|
496
|
+
.array(z.string().min(1))
|
|
497
|
+
.min(1)
|
|
498
|
+
.describe("The permission keys to ask for: the `permission` a `403 forbidden` named."),
|
|
499
|
+
reason: z.string().min(1).describe("Why you need them; shown to the person who approves."),
|
|
500
|
+
}, async (args) => {
|
|
501
|
+
const asked = await permissionRequests.ask(args);
|
|
502
|
+
if ("halt" in asked)
|
|
503
|
+
return haltResult(asked.halt);
|
|
504
|
+
if ("result" in asked) {
|
|
505
|
+
// DX-4148 — a terms refusal halts here as on every dashboard call.
|
|
506
|
+
const halt = termsHaltFor(asked.result);
|
|
507
|
+
return halt ? haltResult(halt) : jsonResult(asked.result);
|
|
508
|
+
}
|
|
509
|
+
return jsonResult(asked.answer);
|
|
510
|
+
});
|
|
466
511
|
// ---------------- plans (DX-2683) ----------------
|
|
467
512
|
// THE BINDING ASYMMETRY IS IN THE SCHEMAS, NOT IN PROSE. `GET /api/plans/:planId`
|
|
468
513
|
// takes a plan id in its path; the plan records, plan cards and
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { POLL_GRACE_MS, defaultSleep } from "./session-access.js";
|
|
2
|
+
import { McpOutdatedError } from "./version-floor.js";
|
|
3
|
+
const PERMISSION_REQUESTS_BASE_PATH = "/api/permission-requests";
|
|
4
|
+
const APPROVAL_REQUIRED_INSTRUCTION = "Show the person the approval URL and the confirm code (they check the code matches, then approve). Keep working on anything " +
|
|
5
|
+
"else: this call has returned and nothing waits. If they approve, this process picks the grant up in the background and a " +
|
|
6
|
+
"later call simply succeeds with the same key (no new key, no re-auth). If you are blocked without it, raise a problem on " +
|
|
7
|
+
"the card; a person sees problems in Needs You.";
|
|
8
|
+
const APPROVAL_PENDING_INSTRUCTION = "This permission is already asked for and not yet decided. Remind the person of the confirm code and the approval URL; keep " +
|
|
9
|
+
"working on anything else. A grant is picked up in the background and a later call simply succeeds with the same key. If you " +
|
|
10
|
+
"are blocked without it, raise a problem on the card.";
|
|
11
|
+
/** The dedupe set: sorted and de-duplicated, so the order the agent listed them in never matters. */
|
|
12
|
+
function setOf(permissions) {
|
|
13
|
+
return [...new Set(permissions)].sort();
|
|
14
|
+
}
|
|
15
|
+
function isCreated(value) {
|
|
16
|
+
if (typeof value !== "object" || value === null)
|
|
17
|
+
return false;
|
|
18
|
+
const v = value;
|
|
19
|
+
return (typeof v.publicId === "string" &&
|
|
20
|
+
typeof v.confirmCode === "string" &&
|
|
21
|
+
typeof v.approvalUrl === "string" &&
|
|
22
|
+
typeof v.expiresAt === "string" &&
|
|
23
|
+
Number.isFinite(Date.parse(v.expiresAt)) &&
|
|
24
|
+
typeof v.pollMs === "number");
|
|
25
|
+
}
|
|
26
|
+
/** A 429's own `retryAfterMs`, else 0 (the poll then waits its normal interval). */
|
|
27
|
+
function retryAfterMsOf(body) {
|
|
28
|
+
const ms = body?.retryAfterMs;
|
|
29
|
+
return typeof ms === "number" && Number.isFinite(ms) && ms > 0 ? ms : 0;
|
|
30
|
+
}
|
|
31
|
+
export class PermissionRequests {
|
|
32
|
+
deps;
|
|
33
|
+
pending = new Map();
|
|
34
|
+
queue = Promise.resolve();
|
|
35
|
+
now;
|
|
36
|
+
sleep;
|
|
37
|
+
constructor(deps) {
|
|
38
|
+
this.deps = deps;
|
|
39
|
+
this.now = deps.now ?? Date.now;
|
|
40
|
+
this.sleep = deps.sleep ?? defaultSleep;
|
|
41
|
+
}
|
|
42
|
+
ask(args) {
|
|
43
|
+
const run = this.queue.then(() => this.askNow(args));
|
|
44
|
+
this.queue = run.catch(() => undefined);
|
|
45
|
+
return run;
|
|
46
|
+
}
|
|
47
|
+
async askNow(args) {
|
|
48
|
+
const permissions = setOf(args.permissions);
|
|
49
|
+
const key = JSON.stringify(permissions);
|
|
50
|
+
const existing = this.pending.get(key);
|
|
51
|
+
// DX-4418: a revoked or signed-out session never asks and never reads as pending; `guard` below answers its halt.
|
|
52
|
+
if (existing !== undefined && this.deps.access.isSignedIn()) {
|
|
53
|
+
return { answer: this.answer("approval_pending", existing) };
|
|
54
|
+
}
|
|
55
|
+
const guarded = await this.deps.access.guard(() => this.deps.request({ method: "POST", basePath: PERMISSION_REQUESTS_BASE_PATH, path: "", body: { permissions, reason: args.reason } }));
|
|
56
|
+
if ("halt" in guarded)
|
|
57
|
+
return { halt: guarded.halt };
|
|
58
|
+
const result = guarded.result;
|
|
59
|
+
// The dashboard's own 4xx (400, 409, 429 ...) goes back as it came: no retry, no default.
|
|
60
|
+
if (!result.ok)
|
|
61
|
+
return { result };
|
|
62
|
+
if (!isCreated(result.body)) {
|
|
63
|
+
throw new Error(`[danx-dashboard-mcp] POST ${PERMISSION_REQUESTS_BASE_PATH} answered without the request fields: ${JSON.stringify(result.body)}`);
|
|
64
|
+
}
|
|
65
|
+
const created = result.body;
|
|
66
|
+
this.pending.set(key, created);
|
|
67
|
+
void this.pollUntilDecided(key, created);
|
|
68
|
+
return { answer: this.answer("approval_required", created) };
|
|
69
|
+
}
|
|
70
|
+
answer(state, request) {
|
|
71
|
+
return {
|
|
72
|
+
state,
|
|
73
|
+
approvalUrl: request.approvalUrl,
|
|
74
|
+
confirmCode: request.confirmCode,
|
|
75
|
+
expiresAt: request.expiresAt,
|
|
76
|
+
instruction: state === "approval_required" ? APPROVAL_REQUIRED_INSTRUCTION : APPROVAL_PENDING_INSTRUCTION,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Polls until the request is decided or lapsed, then frees its set so a later call may ask again. Every ending is final: a
|
|
81
|
+
* halt (revoked, signed out, key lapsed) stops it silently, since the session no longer holds a key that could be granted.
|
|
82
|
+
*/
|
|
83
|
+
async pollUntilDecided(key, request) {
|
|
84
|
+
try {
|
|
85
|
+
await this.poll(request);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
this.deps.log?.(`[danx-dashboard-mcp] permission request poll ended: ${err.message}`);
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
if (this.pending.get(key) === request)
|
|
92
|
+
this.pending.delete(key);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async poll(request) {
|
|
96
|
+
const giveUpAt = Date.parse(request.expiresAt) + POLL_GRACE_MS;
|
|
97
|
+
let waitMs = request.pollMs;
|
|
98
|
+
for (;;) {
|
|
99
|
+
await this.sleep(waitMs);
|
|
100
|
+
waitMs = request.pollMs;
|
|
101
|
+
if (this.now() > giveUpAt)
|
|
102
|
+
return;
|
|
103
|
+
let guarded;
|
|
104
|
+
try {
|
|
105
|
+
// `guard` runs before EVERY claim (DX-4418): a revoked or signed-out session answers its halt without a request.
|
|
106
|
+
guarded = await this.deps.access.guard(() => this.deps.request({ method: "POST", basePath: PERMISSION_REQUESTS_BASE_PATH, path: `/${request.publicId}/claim` }));
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
// An outdated MCP will never get a different answer; any other failure is retried until the request lapses.
|
|
110
|
+
if (err instanceof McpOutdatedError)
|
|
111
|
+
throw err;
|
|
112
|
+
this.deps.log?.(`[danx-dashboard-mcp] permission request poll failed, retrying: ${err.message}`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if ("halt" in guarded)
|
|
116
|
+
return;
|
|
117
|
+
const claim = guarded.result;
|
|
118
|
+
if (claim.status === 429) {
|
|
119
|
+
waitMs = Math.max(request.pollMs, retryAfterMsOf(claim.body));
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (claim.status === 404)
|
|
123
|
+
return; // unknown or already reaped: nothing left to wait for
|
|
124
|
+
if (!claim.ok) {
|
|
125
|
+
throw new Error(`the dashboard refused the claim (${claim.status}): ${JSON.stringify(claim.body)}`);
|
|
126
|
+
}
|
|
127
|
+
const status = claim.body?.status;
|
|
128
|
+
switch (status) {
|
|
129
|
+
case "pending":
|
|
130
|
+
continue;
|
|
131
|
+
case "approved":
|
|
132
|
+
case "claimed":
|
|
133
|
+
case "denied":
|
|
134
|
+
case "expired":
|
|
135
|
+
return;
|
|
136
|
+
default:
|
|
137
|
+
throw new Error(`the dashboard answered the claim with an unknown status: ${JSON.stringify(claim.body)}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
package/dist/session-access.js
CHANGED
|
@@ -29,7 +29,7 @@ import { McpOutdatedError } from "./version-floor.js";
|
|
|
29
29
|
/** Longest a `plan_connect` call waits for an approval already asked for. */
|
|
30
30
|
export const AWAIT_APPROVAL_MS = 45_000;
|
|
31
31
|
/** The server reaps a lapsed request about a minute after expiry; polling past that finds nothing. */
|
|
32
|
-
const POLL_GRACE_MS = 90_000;
|
|
32
|
+
export const POLL_GRACE_MS = 90_000;
|
|
33
33
|
/** A live server rewrites its key file this often, so an in-use key never ages out of the 7-day prune. */
|
|
34
34
|
const KEEP_KEY_FILE_EVERY_MS = 60 * 60 * 1000;
|
|
35
35
|
/** The request's title when the agent gave none: the session id's first characters. */
|
|
@@ -51,7 +51,7 @@ const NO_SESSION_ID_ERROR = "this server has no CLAUDE_CODE_SESSION_ID, so it ca
|
|
|
51
51
|
const CLAIMED_LOST_ERROR = "the dashboard says this request was already claimed, but this process never received the key; " +
|
|
52
52
|
"start a new request";
|
|
53
53
|
const EXPIRED_NOTE = "The previous request expired before it was approved.";
|
|
54
|
-
const defaultSleep = (ms) => new Promise((resolve) => {
|
|
54
|
+
export const defaultSleep = (ms) => new Promise((resolve) => {
|
|
55
55
|
setTimeout(resolve, ms).unref();
|
|
56
56
|
});
|
|
57
57
|
const notReady = (answer, isError, halt) => ({
|
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.230",
|
|
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",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
34
|
-
"@thehammer/danx-session-telemetry": "0.1.
|
|
34
|
+
"@thehammer/danx-session-telemetry": "0.1.25",
|
|
35
35
|
"zod": "^3.25.76"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|