@thehammer/danx-dashboard-mcp 0.1.315 → 0.1.317
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 +1 -1
- package/dist/design-screenshot.js +23 -14
- package/dist/index.js +1 -1
- package/dist/plugin-publish.js +4 -21
- package/dist/plugin-screenshot.js +71 -0
- package/dist/route-board-scope.generated.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `m
|
|
|
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
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. |
|
|
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
|
-
| `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
|
|
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. |
|
|
56
56
|
|
|
57
57
|
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.
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* then from this server's own working directory (`createRequire(<dir>/package.json)`, never a global lookup). The operator's rule is
|
|
7
7
|
* that "no preview image is a fine degraded mode": no browser, a failed launch, a failed capture or a PNG over 1 MiB all publish
|
|
8
8
|
* WITHOUT a screenshot and say why in the result (`none: <reason>`). That is the one stated degraded mode, reported and never silent,
|
|
9
|
-
* and it never fails the publish.
|
|
9
|
+
* and it never fails the publish. `captureWith` is the shared launch-drive-shoot-close path (DX-5256): `plugin-screenshot.ts` drives it online
|
|
10
|
+
* against the dashboard's preview page, where a design capture runs offline against its own HTML.
|
|
10
11
|
*/
|
|
11
12
|
import { createRequire } from "node:module";
|
|
12
13
|
import path from "node:path";
|
|
@@ -50,10 +51,11 @@ function resolvePlaywright(rootAbs, cwd) {
|
|
|
50
51
|
return null;
|
|
51
52
|
}
|
|
52
53
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
54
|
+
* The one capture path (DX-4077, DX-5256): resolve a browser, open a 1280x800 device-scale-1 page, let `drive` bring it to the state to shoot,
|
|
55
|
+
* take the viewport PNG, and close the browser in a `finally` on every path. `offline` cuts the network (a design page is self-contained; a
|
|
56
|
+
* plugin's render needs the dashboard's own page and shims).
|
|
55
57
|
*/
|
|
56
|
-
export async function
|
|
58
|
+
export async function captureWith(where, offline, drive) {
|
|
57
59
|
let playwright;
|
|
58
60
|
try {
|
|
59
61
|
playwright = resolvePlaywright(where.rootAbs, where.cwd);
|
|
@@ -72,16 +74,11 @@ export async function captureScreenshot(html, where) {
|
|
|
72
74
|
return none(`the browser could not be launched (${playwright.from}): ${oneLine(err)}`);
|
|
73
75
|
}
|
|
74
76
|
try {
|
|
75
|
-
const context = await browser.newContext({
|
|
76
|
-
viewport: { width: SCREENSHOT_WIDTH, height: SCREENSHOT_HEIGHT },
|
|
77
|
-
deviceScaleFactor: 1,
|
|
78
|
-
offline: true,
|
|
79
|
-
});
|
|
77
|
+
const context = await browser.newContext({ viewport: { width: SCREENSHOT_WIDTH, height: SCREENSHOT_HEIGHT }, deviceScaleFactor: 1, offline });
|
|
80
78
|
const page = await context.newPage();
|
|
81
|
-
await page
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
await page.waitForFunction("window.__danxDesignReady === true", undefined, { timeout: READY_WAIT_MS }).catch(() => undefined);
|
|
79
|
+
const refusal = await drive(page);
|
|
80
|
+
if (typeof refusal === "string")
|
|
81
|
+
return none(refusal);
|
|
85
82
|
const png = await page.screenshot({ type: "png" });
|
|
86
83
|
if (png.length > MAX_SCREENSHOT_BYTES) {
|
|
87
84
|
return none(`the screenshot is ${png.length} bytes, over the ${MAX_SCREENSHOT_BYTES} byte (1 MiB) limit`);
|
|
@@ -93,6 +90,18 @@ export async function captureScreenshot(html, where) {
|
|
|
93
90
|
}
|
|
94
91
|
finally {
|
|
95
92
|
// A browser that will not close must not fail a publish whose screenshot is already decided.
|
|
96
|
-
await browser.close().catch((err) => console.error(`[danx-dashboard-mcp]
|
|
93
|
+
await browser.close().catch((err) => console.error(`[danx-dashboard-mcp] could not close the browser: ${oneLine(err)}`));
|
|
97
94
|
}
|
|
98
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Capture `html` as a 1280x800 viewport PNG (device scale 1, network disabled). The page is `setContent`-ed, then given a short wait
|
|
98
|
+
* for the harness's ready message (the page has loaded either way).
|
|
99
|
+
*/
|
|
100
|
+
export function captureScreenshot(html, where) {
|
|
101
|
+
return captureWith(where, true, async (page) => {
|
|
102
|
+
await page.addInitScript(READY_LISTENER);
|
|
103
|
+
await page.setContent(html, { waitUntil: "load" });
|
|
104
|
+
// The page has loaded; a design that never posts ready (it threw, or its harness is older) is still captured as it stands.
|
|
105
|
+
await page.waitForFunction("window.__danxDesignReady === true", undefined, { timeout: READY_WAIT_MS }).catch(() => undefined);
|
|
106
|
+
});
|
|
107
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -678,7 +678,7 @@ strictTool("design_publish", "Build a design against this repo's REAL components
|
|
|
678
678
|
// result, which the dashboard cannot do. The build, archive and upload are `@danxbot/plugin-sdk/publish`, shared with the `danx-plugin`
|
|
679
679
|
// CLI; the screenshot is this tool's alone. Opt-in for the same reason as design_publish (`tool-defs.ts`'s `MCP_SERVER_OPT_IN_TOOLS`).
|
|
680
680
|
strictTool("plugin_publish", "Build a card-type plugin project (Vite, `danxPlugin()` from `@danxbot/plugin-sdk/vite`, a lockfile) with its own `npm run build` and publish it as a new revision of the team's plugin " +
|
|
681
|
-
"`plugin` (a new key creates it): uploads `dist/`, the source `.tar.gz` and a screenshot of the
|
|
681
|
+
"`plugin` (a new key creates it): uploads `dist/`, the source `.tar.gz` and a screenshot of the plugin's own render (its `CardView` drawn with a sample card on the dashboard's `/plugin-preview` page). " +
|
|
682
682
|
"Returns `{ok: true, status, body: {plugin, plugin_id, revision_id, revision_no, approval_url, screenshot}}`: `approval_url` is Settings > Plugins, where a team admin approves the revision; you cannot. " +
|
|
683
683
|
"`screenshot` is `captured` or `none: <reason>` (the publish still succeeds). A build error throws with the build's output; a refused upload comes back verbatim as `{ok: false, status, body, plugin, step}`.", {
|
|
684
684
|
dir: z.string().min(1).describe("The project directory: absolute, or relative to this server's cwd."),
|
package/dist/plugin-publish.js
CHANGED
|
@@ -4,42 +4,25 @@
|
|
|
4
4
|
*
|
|
5
5
|
* The build, the archive and the upload are `@danxbot/plugin-sdk/publish`, the same code the `danx-plugin publish` CLI runs; this module adds
|
|
6
6
|
* what only an agent's tool has: the dashboard client it already holds (its credential, trace and session headers) as the transport, and the
|
|
7
|
-
* screenshot
|
|
8
|
-
*
|
|
9
|
-
* failed capture publishes WITHOUT a screenshot and says why in the result (`none: <reason>`), the same stated degraded mode as `design_publish`.
|
|
7
|
+
* screenshot: the built plugin's own render on the dashboard's `/plugin-preview` page (`plugin-screenshot.ts`, DX-5256). No browser, a dashboard
|
|
8
|
+
* without that page or a failed render publishes WITHOUT a screenshot and says why in the result (`none: <reason>`), the same stated degraded mode as `design_publish`.
|
|
10
9
|
*
|
|
11
10
|
* ORDER, so a failure leaves as little behind as it can: the arguments (a blank note is refused here, before the build), then the build (a failed build uploads nothing), the archive, the
|
|
12
11
|
* screenshot, and only then the upload. The dashboard is the only validator; its refusal comes back verbatim with the step it refused.
|
|
13
12
|
*/
|
|
14
|
-
import { readFile } from "node:fs/promises";
|
|
15
13
|
import path from "node:path";
|
|
16
14
|
import { archiveSource, assertNote, buildPlugin, uploadRevision, withScreenshot } from "@danxbot/plugin-sdk/publish";
|
|
17
|
-
import {
|
|
18
|
-
/** The page in a plugin project's root that is screenshotted at publish. */
|
|
19
|
-
export const PREVIEW_FILE = "preview.html";
|
|
15
|
+
import { capturePluginRender } from "./plugin-screenshot.js";
|
|
20
16
|
/** The agent's authenticated dashboard client as the publish flow's transport. */
|
|
21
17
|
function clientTransport(client) {
|
|
22
18
|
return { request: ({ method, path: route, query, body }) => client.request({ method, path: route, basePath: "", query, body }) };
|
|
23
19
|
}
|
|
24
|
-
async function readPreview(projectDir) {
|
|
25
|
-
try {
|
|
26
|
-
return await readFile(path.join(projectDir, PREVIEW_FILE), "utf8");
|
|
27
|
-
}
|
|
28
|
-
catch (err) {
|
|
29
|
-
if (err.code === "ENOENT")
|
|
30
|
-
return null;
|
|
31
|
-
throw err;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
20
|
export async function pluginPublish(client, args, deps) {
|
|
35
21
|
assertNote(args.note);
|
|
36
22
|
const projectDir = path.resolve(deps.cwd, args.dir);
|
|
37
23
|
const built = await buildPlugin(projectDir);
|
|
38
24
|
const source = await archiveSource(projectDir);
|
|
39
|
-
const
|
|
40
|
-
const shot = preview === null
|
|
41
|
-
? { png_base64: null, screenshot: `none: the project has no ${PREVIEW_FILE}` }
|
|
42
|
-
: await captureScreenshot(preview, { rootAbs: projectDir, cwd: deps.cwd });
|
|
25
|
+
const shot = await capturePluginRender(built, deps.baseUrl, { rootAbs: projectDir, cwd: deps.cwd });
|
|
43
26
|
const toUpload = shot.png_base64 === null ? built : withScreenshot(built, Buffer.from(shot.png_base64, "base64"));
|
|
44
27
|
const result = await uploadRevision(clientTransport(client), {
|
|
45
28
|
plugin: args.plugin,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { captureWith } from "./design-screenshot.js";
|
|
2
|
+
/** The route the app draws the plugin on, the prefix its files are requested under, the window global it is handed the manifest on, and the attribute the page reports through: restated from `frontend/src/lib/plugin-preview-contract.ts` (this package cannot import the app); `src/dispatch/design-publish-restated-constants.test.ts` ties them. */
|
|
3
|
+
export const PLUGIN_PREVIEW_ROUTE = "/plugin-preview";
|
|
4
|
+
export const PLUGIN_PREVIEW_FILES = "/__plugin-preview__/";
|
|
5
|
+
export const PLUGIN_PREVIEW_ATTRIBUTE = "data-plugin-preview";
|
|
6
|
+
export const PLUGIN_PREVIEW_MANIFEST_GLOBAL = "__DANX_PLUGIN_PREVIEW_MANIFEST__";
|
|
7
|
+
/** The ceiling on one render, from page load to the page saying `ready` or `failed`. A page that reports is waited on only as long as it takes. */
|
|
8
|
+
export const RENDER_WAIT_MS = 30_000;
|
|
9
|
+
/** The content types of the files a plugin may carry: restated from the server's `PLUGIN_FILE_TYPES` (`src/plugins/manifest-validation.ts`, minus its charset suffix); `src/dispatch/design-publish-restated-constants.test.ts` ties them. */
|
|
10
|
+
export const CONTENT_TYPES = {
|
|
11
|
+
js: "text/javascript",
|
|
12
|
+
css: "text/css",
|
|
13
|
+
map: "application/json",
|
|
14
|
+
json: "application/json",
|
|
15
|
+
svg: "image/svg+xml",
|
|
16
|
+
png: "image/png",
|
|
17
|
+
webp: "image/webp",
|
|
18
|
+
woff2: "font/woff2",
|
|
19
|
+
};
|
|
20
|
+
function contentTypeOf(filePath) {
|
|
21
|
+
return CONTENT_TYPES[filePath.slice(filePath.lastIndexOf(".") + 1).toLowerCase()] ?? "application/octet-stream";
|
|
22
|
+
}
|
|
23
|
+
/** The build's files by path. Only what the build listed can be requested. */
|
|
24
|
+
function servedFiles(built) {
|
|
25
|
+
return new Map(built.files.map((file) => [file.path, file.bytes]));
|
|
26
|
+
}
|
|
27
|
+
async function answer(route, prefix, served) {
|
|
28
|
+
const { pathname } = new URL(route.request().url());
|
|
29
|
+
let requested;
|
|
30
|
+
try {
|
|
31
|
+
requested = decodeURIComponent(pathname.slice(prefix.length));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
requested = ""; // a malformed percent-escape names no file
|
|
35
|
+
}
|
|
36
|
+
const bytes = served.get(requested);
|
|
37
|
+
if (bytes === undefined) {
|
|
38
|
+
await route.fulfill({ status: 404, contentType: "text/plain", body: `no such plugin file: ${pathname}` });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
await route.fulfill({ status: 200, contentType: contentTypeOf(pathname), body: bytes });
|
|
42
|
+
}
|
|
43
|
+
const REPORT_EXPRESSION = `(() => { const el = document.querySelector("[${PLUGIN_PREVIEW_ATTRIBUTE}]"); return { state: el ? el.getAttribute("${PLUGIN_PREVIEW_ATTRIBUTE}") : null, text: el ? el.textContent : "" }; })()`;
|
|
44
|
+
/** Settled: the page said `ready` or `failed`, and nothing on it is still loading (`aria-busy`). */
|
|
45
|
+
const SETTLED_EXPRESSION = `(() => { const el = document.querySelector("[${PLUGIN_PREVIEW_ATTRIBUTE}]"); const state = el && el.getAttribute("${PLUGIN_PREVIEW_ATTRIBUTE}"); return (state === "ready" || state === "failed") && !document.querySelector('[aria-busy="true"]'); })()`;
|
|
46
|
+
/**
|
|
47
|
+
* Renders the built plugin on the dashboard's preview page and screenshots it.
|
|
48
|
+
* @param dashboardUrl the dashboard's base URL (`ClientConfig.baseUrl`).
|
|
49
|
+
*/
|
|
50
|
+
export async function capturePluginRender(built, dashboardUrl, where) {
|
|
51
|
+
const origin = new URL(dashboardUrl).origin;
|
|
52
|
+
const served = servedFiles(built);
|
|
53
|
+
return captureWith(where, false, async (page) => {
|
|
54
|
+
await page.addInitScript(`window[${JSON.stringify(PLUGIN_PREVIEW_MANIFEST_GLOBAL)}] = ${JSON.stringify({ entry: built.manifest.entry, styles: built.manifest.styles })};`);
|
|
55
|
+
await page.route(`${origin}${PLUGIN_PREVIEW_FILES}**`, (route) => answer(route, PLUGIN_PREVIEW_FILES, served));
|
|
56
|
+
const response = await page.goto(`${origin}${PLUGIN_PREVIEW_ROUTE}`, { waitUntil: "load" });
|
|
57
|
+
const status = response?.status();
|
|
58
|
+
if (status !== undefined && (status < 200 || status > 299))
|
|
59
|
+
return `the dashboard answered ${status} for its ${PLUGIN_PREVIEW_ROUTE} page; a dashboard older than this tool does not serve it`;
|
|
60
|
+
try {
|
|
61
|
+
await page.waitForFunction(SETTLED_EXPRESSION, undefined, { timeout: RENDER_WAIT_MS });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
const report = await page.evaluate(REPORT_EXPRESSION);
|
|
65
|
+
return `the dashboard's ${PLUGIN_PREVIEW_ROUTE} page did not say ready within ${RENDER_WAIT_MS} ms (it said ${report.state ?? "nothing"}); the page may be missing, a plugin file may not have loaded, or something on it is still loading`;
|
|
66
|
+
}
|
|
67
|
+
const report = await page.evaluate(REPORT_EXPRESSION);
|
|
68
|
+
if (report.state === "failed")
|
|
69
|
+
return `the plugin did not render: ${report.text.trim().slice(0, 300)}`;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -38,6 +38,7 @@ export const GET_ROUTE_BOARD_SCOPE = [
|
|
|
38
38
|
["/api/plans/:planId/usage-series", "refuse"],
|
|
39
39
|
["/api/plans/mine/architecture/sections", "refuse"],
|
|
40
40
|
["/api/plans/mine/records", "refuse"],
|
|
41
|
+
["/api/platform/feedback", "refuse"],
|
|
41
42
|
["/api/platform/signup-codes", "refuse"],
|
|
42
43
|
["/api/platform/teams", "refuse"],
|
|
43
44
|
["/api/plugins", "refuse"],
|
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.317",
|
|
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",
|