@thehammer/danx-dashboard-mcp 0.1.341 → 0.1.343
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/design-screenshot.js +1 -1
- package/dist/docs-tools.js +134 -0
- package/dist/index.js +17 -0
- package/dist/route-board-scope.generated.js +2 -0
- package/dist/tool-access.js +4 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -52,6 +52,8 @@ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `m
|
|
|
52
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
|
+
| `docs_search` | `{source, query, limit?}` — searches a docs root with the shared `@danxbot/docs` engine and answers `{query, results: [{id, type, title, summary, file, bytes, lines, look_in?}]}`, best first, **never a page body** (DX-5582). `source` is `{kind: "local"}` (this checkout's `docs/pages`, the server's cwd), `{kind: "dir", path}` (another docs root on this machine) or `{kind: "remote", repo}` (a team repo's hosted docs, `GET /api/docs/<repo>/search`, `docs.view`, the repo must be on the caller's team's boards). A local or dir `file` is an absolute path; a remote `file` is the page's path inside that repo's docs. `bytes` and `lines` let the agent read the file whole or with offset/limit. Shown to a key holding `docs.view`; opt-in like `plugin_publish`. |
|
|
56
|
+
| `docs_read` | `{source, id}` — answers `{id, path, bytes, lines}`: the page as a file on this machine, never its text. A local or dir page is read where it is (nothing copied); a remote page is fetched from `GET /api/docs/<repo>/pages/<id>` and written to `<tmp>/danx-docs/<repo>/<id>.md`, replaced on every call. The agent then reads it with its own file tools. An unknown id is refused with the nearest ids. Same gate and opt-in as `docs_search` (DX-5582). |
|
|
55
57
|
| `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
58
|
|
|
57
59
|
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.
|
|
@@ -26,7 +26,7 @@ const READY_LISTENER = `window.__danxDesignReady = false; window.addEventListene
|
|
|
26
26
|
* is restated here as a stub: `holdReady`, a once-only `ready` and ready-on-`load` unless held, posting the same ready message the listener
|
|
27
27
|
* above hears. `reportError` and `onAppearance` are no-ops so a harness never throws.
|
|
28
28
|
*/
|
|
29
|
-
const MINI_APP_STUB = `(function () { var held = false, posted = false; ` +
|
|
29
|
+
export const MINI_APP_STUB = `(function () { var held = false, posted = false; ` +
|
|
30
30
|
`function ready() { if (posted) return; posted = true; window.postMessage({ type: ${JSON.stringify(READY_MESSAGE)} }, "*"); } ` +
|
|
31
31
|
`window.danxMiniApp = { holdReady: function () { held = true; }, ready: ready, reportError: function () {}, onAppearance: function () { return function () {}; } }; ` +
|
|
32
32
|
`window.addEventListener("load", function () { if (!held) ready(); }); })();`;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-5582 - `docs_search` and `docs_read`: one pair of tools over three places a docs root can live, picked by `source`:
|
|
3
|
+
*
|
|
4
|
+
* - `{kind: "local"}` this checkout's `docs/pages` (the server's working directory),
|
|
5
|
+
* - `{kind: "dir", path}` another docs root on this machine,
|
|
6
|
+
* - `{kind: "remote", repo}` a team repo's hosted docs on the dashboard (`GET /api/docs/:repo/search`, `GET /api/docs/:repo/pages/:id`).
|
|
7
|
+
*
|
|
8
|
+
* The tools never put page text in the agent's context. A search answers which pages and where their files are, with each file's size; a
|
|
9
|
+
* read answers the path of a file on THIS machine and its size, and the agent reads it with its own file tools (Read with offset/limit,
|
|
10
|
+
* grep). That is what `danxbot_api` cannot do: it answers into context and cannot write a local file.
|
|
11
|
+
*
|
|
12
|
+
* The ranking is one engine (`@danxbot/docs` `search.ts`): in-process for local and dir, on the dashboard for remote. A local or dir
|
|
13
|
+
* answer's `file` is an ABSOLUTE path, usable as it stands; a remote answer's `file` is the page's path inside the repo's docs, and
|
|
14
|
+
* `docs_read` is what brings it to disk. A remote page is written to `<tmp>/danx-docs/<repo>/<id>.md`, replaced on every read, so the
|
|
15
|
+
* agent never reads a stale copy and re-reads of one page share one path.
|
|
16
|
+
*/
|
|
17
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
18
|
+
import { existsSync } from "node:fs";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import { UnknownNodeIdError, loadLocalDocs, locateLocalPage, searchLocalDocs } from "@danxbot/docs/agent";
|
|
23
|
+
/** The most results a search may ask for: the dashboard's own cap (`docs-agent-routes.ts`), so a local search and a remote one answer alike. */
|
|
24
|
+
export const MAX_DOCS_SEARCH_LIMIT = 50;
|
|
25
|
+
/** The tools' shared `source` argument (one flat object so the schema stays plain: `path` goes with `dir`, `repo` with `remote`; `sourceProblem` enforces it). */
|
|
26
|
+
export const docsSourceShape = z
|
|
27
|
+
.object({
|
|
28
|
+
kind: z.enum(["local", "dir", "remote"]).describe("local: this checkout's docs/pages. dir: another docs root here (set path). remote: a team repo's hosted docs (set repo)."),
|
|
29
|
+
path: z.string().min(1).optional().describe("kind dir: the docs root directory."),
|
|
30
|
+
repo: z.string().min(1).optional().describe("kind remote: the repo name, e.g. danxbot."),
|
|
31
|
+
})
|
|
32
|
+
.strict();
|
|
33
|
+
const REFUSED = 400;
|
|
34
|
+
function refusal(status, error, extra = {}) {
|
|
35
|
+
return { ok: false, status, body: { error, ...extra } };
|
|
36
|
+
}
|
|
37
|
+
/** A `source` whose fields do not match its kind is the caller's mistake; say which field, never guess what was meant. */
|
|
38
|
+
function sourceProblem(source) {
|
|
39
|
+
if (source.kind === "dir") {
|
|
40
|
+
if (source.path === undefined || source.path === "")
|
|
41
|
+
return 'source {kind: "dir"} needs a `path`: the docs root directory';
|
|
42
|
+
if (source.repo !== undefined)
|
|
43
|
+
return 'source {kind: "dir"} takes a `path`, not a `repo`';
|
|
44
|
+
}
|
|
45
|
+
else if (source.kind === "remote") {
|
|
46
|
+
if (source.repo === undefined || source.repo === "")
|
|
47
|
+
return 'source {kind: "remote"} needs a `repo`: the repo name of one of the team\'s boards';
|
|
48
|
+
if (source.path !== undefined)
|
|
49
|
+
return 'source {kind: "remote"} takes a `repo`, not a `path`';
|
|
50
|
+
}
|
|
51
|
+
else if (source.path !== undefined || source.repo !== undefined) {
|
|
52
|
+
return 'source {kind: "local"} takes no other field';
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
/** The local docs root a non-remote source names; an absent directory is an error naming it, never an empty search. */
|
|
57
|
+
function localRoot(source, deps) {
|
|
58
|
+
const root = source.kind === "local" ? path.join(deps.cwd, "docs", "pages") : path.resolve(deps.cwd, source.path);
|
|
59
|
+
if (!existsSync(root)) {
|
|
60
|
+
return refusal(404, `no docs root at ${root}`, { source: source.kind === "local" ? "local" : "dir" });
|
|
61
|
+
}
|
|
62
|
+
return root;
|
|
63
|
+
}
|
|
64
|
+
/** The repo name becomes a directory and a URL segment: refuse anything that is not a plain name before it reaches either. */
|
|
65
|
+
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
66
|
+
export async function docsSearch(client, args, deps) {
|
|
67
|
+
const problem = sourceProblem(args.source);
|
|
68
|
+
if (problem !== null)
|
|
69
|
+
return refusal(REFUSED, problem);
|
|
70
|
+
if (args.limit !== undefined && (!Number.isInteger(args.limit) || args.limit < 1 || args.limit > MAX_DOCS_SEARCH_LIMIT)) {
|
|
71
|
+
return refusal(REFUSED, `limit must be a whole number from 1 to ${MAX_DOCS_SEARCH_LIMIT}`);
|
|
72
|
+
}
|
|
73
|
+
if (args.source.kind === "remote") {
|
|
74
|
+
const repo = args.source.repo;
|
|
75
|
+
if (!SAFE_NAME.test(repo))
|
|
76
|
+
return refusal(REFUSED, `"${repo}" is not a repo name`);
|
|
77
|
+
return client.request({
|
|
78
|
+
method: "GET",
|
|
79
|
+
path: `/${repo}/search`,
|
|
80
|
+
basePath: "/api/docs",
|
|
81
|
+
query: { q: args.query, ...(args.limit === undefined ? {} : { limit: args.limit }) },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const root = localRoot(args.source, deps);
|
|
85
|
+
if (typeof root !== "string")
|
|
86
|
+
return root;
|
|
87
|
+
const docs = loadLocalDocs(root);
|
|
88
|
+
const answer = searchLocalDocs(docs, args.query, args.limit === undefined ? {} : { limit: args.limit });
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
status: 200,
|
|
92
|
+
body: {
|
|
93
|
+
query: answer.query,
|
|
94
|
+
results: answer.results,
|
|
95
|
+
// A docs tree that still has files the loader rejected would otherwise read as "no docs about this".
|
|
96
|
+
...(docs.skipped > 0 ? { skipped_files: docs.skipped } : {}),
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export async function docsRead(client, args, deps) {
|
|
101
|
+
const problem = sourceProblem(args.source);
|
|
102
|
+
if (problem !== null)
|
|
103
|
+
return refusal(REFUSED, problem);
|
|
104
|
+
if (args.source.kind === "remote") {
|
|
105
|
+
const repo = args.source.repo;
|
|
106
|
+
if (!SAFE_NAME.test(repo))
|
|
107
|
+
return refusal(REFUSED, `"${repo}" is not a repo name`);
|
|
108
|
+
if (!SAFE_NAME.test(args.id))
|
|
109
|
+
return refusal(REFUSED, `"${args.id}" is not a docs page id`);
|
|
110
|
+
const fetched = await client.request({ method: "GET", path: `/${repo}/pages/${args.id}`, basePath: "/api/docs" });
|
|
111
|
+
if (!fetched.ok)
|
|
112
|
+
return fetched;
|
|
113
|
+
const page = fetched.body;
|
|
114
|
+
const dir = path.join(deps.tmpDir ?? os.tmpdir(), "danx-docs", repo);
|
|
115
|
+
const file = path.join(dir, `${page.id}.md`);
|
|
116
|
+
await mkdir(dir, { recursive: true });
|
|
117
|
+
await writeFile(file, page.content, "utf8");
|
|
118
|
+
const body = { id: page.id, path: file, bytes: page.bytes, lines: page.lines };
|
|
119
|
+
return { ok: true, status: 200, body };
|
|
120
|
+
}
|
|
121
|
+
const root = localRoot(args.source, deps);
|
|
122
|
+
if (typeof root !== "string")
|
|
123
|
+
return root;
|
|
124
|
+
try {
|
|
125
|
+
const located = locateLocalPage(loadLocalDocs(root), args.id);
|
|
126
|
+
const body = { id: located.id, path: located.path, bytes: located.bytes, lines: located.lines };
|
|
127
|
+
return { ok: true, status: 200, body };
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
if (err instanceof UnknownNodeIdError)
|
|
131
|
+
return refusal(404, err.message, { nearest: err.nearest });
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -106,6 +106,7 @@ import { DashboardHttpClient } from "./http-client.js";
|
|
|
106
106
|
import { planConnect, danxbotApi, danxbotApiList, danxbotApiSpec, attachFile, DANXBOT_API_METHODS, } from "./handlers.js";
|
|
107
107
|
import { designPublish } from "./design-publish.js";
|
|
108
108
|
import { pluginPublish } from "./plugin-publish.js";
|
|
109
|
+
import { docsRead, docsSearch, docsSourceShape, MAX_DOCS_SEARCH_LIMIT } from "./docs-tools.js";
|
|
109
110
|
import { boundedResultText } from "./result-limit.js";
|
|
110
111
|
import { bodyValueSchema, queryValueSchema } from "./types.js";
|
|
111
112
|
import { termsAcceptanceCallHalt, termsHaltFor } from "./terms-halt.js";
|
|
@@ -689,6 +690,22 @@ strictTool("plugin_publish", "Build a card-type plugin project (Vite, `danxPlugi
|
|
|
689
690
|
note: z.string().min(1).describe("What this revision changes, shown to the approver."),
|
|
690
691
|
name: z.string().min(1).optional().describe("Display name for a plugin this call creates; default: the key."),
|
|
691
692
|
}, async (args) => guardedJson(() => pluginPublish(client, args, { baseUrl: config.baseUrl, cwd: process.cwd() })));
|
|
693
|
+
// ---------------- docs_search / docs_read (DX-5582) ----------------
|
|
694
|
+
//
|
|
695
|
+
// One pair of tools over three places a docs root lives (`source`): this checkout's `docs/pages`, another local docs root, or a team repo's hosted
|
|
696
|
+
// docs on the dashboard. They answer with file paths and sizes, never page text (`docs-tools.ts`): `danxbot_api` answers into context and cannot write
|
|
697
|
+
// the local file a remote page is read from. Opt-in like `plugin_publish` (`tool-defs.ts`'s `MCP_SERVER_OPT_IN_TOOLS`).
|
|
698
|
+
strictTool("docs_search", "Search docs with the shared @danxbot/docs engine. Returns `{query, results: [{id, type, title, summary, file, bytes, lines, look_in?}]}`, best first, never a page body; " +
|
|
699
|
+
"then `docs_read` the id you pick. `file` is an absolute path for local/dir and the page's path in the repo's docs for remote; `bytes`/`lines` size it for offset/limit reads.", {
|
|
700
|
+
source: docsSourceShape,
|
|
701
|
+
query: z.string().min(1).describe("A question or the words a page would use."),
|
|
702
|
+
limit: z.number().int().min(1).max(MAX_DOCS_SEARCH_LIMIT).optional().describe("Most results (default 6)."),
|
|
703
|
+
}, async (args) => guardedJson(() => docsSearch(client, args, { cwd: process.cwd() })));
|
|
704
|
+
strictTool("docs_read", "Get a docs page as a file on this machine: returns `{id, path, bytes, lines}`, never the text. local/dir: the page's own file. remote: a fresh copy fetched from the dashboard into the temp directory. " +
|
|
705
|
+
"Read `path` with your own file tools (offset/limit, grep). An unknown id is refused with the nearest ids.", {
|
|
706
|
+
source: docsSourceShape,
|
|
707
|
+
id: z.string().min(1).describe("A page id from docs_search, e.g. topic.mini-apps."),
|
|
708
|
+
}, async (args) => guardedJson(() => docsRead(client, args, { cwd: process.cwd() })));
|
|
692
709
|
// ---------------- request_permission (DX-4434, parent DX-4432) ----------------
|
|
693
710
|
strictTool("request_permission", "Ask a person to grant this key ONE MORE permission. Call it when a call was refused `403 forbidden` naming a " +
|
|
694
711
|
"`permission`, or when you know you need one more. It returns AT ONCE with `{state, approvalUrl, confirmCode, " +
|
|
@@ -15,7 +15,9 @@ export const GET_ROUTE_BOARD_SCOPE = [
|
|
|
15
15
|
["/api/dispatches/:dispatchId/usage-events", "refuse"],
|
|
16
16
|
["/api/dispatches/dead-letter-ids", "refuse"],
|
|
17
17
|
["/api/docs/:repo/changes", "refuse"],
|
|
18
|
+
["/api/docs/:repo/pages/:id", "refuse"],
|
|
18
19
|
["/api/docs/:repo/progress/:pathId", "refuse"],
|
|
20
|
+
["/api/docs/:repo/search", "refuse"],
|
|
19
21
|
["/api/failure-categories", "refuse"],
|
|
20
22
|
["/api/failure-categories/ambiguous", "refuse"],
|
|
21
23
|
["/api/failure-categories/uncategorized", "refuse"],
|
package/dist/tool-access.js
CHANGED
|
@@ -50,6 +50,10 @@ export const TOOL_ACCESS = {
|
|
|
50
50
|
// `POST /api/plugins/:id/revisions`; both writes are `plugins.publish`, which the default agent role holds. The lookup is a read the key's
|
|
51
51
|
// role already has (`boards.view`); a refusal of any step comes back verbatim.
|
|
52
52
|
plugin_publish: needs("plugins.publish"),
|
|
53
|
+
// DX-5582 - `docs_search` / `docs_read` read a local docs root with no dashboard call, and a team repo's hosted docs through `GET /api/docs/:repo/search` and
|
|
54
|
+
// `.../pages/:id` (`docs.view`, which the default agent role holds). One gate for both sources: a key without `docs.view` is not advertised the pair.
|
|
55
|
+
docs_search: needs("docs.view"),
|
|
56
|
+
docs_read: needs("docs.view"),
|
|
53
57
|
// DX-4233 - the plugin's relay waits on the session's plan events here: it opens the listener ticket
|
|
54
58
|
// (`POST /api/plan-sessions/me/stream-ticket`) and heartbeats (`.../me/heartbeat`), both `plans.view`.
|
|
55
59
|
// DX-4667 - not operator-session-only: an orchestrator's dispatch calls it too, through a background waiter sub-agent that
|
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.343",
|
|
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",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"test:watch": "vitest"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
+
"@danxbot/docs": "0.1.50",
|
|
34
35
|
"@danxbot/plugin-sdk": "0.2.1",
|
|
35
36
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
36
37
|
"@thehammer/danx-session-telemetry": "0.1.57",
|