@thehammer/danx-dashboard-mcp 0.1.233 → 0.1.236
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 +2 -0
- package/dist/access-request.js +13 -0
- package/dist/handlers.js +22 -9
- package/dist/index.js +30 -5
- package/dist/session-access.js +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@ Stdio MCP server fronting danxbot's dashboard HTTP API. Since DX-3465 it exposes
|
|
|
10
10
|
|---|---|
|
|
11
11
|
| `DANXBOT_DASHBOARD_URL` | Dashboard base, e.g. `http://danxbot-dashboard:5555` (inside compose) or `http://localhost:5555` (host) |
|
|
12
12
|
| `DANX_DASHBOARD_CREDENTIAL` | A DISPATCH's credential: the bearer verbatim, substituted into the materialized `.mcp.json` from its overlay (DX-2862, `src/credential.ts`). Authoritative when set; it never signs out. An operator session sets nothing: with no credential the server boots SIGNED OUT, every tool except `plan_connect` returns one "not signed in" halt, and `plan_connect` requests access (below). The same resolver serves the `bridge` subcommand, so a session's tools and its event stream can never sign in as two different identities |
|
|
13
|
+
| `DANXBOT_TEAM` | OPTIONAL, non-secret (DX-4446): the team slug (or id) whose approvers see this session's access request in their danxbot site. Set it in the repo's `.mcp.json` env block or the shell. Unset, the request is answered by link only. A value that is not a team slug fails at boot; a team the dashboard does not know makes `plan_connect` answer `unknown_team` (fix `DANXBOT_TEAM`), never a silent link fallback |
|
|
13
14
|
| `DANX_REPO_NAME` | Repo half of the qualified board id |
|
|
14
15
|
| `DANXBOT_BOARD_NAME` | Board-slug half of the qualified board id (the dispatch injects `board.slug` here) |
|
|
15
16
|
|
|
@@ -38,6 +39,7 @@ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `m
|
|
|
38
39
|
| `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. |
|
|
39
40
|
| `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. |
|
|
40
41
|
| `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}`. Opt-in: granted to `slack-agent` and `work` (DX-4386) through `extraTools`. A screenshot of browser-visible work: first `primary: true`, then `replaces` at each milestone. |
|
|
41
43
|
| `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. |
|
|
42
44
|
|
|
43
45
|
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.
|
package/dist/access-request.js
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { assertVersionFloorSatisfied, MCP_MIN_VERSION_HEADER } from "./version-floor.js";
|
|
2
|
+
// DX-4446: mirrors TEAM_SLUG_PATTERN in src/lib/auth-rules.ts (this package cannot import the main src/); a team ref is a slug,
|
|
3
|
+
// and a team id's digits are a slug too. Keep the two in step.
|
|
4
|
+
const TEAM_REF_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
5
|
+
/** DX-4446: the `DANXBOT_TEAM` value, or a loud error naming the variable. */
|
|
6
|
+
export function assertMcpTeamRef(value) {
|
|
7
|
+
if (!TEAM_REF_PATTERN.test(value)) {
|
|
8
|
+
throw new Error(`[danx-dashboard-mcp] DANXBOT_TEAM "${value}" is not a team slug or id: lowercase letters and digits, joined by single hyphens`);
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
2
12
|
function baseOf(dashboardUrl) {
|
|
3
13
|
return dashboardUrl.replace(/\/+$/, "");
|
|
4
14
|
}
|
|
@@ -51,6 +61,9 @@ function isCreated(value) {
|
|
|
51
61
|
export async function createAccessRequest(fetchImpl, dashboardUrl, input) {
|
|
52
62
|
const url = `${baseOf(dashboardUrl)}/api/access-requests`;
|
|
53
63
|
const { status, headers, json } = await postPublic(fetchImpl, url, input);
|
|
64
|
+
if (status === 400 && input.team !== undefined && json?.error === "unknown_team") {
|
|
65
|
+
return { kind: "unknown_team", team: input.team };
|
|
66
|
+
}
|
|
54
67
|
if (status === 201) {
|
|
55
68
|
if (!isCreated(json)) {
|
|
56
69
|
throw new Error(`[danx-dashboard-mcp] POST ${url} answered 201 without the access request fields: ${JSON.stringify(json)}`);
|
package/dist/handlers.js
CHANGED
|
@@ -212,8 +212,9 @@ export function inferAttachmentMime(filename) {
|
|
|
212
212
|
* Attach a local file to a card. Reads the bytes from the dispatch's
|
|
213
213
|
* shared filesystem (this MCP server runs co-located with the agent that
|
|
214
214
|
* calls it), then forwards to the dashboard's `POST /api/issues/:id/attachments`
|
|
215
|
-
* route
|
|
216
|
-
*
|
|
215
|
+
* route (or, with `replaces`, `PUT /api/issues/:id/attachments/:aid`, which swaps that row's bytes in place) — the
|
|
216
|
+
* SAME ingest the dashboard UI's own upload uses: stores the
|
|
217
|
+
* bytes in S3, inserts or updates one attachment row, and (via the dashboard's
|
|
217
218
|
* existing outbound projection) auto-mirrors the file to the card's
|
|
218
219
|
* linked Trello card and Slack thread. Board-scoped; see `board`.
|
|
219
220
|
*
|
|
@@ -234,21 +235,33 @@ export async function attachFile(client, args, deps = {}) {
|
|
|
234
235
|
if (!isAbsolute(args.path)) {
|
|
235
236
|
throw new Error(`attach_file: path must be an absolute path (got "${args.path}")`);
|
|
236
237
|
}
|
|
238
|
+
// DX-4386: the replaced row keeps its stored name, so a filename override has nothing to apply to - refuse it loudly, before any read.
|
|
239
|
+
if (args.replaces !== undefined && args.filename !== undefined) {
|
|
240
|
+
throw new Error("attach_file: filename cannot be set with replaces (the replaced attachment keeps its name)");
|
|
241
|
+
}
|
|
237
242
|
const read = deps.readFile ?? readFile;
|
|
238
243
|
// A missing / unreadable file rejects here — no HTTP call is made, so no
|
|
239
244
|
// S3 upload, no row insert, and no projection are ever triggered.
|
|
240
245
|
const bytes = await read(args.path);
|
|
246
|
+
const contentTypeOf = (name) => (args.content_type?.trim() ? args.content_type.trim() : inferAttachmentMime(name));
|
|
247
|
+
const issuePath = `/${encodeURIComponent(args.card_id)}/attachments`;
|
|
248
|
+
// DX-4386: `primary` is the dashboard's own atomic option (one live cover, set in the write's own transaction); an absent flag sends no key.
|
|
249
|
+
const primary = args.primary === undefined ? {} : { primary: args.primary };
|
|
250
|
+
if (args.replaces !== undefined) {
|
|
251
|
+
return client.request({
|
|
252
|
+
method: "PUT",
|
|
253
|
+
basePath: "/api/issues",
|
|
254
|
+
path: `${issuePath}/${args.replaces}`,
|
|
255
|
+
body: { contentType: contentTypeOf(basename(args.path)), base64: bytes.toString("base64"), ...primary },
|
|
256
|
+
board: args.board,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
241
259
|
const filename = args.filename?.trim() ? args.filename.trim() : basename(args.path);
|
|
242
|
-
const contentType = args.content_type?.trim() ? args.content_type.trim() : inferAttachmentMime(filename);
|
|
243
260
|
return client.request({
|
|
244
261
|
method: "POST",
|
|
245
262
|
basePath: "/api/issues",
|
|
246
|
-
path:
|
|
247
|
-
body: {
|
|
248
|
-
filename,
|
|
249
|
-
contentType,
|
|
250
|
-
base64: bytes.toString("base64"),
|
|
251
|
-
},
|
|
263
|
+
path: issuePath,
|
|
264
|
+
body: { filename, contentType: contentTypeOf(filename), base64: bytes.toString("base64"), ...primary },
|
|
252
265
|
board: args.board,
|
|
253
266
|
});
|
|
254
267
|
}
|
package/dist/index.js
CHANGED
|
@@ -61,6 +61,12 @@
|
|
|
61
61
|
* otherwise this session's approved key (`session-key.ts`), else none: the
|
|
62
62
|
* server boots SIGNED OUT, every tool but `plan_connect`
|
|
63
63
|
* halts, and `plan_connect` requests access (`session-access.ts`)
|
|
64
|
+
* DANXBOT_TEAM OPTIONAL (DX-4446), non-secret: the team slug (or id) whose
|
|
65
|
+
* approvers see this session's access request in their danxbot
|
|
66
|
+
* site. Set it in the repo's `.mcp.json` env block or the shell.
|
|
67
|
+
* Unset: the request is answered by link only. Set but not a
|
|
68
|
+
* valid ref, or unknown to the dashboard: fails loud, never a
|
|
69
|
+
* silent fall back to the link flow.
|
|
64
70
|
* DANX_REPO_NAME repo half of the qualified board id
|
|
65
71
|
* DANXBOT_BOARD_NAME board-slug half of the qualified board id
|
|
66
72
|
* (the dispatch injects `board.slug` here);
|
|
@@ -83,6 +89,7 @@ import { EVENT_TEXT_SUBCOMMAND, runEventTextCommand } from "./event-text.js";
|
|
|
83
89
|
import { RESTART_NOTICE_SUBCOMMAND, runRestartNoticeCommand } from "./restart-notice.js";
|
|
84
90
|
import { resolveBootCredential } from "./credential.js";
|
|
85
91
|
import { SessionKeyUnusableError, deleteSessionKey } from "./session-key.js";
|
|
92
|
+
import { assertMcpTeamRef } from "./access-request.js";
|
|
86
93
|
import { SessionAccess } from "./session-access.js";
|
|
87
94
|
import { PermissionRequests } from "./permission-request.js";
|
|
88
95
|
import { withForbiddenHint } from "./forbidden-hint.js";
|
|
@@ -119,6 +126,19 @@ function readEnvOptional(name) {
|
|
|
119
126
|
const v = process.env[name];
|
|
120
127
|
return typeof v === "string" && v !== "" ? v : undefined;
|
|
121
128
|
}
|
|
129
|
+
/** DX-4446: optional `DANXBOT_TEAM`; set, it must be a valid team ref or boot fails loud (no fallback to the link flow). */
|
|
130
|
+
function readTeamRef() {
|
|
131
|
+
const team = readEnvOptional("DANXBOT_TEAM");
|
|
132
|
+
if (team === undefined)
|
|
133
|
+
return undefined;
|
|
134
|
+
try {
|
|
135
|
+
return assertMcpTeamRef(team);
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
console.error(err.message);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
122
142
|
// The dispatch config + HTTP client are LATE-BOUND (assigned by `boot()` at
|
|
123
143
|
// entrypoint, below). Tool registration happens at module import and the
|
|
124
144
|
// callbacks close over these `let` bindings, reading them at CALL time — so the
|
|
@@ -168,6 +188,7 @@ function boot() {
|
|
|
168
188
|
sessionId: session?.id,
|
|
169
189
|
repo,
|
|
170
190
|
clientVersion: readOwnVersion(),
|
|
191
|
+
team: readTeamRef(),
|
|
171
192
|
setToken: (token) => dashboardClient.setToken(token),
|
|
172
193
|
initial: resolveInitialCredential(dashboardUrl, session?.id),
|
|
173
194
|
log: (line) => console.error(line),
|
|
@@ -475,14 +496,18 @@ strictTool("attach_file", "Attach a LOCAL file to a card. This server reads and
|
|
|
475
496
|
"context. The attachment mirrors to the card's linked Trello card and Slack thread. 25 MB limit (413). " +
|
|
476
497
|
"Returns `{issue, attachment_id, url, thumbnail_url, is_primary}`: `url` is permanent, embed it in any " +
|
|
477
498
|
"markdown field as `` and it renders inline. Use it when a screenshot or file judges something faster than prose, or instead of pasting a " +
|
|
478
|
-
"large CSV or log into a card field.", {
|
|
499
|
+
"large CSV or log into a card field. A screenshot: first with `primary: true`, then `replaces: <id>` each milestone.", {
|
|
479
500
|
card_id: z.string().min(1).describe("The card id to attach the file to."),
|
|
480
|
-
path: z
|
|
481
|
-
.string()
|
|
482
|
-
.min(1)
|
|
483
|
-
.describe("Absolute path to a local file on the dispatch's shared filesystem (must start with `/`)."),
|
|
501
|
+
path: z.string().min(1).describe("Absolute local path on the machine this server runs on (`/tmp/a.png` or `C:\\x\\a.png`)."),
|
|
484
502
|
filename: z.string().min(1).optional().describe("Override for the stored filename; default: path's basename."),
|
|
485
503
|
content_type: z.string().min(1).optional().describe("Override for the stored MIME type; default: an extension guess."),
|
|
504
|
+
replaces: z
|
|
505
|
+
.number()
|
|
506
|
+
.int()
|
|
507
|
+
.positive()
|
|
508
|
+
.optional()
|
|
509
|
+
.describe("Id of this card's attachment to replace in place: it keeps its id, name and cover flag. Not with `filename`."),
|
|
510
|
+
primary: z.boolean().optional().describe("Make this attachment the card's single cover (lead image)."),
|
|
486
511
|
...boardField,
|
|
487
512
|
}, async (args) => guardedJson(() => attachFile(client, args)));
|
|
488
513
|
// ---------------- request_permission (DX-4434, parent DX-4432) ----------------
|
package/dist/session-access.js
CHANGED
|
@@ -47,6 +47,7 @@ const DENIED_INSTRUCTION = "The user denied the request. Do not retry unless the
|
|
|
47
47
|
const DASHBOARD_OUTDATED_ERROR = "this dashboard does not support request-and-approve yet (POST /api/access-requests answered 404): " +
|
|
48
48
|
"it must be updated before this session can sign in";
|
|
49
49
|
const RATE_LIMITED_ERROR = "too many access requests from this address; wait, then call plan_connect again";
|
|
50
|
+
const unknownTeamError = (team) => `the dashboard has no team "${team}" (DANXBOT_TEAM); fix DANXBOT_TEAM in this server's env (the repo's .mcp.json env block or the shell) and restart the session`;
|
|
50
51
|
const NO_SESSION_ID_ERROR = "this server has no CLAUDE_CODE_SESSION_ID, so it cannot request a session key";
|
|
51
52
|
const CLAIMED_LOST_ERROR = "the dashboard says this request was already claimed, but this process never received the key; " +
|
|
52
53
|
"start a new request";
|
|
@@ -212,10 +213,13 @@ export class SessionAccess {
|
|
|
212
213
|
repo: this.deps.repo,
|
|
213
214
|
machine: this.deps.machine ?? hostname(),
|
|
214
215
|
clientVersion: this.deps.clientVersion,
|
|
216
|
+
...(this.deps.team === undefined ? {} : { team: this.deps.team }),
|
|
215
217
|
});
|
|
216
218
|
switch (created.kind) {
|
|
217
219
|
case "dashboard_outdated":
|
|
218
220
|
return notReady({ state: "dashboard_outdated", error: DASHBOARD_OUTDATED_ERROR }, true);
|
|
221
|
+
case "unknown_team":
|
|
222
|
+
return notReady({ state: "unknown_team", team: created.team, error: unknownTeamError(created.team) }, true);
|
|
219
223
|
case "rate_limited":
|
|
220
224
|
return notReady({ state: "rate_limited", retryAfterSeconds: created.retryAfterSeconds, error: RATE_LIMITED_ERROR }, true);
|
|
221
225
|
case "refused":
|
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.236",
|
|
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",
|