@thehammer/danx-dashboard-mcp 0.1.317 → 0.1.319

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 CHANGED
@@ -35,7 +35,7 @@ The hint: every dashboard result that is `403 {error: "forbidden", permission}`
35
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
36
 
37
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.
38
+ - **Permission tools** (`attach_file` needs `cards.edit` for both targets): shown once the key holds the permission the tool's dashboard route needs.
39
39
  - **Callback tool** (`danxbot_slack_reply`, the only one, DX-4525): shown when the worker injected its URL (`DANXBOT_SLACK_REPLY_URL`, `<dashboard>/api/slack/reply`, no dispatch id) AND the key holds `slack.threads.post`, so a non-Slack dispatch never sees it. It posts with the dispatch's own credential (`DANX_DASHBOARD_CREDENTIAL`), the same one `danxbot_api` uses.
40
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
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.
@@ -49,7 +49,7 @@ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `m
49
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. |
50
50
  | `danxbot_api_list` | No args. Lists the routes this caller's credential may reach as `{routes: ["METHOD /path"]}` (one bare string per route, exactly what `danxbot_api_spec` takes; a dispatch's own callbacks are in it) — call this first when you don't know the route for what you need to do. |
51
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. |
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. |
52
+ | `attach_file` | `{target?, 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}`. `target: "inline_image"` (DX-5241) instead uploads a local png/jpg/gif/webp/avif (25 MB, no SVG) to `POST /api/uploads/images` with no card, takes only `path`, and replies `{url, markdown}` for embedding in any markdown body such as a `POST /api/feedback` body. 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. |
53
53
  | `design_publish` | `{target, note, dir, card_id, design_no | design_title}` — builds a design with the TARGET repo's own Vite (resolved from the target's root at run time: this package depends on neither `vite` nor `playwright`) and publishes it as a revision of one of an existing card's designs, or as a new design on it (DX-4077, DX-5164, DX-5193): a card holds several designs, so pass exactly one of `design_no` (add a revision to that design, `POST /api/issues/<card>/designs/<no>/revisions`) or `design_title` (start a new design, `POST /api/issues/<card>/designs`; the title is fixed for good), both-or-neither is refused before anything is built; the answer carries `{card_id, design_no, revision_no, dashboard_url, local_html_path, screenshot}`. `target` names one of the repo's design targets, read from the dashboard (`GET /api/repos/<repo>`, edited in Settings > Board > General), never from a file; `dir` holds `Design.tsx` under the target's root. The repo is the board's `repo_name` (`GET /api/boards/<board>`). The build runs in `<root>/.danx-design/<build-id>/` (removed afterwards), in a short-lived `node` child process that exits when the build ends, so the server never holds the design's worktree files open and no esbuild outlives the publish (DX-4727), the page is written to `<tmpdir>/danx-design/<build-id>.html` before the upload, and a 1280x800 screenshot is taken when Playwright resolves from the target or this server's cwd (`screenshot: "none: <reason>"` otherwise, never a failed publish). `card_id` is required and names an existing card (any but an Epic or Feature); the tool never creates a card, and a refused revision comes back verbatim with the `card_id`. Shown to a key holding `cards.edit`; opt-in, so a dispatch profile receives it only when its catalog selection names it (none does yet). |
54
54
  | `plugin_publish` | `{dir, plugin, note, name?}` — builds a card-type plugin project with its own `npm run build` in a short-lived child process, archives its source (`source.tar.gz`: git-tracked plus untracked-unignored files, else what the top-level `.gitignore` leaves, minus `node_modules`, `.git`, `dist`; never `.env*`; a lockfile is required) and uploads both as a new revision of the plugin `plugin` (created on the first publish), `POST /api/plugins/<id>/revisions` (DX-5237). The build, archive and upload are `@danxbot/plugin-sdk/publish`, the code `danx-plugin publish` runs too. The answer carries `{plugin, plugin_id, revision_id, revision_no, approval_url, screenshot}`; `approval_url` is Settings > Plugins, where a team admin approves the revision. A 1280x800 screenshot of the plugin's own render is taken when Playwright resolves from the project or this server's cwd: a headless browser opens the dashboard's public `/plugin-preview` page, which draws the built plugin's `CardView` on the app's own runtime (React, `@danxbot/ui`, the card kit) with a sample card, the build served from memory so nothing is uploaded first, and the capture waits until the page reports `ready` (`screenshot: "none: <reason>"` when there is no browser, the dashboard predates the page, or the plugin fails to render; never a failed publish). A refused upload comes back verbatim with the `step` refused. Shown to a key holding `plugins.publish`; opt-in like `design_publish`. |
55
55
  | `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. |
package/dist/handlers.js CHANGED
@@ -22,7 +22,7 @@
22
22
  * guess.
23
23
  */
24
24
  import { basename, extname, isAbsolute } from "node:path";
25
- import { readFile } from "node:fs/promises";
25
+ import { readFile, stat } from "node:fs/promises";
26
26
  // ---------------- plan_connect (DX-2683) ----------------
27
27
  // DX-3465 (PLN-11 R-25) — every other per-operation handler that used to
28
28
  // live in this file (issue_*, plan_* except plan_connect, brief_*,
@@ -206,11 +206,26 @@ const MIME_BY_EXT = {
206
206
  ".gz": "application/gzip",
207
207
  ".tgz": "application/gzip",
208
208
  };
209
+ /**
210
+ * DX-5241: the types and ceiling of `POST /api/uploads/images` (`src/dashboard/uploads-images-route.ts`, no SVG). This package is a separate
211
+ * published artifact and cannot import them, so they are restated here - keep them in sync with that route. The route enforces both too;
212
+ * checking here first gives the agent a plain refusal without reading or sending a file the route would refuse.
213
+ */
214
+ export const INLINE_IMAGE_MIME_BY_EXT = {
215
+ ".png": "image/png",
216
+ ".jpg": "image/jpeg",
217
+ ".jpeg": "image/jpeg",
218
+ ".gif": "image/gif",
219
+ ".webp": "image/webp",
220
+ ".avif": "image/avif",
221
+ };
222
+ export const MAX_INLINE_IMAGE_BYTES = 25 * 1024 * 1024;
209
223
  export function inferAttachmentMime(filename) {
210
224
  return MIME_BY_EXT[extname(filename).toLowerCase()] ?? "application/octet-stream";
211
225
  }
212
226
  /**
213
- * Attach a local file to a card. Reads the bytes from the dispatch's
227
+ * Attach a local file to a card (`target` "card", the default; replies with the attachment row) or, with `target: "inline_image"`, upload
228
+ * a local image to `POST /api/uploads/images` and reply `{url, markdown}` (see `uploadInlineImage`). Reads the bytes from the dispatch's
214
229
  * shared filesystem (this MCP server runs co-located with the agent that
215
230
  * calls it), then forwards to the dashboard's `POST /api/issues/:id/attachments`
216
231
  * route (or, with `replaces`, `PUT /api/issues/:id/attachments/:aid`, which swaps that row's bytes in place) — the
@@ -236,11 +251,16 @@ export async function attachFile(client, args, deps = {}) {
236
251
  if (!isAbsolute(args.path)) {
237
252
  throw new Error(`attach_file: path must be an absolute path (got "${args.path}")`);
238
253
  }
254
+ const read = deps.readFile ?? readFile;
255
+ if (args.target === "inline_image")
256
+ return uploadInlineImage(client, args, read, deps.fileSize ?? (async (p) => (await stat(p)).size));
239
257
  // DX-4386: the replaced row keeps its stored name, so a filename override has nothing to apply to - refuse it loudly, before any read.
240
258
  if (args.replaces !== undefined && args.filename !== undefined) {
241
259
  throw new Error("attach_file: filename cannot be set with replaces (the replaced attachment keeps its name)");
242
260
  }
243
- const read = deps.readFile ?? readFile;
261
+ if (typeof args.card_id !== "string" || args.card_id.trim() === "") {
262
+ throw new Error("attach_file: card_id is required unless target is inline_image");
263
+ }
244
264
  // A missing / unreadable file rejects here — no HTTP call is made, so no
245
265
  // S3 upload, no row insert, and no projection are ever triggered.
246
266
  const bytes = await read(args.path);
@@ -266,3 +286,36 @@ export async function attachFile(client, args, deps = {}) {
266
286
  board: args.board,
267
287
  });
268
288
  }
289
+ /**
290
+ * DX-5241 / PLAN-47 CAV-1: upload a local image to `POST /api/uploads/images` and answer its permanent URL plus the markdown to embed it
291
+ * (feedback bodies, any markdown field). `danxbot_api` is JSON only, so the bytes are read here, never through the agent's context.
292
+ * Everything that makes no sense for an inline image (a card, a replace, a cover flag, a name or type override) is refused before any read.
293
+ */
294
+ async function uploadInlineImage(client, args, read, fileSize) {
295
+ for (const key of ["card_id", "filename", "content_type", "replaces", "primary", "board"]) {
296
+ if (args[key] !== undefined)
297
+ throw new Error(`attach_file: ${key} cannot be set with target inline_image`);
298
+ }
299
+ const ext = extname(args.path).toLowerCase();
300
+ const contentType = INLINE_IMAGE_MIME_BY_EXT[ext];
301
+ if (!contentType) {
302
+ throw new Error(`attach_file: inline_image accepts only ${Object.keys(INLINE_IMAGE_MIME_BY_EXT).join(", ")} files (got "${ext || basename(args.path)}"); no SVG`);
303
+ }
304
+ // Stat first so an oversized file is refused without being read into memory.
305
+ const size = await fileSize(args.path);
306
+ if (size === 0)
307
+ throw new Error(`attach_file: "${args.path}" is empty`);
308
+ if (size > MAX_INLINE_IMAGE_BYTES) {
309
+ throw new Error(`attach_file: "${args.path}" is ${size} bytes; the limit is ${MAX_INLINE_IMAGE_BYTES} (25 MB)`);
310
+ }
311
+ const bytes = await read(args.path);
312
+ const result = await client.request({ method: "POST", basePath: "/api/uploads", path: "/images", rawBody: { bytes, contentType } });
313
+ if (!result.ok)
314
+ return result;
315
+ const url = result.body?.url;
316
+ if (typeof url !== "string" || url === "") {
317
+ throw new Error("attach_file: the image upload answered no url");
318
+ }
319
+ const alt = basename(args.path, extname(args.path)).replace(/[[\]]/g, "");
320
+ return { ...result, body: { url, markdown: `![${alt}](${url})` } };
321
+ }
@@ -62,17 +62,23 @@ export class DashboardHttpClient {
62
62
  if (args.background === true) {
63
63
  headers[PLUGIN_BACKGROUND_HEADER] = "1";
64
64
  }
65
- let bodyString;
66
- if (args.body !== undefined) {
65
+ let payload;
66
+ if (args.rawBody !== undefined) {
67
+ if (args.body !== undefined)
68
+ throw new Error("[danx-dashboard-mcp] a request sends `body` or `rawBody`, never both");
69
+ headers["Content-Type"] = args.rawBody.contentType;
70
+ payload = new Blob([args.rawBody.bytes]);
71
+ }
72
+ else if (args.body !== undefined) {
67
73
  headers["Content-Type"] = "application/json";
68
- bodyString = JSON.stringify(args.body);
74
+ payload = JSON.stringify(args.body);
69
75
  }
70
76
  let res;
71
77
  try {
72
78
  res = await this.fetchImpl(url, {
73
79
  method: args.method,
74
80
  headers,
75
- body: bodyString,
81
+ body: payload,
76
82
  });
77
83
  }
78
84
  catch (err) {
package/dist/index.js CHANGED
@@ -622,12 +622,15 @@ strictTool("danxbot_api_spec",
622
622
  // out plus a role-by-role grant, see `tool-defs.ts`'s `MCP_SERVER_OPT_IN_TOOLS`
623
623
  // — a role needs local-file-upload only when it deals with files an agent
624
624
  // might hold locally, e.g. the Slack recovery agent's CSV path).
625
- strictTool("attach_file", "Attach a LOCAL file to a card. This server reads and uploads the bytes, so they never pass through your " +
626
- "context. The attachment mirrors to the card's linked Trello card and Slack thread. 25 MB limit (413). " +
627
- "Returns `{issue, attachment_id, url, thumbnail_url, is_primary}`: `url` is permanent, embed it in any " +
625
+ strictTool("attach_file", "Upload a LOCAL file. This server reads and uploads the bytes, so they never pass through your context. " +
626
+ "`target: \"card\"` (default) attaches it to a card; `target: \"inline_image\"` uploads an image for a markdown body and replies `{url, markdown}` (below). The card attachment mirrors to the card's linked Trello card and Slack thread. 25 MB limit (413). " +
627
+ "Card target returns `{issue, attachment_id, url, thumbnail_url, is_primary}`: `url` is permanent, embed it in any " +
628
628
  "markdown field as `![description](url)` and it renders inline. Use it when a screenshot or file judges something faster than prose, or instead of pasting a " +
629
- "large CSV or log into a card field. A screenshot: first with `primary: true`, then `replaces: <id>` each milestone.", {
630
- card_id: z.string().min(1).describe("The card id to attach the file to."),
629
+ "large CSV or log into a card field. A screenshot: first with `primary: true`, then `replaces: <id>` each milestone. " +
630
+ "`target: \"inline_image\"` instead uploads a local png/jpeg/gif/webp/avif (25 MB) with NO card and returns `{url, markdown}`: put the " +
631
+ "markdown in any markdown body, notably a `POST /api/feedback` body (`danxbot_api` is JSON only and cannot upload bytes).", {
632
+ target: z.enum(["card", "inline_image"]).optional().describe("`card` (default): attach to a card. `inline_image`: upload an image for a markdown body; takes only `path`."),
633
+ card_id: z.string().min(1).optional().describe("The card id to attach the file to. Required for target `card`; not allowed with `inline_image`."),
631
634
  path: z.string().min(1).describe("Absolute local path on the machine this server runs on (`/tmp/a.png` or `C:\\x\\a.png`)."),
632
635
  filename: z.string().min(1).optional().describe("Override for the stored filename; default: path's basename."),
633
636
  content_type: z.string().min(1).optional().describe("Override for the stored MIME type; default: an extension guess."),
@@ -20,6 +20,7 @@ export const GET_ROUTE_BOARD_SCOPE = [
20
20
  ["/api/failure-categories/ambiguous", "refuse"],
21
21
  ["/api/failure-categories/uncategorized", "refuse"],
22
22
  ["/api/failure-signatures", "refuse"],
23
+ ["/api/feedback/mine", "refuse"],
23
24
  ["/api/issues", "named"],
24
25
  ["/api/issues/:id/problems", "refuse"],
25
26
  ["/api/issues/:id/usage-runs", "refuse"],
@@ -40,7 +40,7 @@ export const TOOL_ACCESS = {
40
40
  danxbot_api: BOOTSTRAP,
41
41
  danxbot_api_list: BOOTSTRAP,
42
42
  danxbot_api_spec: BOOTSTRAP,
43
- // `POST /api/issues/:id/attachments`
43
+ // `POST /api/issues/:id/attachments` (target `card`) or `POST /api/uploads/images` (target `inline_image`, replies `{url, markdown}`); one gate for both (DX-5241)
44
44
  attach_file: needs("cards.edit"),
45
45
  // DX-4077 - builds in the agent's own checkout, then `POST /api/issues/:id/designs` (a new design) or `POST /api/issues/:id/designs/:designNo/revisions` (the next revision), both `cards.edit` (DX-4076, DX-5193). The routes it
46
46
  // reads on the way (`GET /api/boards/:id`, `GET /api/repos/:id`, `GET /api/issues/:id`) and the card it creates are separate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.317",
3
+ "version": "0.1.319",
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, request_permission, attach_file, and danxbot_slack_reply) (DX-3465, PLN-11 R-25).",
5
5
  "license": "MIT",
6
6
  "type": "module",