@50arms/platform-mcp 0.0.0-stage → 0.1.437
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 +114 -2
- package/dist/access-request.js +101 -0
- package/dist/agent-gate.js +69 -0
- package/dist/board-id.js +27 -0
- package/dist/bounded-retry.js +25 -0
- package/dist/checkout-docs-root.js +54 -0
- package/dist/clear-control.js +77 -0
- package/dist/compaction-control.js +142 -0
- package/dist/credential.js +104 -0
- package/dist/danxbot-api-examples.js +97 -0
- package/dist/dashboard-identity.js +14 -0
- package/dist/design-screenshot.js +138 -0
- package/dist/digest-hold.js +67 -0
- package/dist/docs-tools.js +140 -0
- package/dist/durable-write.js +56 -0
- package/dist/entrypoint.js +35 -0
- package/dist/forbidden-hint.js +23 -0
- package/dist/guarded-calls.js +21 -0
- package/dist/handlers.js +385 -0
- package/dist/heartbeat-reporter.js +156 -0
- package/dist/http-client.js +190 -0
- package/dist/index.js +1128 -0
- package/dist/json-body.js +12 -0
- package/dist/key-revoked-halt.js +30 -0
- package/dist/lifecycle.js +113 -0
- package/dist/listen.js +999 -0
- package/dist/listener-reminder.js +119 -0
- package/dist/machine-daemon/autostart.js +160 -0
- package/dist/machine-daemon/beat.js +77 -0
- package/dist/machine-daemon/cli.js +42 -0
- package/dist/machine-daemon/install.js +168 -0
- package/dist/machine-daemon/state.js +64 -0
- package/dist/machine-daemon/version.js +13 -0
- package/dist/machine-daemon.js +553 -0
- package/dist/machine-toolkit.js +460 -0
- package/dist/mini-app-build.js +159 -0
- package/dist/mini-app-checkout.js +215 -0
- package/dist/mini-app-output.js +111 -0
- package/dist/mini-app-publish.js +154 -0
- package/dist/mini-app-restore.js +175 -0
- package/dist/one-line.js +25 -0
- package/dist/pacing-relay.js +16 -0
- package/dist/permission-request.js +134 -0
- package/dist/permission-watch.js +145 -0
- package/dist/plan-events-relay.js +572 -0
- package/dist/plan-events-tool.js +9 -0
- package/dist/plugin-publish.js +38 -0
- package/dist/plugin-screenshot.js +81 -0
- package/dist/request-bound.js +21 -0
- package/dist/restart-notice.js +271 -0
- package/dist/result-limit.js +31 -0
- package/dist/role-instruction-file.js +36 -0
- package/dist/route-board-scope.generated.js +95 -0
- package/dist/route-board-scope.js +71 -0
- package/dist/session-access.js +322 -0
- package/dist/session-connection.js +264 -0
- package/dist/session-control-file.js +58 -0
- package/dist/session-key.js +125 -0
- package/dist/session-options.js +60 -0
- package/dist/session-stats.js +235 -0
- package/dist/session-stream.js +345 -0
- package/dist/session-title.js +129 -0
- package/dist/shutdown.js +38 -0
- package/dist/stop-fixes.js +50 -0
- package/dist/subagents-live.js +162 -0
- package/dist/terms-halt.js +39 -0
- package/dist/text-limit.js +24 -0
- package/dist/tool-access.js +114 -0
- package/dist/tool-defs.js +58 -0
- package/dist/tool-surface.js +59 -0
- package/dist/transcript-lineage.js +105 -0
- package/dist/types.js +45 -0
- package/dist/urgent-records.js +47 -0
- package/dist/version-floor.js +146 -0
- package/package.json +51 -4
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-5582 - `docs_search` and `docs_read`: one pair of tools over four 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, by the repo's name (`GET /api/docs/:repo/search`, `GET /api/docs/:repo/pages/:id`),
|
|
7
|
+
* - `{kind: "product"}` 50 Arms' own product docs, which every team reads (`GET /api/product-docs/search`, `GET /api/product-docs/pages/:id`).
|
|
8
|
+
*
|
|
9
|
+
* 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
|
|
10
|
+
* 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,
|
|
11
|
+
* grep). That is what `50arms_api` cannot do: it answers into context and cannot write a local file.
|
|
12
|
+
*
|
|
13
|
+
* The ranking is one engine (`@danxbot/docs` `search.ts`): in-process for local and dir, on the dashboard for remote and product. A local or dir
|
|
14
|
+
* answer's `file` is an ABSOLUTE path, usable as it stands; a hosted answer's `file` is the page's path inside its docs, and
|
|
15
|
+
* `docs_read` is what brings it to disk. A hosted page is written to `<tmp>/danx-docs/<repo>/<id>.md` (`<tmp>/danx-product-docs/<id>.md`), replaced on every read, so the
|
|
16
|
+
* agent never reads a stale copy and re-reads of one page share one path. The file is written under a temp name and renamed into place
|
|
17
|
+
* (DX-5597), so a reader never sees half a page.
|
|
18
|
+
*/
|
|
19
|
+
import { statSync } from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import { MAX_SEARCH_LIMIT, UnknownNodeIdError, loadLocalDocs, locateLocalPage, searchLocalDocs } from "@danxbot/docs/agent";
|
|
24
|
+
import { repoNameArgSchema } from "./board-id.js";
|
|
25
|
+
import { writeFileDurable } from "./durable-write.js";
|
|
26
|
+
/** The tools' shared `source` argument (one flat object so the schema stays plain: `path` goes with `dir`, `repo` with `remote`; `sourceProblem` enforces it). */
|
|
27
|
+
export const docsSourceShape = z
|
|
28
|
+
.object({
|
|
29
|
+
kind: z.enum(["local", "dir", "remote", "product"]).describe("local: this checkout's docs/pages. dir: another docs root here (set path). remote: a team repo's hosted docs (set repo). product: 50 Arms' own product docs, which every team may read."),
|
|
30
|
+
path: z.string().min(1).optional().describe("kind dir: the docs root directory."),
|
|
31
|
+
repo: repoNameArgSchema.optional().describe("kind remote: the repo's name."),
|
|
32
|
+
})
|
|
33
|
+
.strict();
|
|
34
|
+
const REFUSED = 400;
|
|
35
|
+
function refusal(status, error, extra = {}) {
|
|
36
|
+
return { ok: false, status, body: { error, ...extra } };
|
|
37
|
+
}
|
|
38
|
+
/** A docs page id becomes a file name and a URL segment: refuse anything that is not a plain name before it reaches either. */
|
|
39
|
+
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
40
|
+
/** A `source` whose fields do not match its kind is the caller's mistake; say which field, never guess what was meant. */
|
|
41
|
+
function sourceProblem(source) {
|
|
42
|
+
if (source.kind === "dir") {
|
|
43
|
+
if (source.path === undefined || source.path === "")
|
|
44
|
+
return 'source {kind: "dir"} needs a `path`: the docs root directory';
|
|
45
|
+
if (source.repo !== undefined)
|
|
46
|
+
return 'source {kind: "dir"} takes a `path`, not a `repo`';
|
|
47
|
+
}
|
|
48
|
+
else if (source.kind === "remote") {
|
|
49
|
+
if (source.repo === undefined)
|
|
50
|
+
return 'source {kind: "remote"} needs a `repo`: the name of one of the team\'s repos';
|
|
51
|
+
if (source.path !== undefined)
|
|
52
|
+
return 'source {kind: "remote"} takes a `repo`, not a `path`';
|
|
53
|
+
}
|
|
54
|
+
else if (source.path !== undefined || source.repo !== undefined) {
|
|
55
|
+
return `source {kind: "${source.kind}"} takes no other field`;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
/** Where a hosted source's routes live: a repo's under `/api/docs/<repo>`, the product docs' under `/api/product-docs`. Only called once `sourceProblem` passed. */
|
|
60
|
+
function hostedBase(source) {
|
|
61
|
+
return source.kind === "product" ? "/api/product-docs" : `/api/docs/${source.repo}`;
|
|
62
|
+
}
|
|
63
|
+
/** The local docs root a non-hosted source names; a missing path or one that is not a directory is an error naming it, never an empty search. */
|
|
64
|
+
function localRoot(source, deps) {
|
|
65
|
+
const root = source.kind === "local" ? path.join(deps.cwd, "docs", "pages") : path.resolve(deps.cwd, source.path);
|
|
66
|
+
const kind = source.kind === "local" ? "local" : "dir";
|
|
67
|
+
const stat = statSync(root, { throwIfNoEntry: false });
|
|
68
|
+
if (stat === undefined)
|
|
69
|
+
return refusal(404, `no docs root at ${root}`, { source: kind });
|
|
70
|
+
if (!stat.isDirectory())
|
|
71
|
+
return refusal(REFUSED, `${root} is not a directory; a docs root is the folder of page files`, { source: kind });
|
|
72
|
+
return root;
|
|
73
|
+
}
|
|
74
|
+
export async function docsSearch(client, args, deps) {
|
|
75
|
+
const problem = sourceProblem(args.source);
|
|
76
|
+
if (problem !== null)
|
|
77
|
+
return refusal(REFUSED, problem);
|
|
78
|
+
// DX-5597: refused for every source alike, as the dashboard's search route refuses it (400), never an empty answer for local alone. The
|
|
79
|
+
// tool schema's `.min(1)` already refuses `""`; this catches a query of only whitespace.
|
|
80
|
+
if (args.query.trim() === "")
|
|
81
|
+
return refusal(REFUSED, "query is empty: say what to look for");
|
|
82
|
+
if (args.limit !== undefined && (!Number.isInteger(args.limit) || args.limit < 1 || args.limit > MAX_SEARCH_LIMIT)) {
|
|
83
|
+
return refusal(REFUSED, `limit must be a whole number from 1 to ${MAX_SEARCH_LIMIT}`);
|
|
84
|
+
}
|
|
85
|
+
if (args.source.kind === "remote" || args.source.kind === "product") {
|
|
86
|
+
return client.request({
|
|
87
|
+
method: "GET",
|
|
88
|
+
path: "/search",
|
|
89
|
+
basePath: hostedBase(args.source),
|
|
90
|
+
query: { q: args.query, ...(args.limit === undefined ? {} : { limit: args.limit }) },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const root = localRoot(args.source, deps);
|
|
94
|
+
if (typeof root !== "string")
|
|
95
|
+
return root;
|
|
96
|
+
const docs = loadLocalDocs(root);
|
|
97
|
+
const answer = searchLocalDocs(docs, args.query, args.limit === undefined ? {} : { limit: args.limit });
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
status: 200,
|
|
101
|
+
body: {
|
|
102
|
+
query: answer.query,
|
|
103
|
+
results: answer.results,
|
|
104
|
+
// A docs tree that still has files the loader rejected would otherwise read as "no docs about this".
|
|
105
|
+
...(docs.skipped > 0 ? { skipped_files: docs.skipped } : {}),
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export async function docsRead(client, args, deps) {
|
|
110
|
+
const problem = sourceProblem(args.source);
|
|
111
|
+
if (problem !== null)
|
|
112
|
+
return refusal(REFUSED, problem);
|
|
113
|
+
if (args.source.kind === "remote" || args.source.kind === "product") {
|
|
114
|
+
if (!SAFE_NAME.test(args.id))
|
|
115
|
+
return refusal(REFUSED, `"${args.id}" is not a docs page id`);
|
|
116
|
+
const fetched = await client.request({ method: "GET", path: `/pages/${args.id}`, basePath: hostedBase(args.source) });
|
|
117
|
+
if (!fetched.ok)
|
|
118
|
+
return fetched;
|
|
119
|
+
const page = fetched.body;
|
|
120
|
+
// DX-5597: the file is named by the id this call validated, never by a field of the answer, and lands whole: a temp name, then a rename.
|
|
121
|
+
const tmp = deps.tmpDir ?? os.tmpdir();
|
|
122
|
+
const file = args.source.kind === "product" ? path.join(tmp, "danx-product-docs", `${args.id}.md`) : path.join(tmp, "danx-docs", args.source.repo, `${args.id}.md`);
|
|
123
|
+
writeFileDurable(file, page.content);
|
|
124
|
+
const body = { id: args.id, path: file, bytes: page.bytes, lines: page.lines };
|
|
125
|
+
return { ok: true, status: 200, body };
|
|
126
|
+
}
|
|
127
|
+
const root = localRoot(args.source, deps);
|
|
128
|
+
if (typeof root !== "string")
|
|
129
|
+
return root;
|
|
130
|
+
try {
|
|
131
|
+
const located = locateLocalPage(loadLocalDocs(root), args.id);
|
|
132
|
+
const body = { id: located.id, path: located.path, bytes: located.bytes, lines: located.lines };
|
|
133
|
+
return { ok: true, status: 200, body };
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
if (err instanceof UnknownNodeIdError)
|
|
137
|
+
return refusal(404, err.message, { nearest: err.nearest });
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-6156 - the one durable "atomic" file write of this package: a temp sibling, flushed to disk, then renamed over the target.
|
|
3
|
+
* Without the flush NTFS journals the rename but not the data, so a hard crash can leave the target at full length filled with NUL bytes.
|
|
4
|
+
* DX-6117: the parent directory is created here (recursive), so no caller repeats it; a failure before the rename removes the temp sibling and rethrows.
|
|
5
|
+
* The parent directory is synced after the rename on POSIX only; Windows cannot open a directory for fsync and NTFS journals the rename itself.
|
|
6
|
+
*/
|
|
7
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, renameSync, unlinkSync, writeSync } from "node:fs";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
const realFs = { openSync, writeSync: (fd, data, offset) => writeSync(fd, data, offset), fsyncSync, closeSync, renameSync, mkdirSync: (path, options) => void mkdirSync(path, options), unlinkSync };
|
|
10
|
+
let tempSeq = 0;
|
|
11
|
+
/**
|
|
12
|
+
* Write `data` to a temp sibling of `target` (named here, unique per process and call), flush it, then rename it over `target`.
|
|
13
|
+
* A failing step throws and leaves the old `target` untouched: it is only ever replaced by the rename.
|
|
14
|
+
*/
|
|
15
|
+
export function writeFileDurable(target, data, options = {}) {
|
|
16
|
+
const fs = options.fs ?? realFs;
|
|
17
|
+
const temp = `${target}.${process.pid}.${tempSeq++}.tmp`;
|
|
18
|
+
const bytes = Buffer.from(data, "utf-8");
|
|
19
|
+
fs.mkdirSync(dirname(target), { recursive: true });
|
|
20
|
+
const fd = fs.openSync(temp, "w", options.mode);
|
|
21
|
+
try {
|
|
22
|
+
try {
|
|
23
|
+
// writeSync may write fewer bytes than asked; a truncated temp file must never reach the rename.
|
|
24
|
+
for (let written = 0; written < bytes.length;) {
|
|
25
|
+
const n = fs.writeSync(fd, bytes, written);
|
|
26
|
+
if (n <= 0)
|
|
27
|
+
throw new Error(`short write to ${temp}: ${written} of ${bytes.length} bytes`);
|
|
28
|
+
written += n;
|
|
29
|
+
}
|
|
30
|
+
fs.fsyncSync(fd);
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
fs.closeSync(fd);
|
|
34
|
+
}
|
|
35
|
+
fs.renameSync(temp, target);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
// DX-6117: a partial temp sibling is never left behind; the original failure is the one reported.
|
|
39
|
+
try {
|
|
40
|
+
fs.unlinkSync(temp);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// the temp may already be gone
|
|
44
|
+
}
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
if ((options.platform ?? process.platform) !== "win32") {
|
|
48
|
+
const dir = fs.openSync(dirname(target), "r");
|
|
49
|
+
try {
|
|
50
|
+
fs.fsyncSync(dir);
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
fs.closeSync(dir);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
/**
|
|
4
|
+
* True when this module is the process entrypoint (the published bin) rather
|
|
5
|
+
* than an `import` — the DX-1606 tool-defs generator + its drift test import
|
|
6
|
+
* the module to introspect tool schemas without booting the stdio server, so
|
|
7
|
+
* `boot()` + `server.connect()` must run ONLY for the real bin invocation.
|
|
8
|
+
*
|
|
9
|
+
* Why the realpath resolve matters (DX-1647): `import.meta.url` reports the
|
|
10
|
+
* module's REALPATH — Node resolves symlinks for ESM module URLs by default.
|
|
11
|
+
* When the bin runs via a SYMLINK — both `npx` and global installs link
|
|
12
|
+
* `node_modules/.bin/platform-mcp` → `dist/index.js` — `process.argv[1]`
|
|
13
|
+
* is the symlink path, which `pathToFileURL` would NOT match against the
|
|
14
|
+
* realpath `import.meta.url` carries. So resolve argv[1]'s realpath first; then
|
|
15
|
+
* the entrypoint is detected under BOTH direct `node dist/index.js` and the
|
|
16
|
+
* symlinked `npx -y @50arms/platform-mcp` the worker spawns.
|
|
17
|
+
*
|
|
18
|
+
* Without this, npx imports the module, registers the tools, and exits 0
|
|
19
|
+
* WITHOUT starting the server — every dispatch then fails the worker's
|
|
20
|
+
* "MCP loaded" preflight (`declared-not-loaded=[platform]`).
|
|
21
|
+
*/
|
|
22
|
+
export function isEntrypointModule(moduleUrl, argvPath) {
|
|
23
|
+
if (typeof argvPath !== "string" || argvPath.length === 0)
|
|
24
|
+
return false;
|
|
25
|
+
let resolved;
|
|
26
|
+
try {
|
|
27
|
+
resolved = realpathSync(argvPath);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// argv[1] may not resolve on disk (synthetic paths in tests) — fall back to
|
|
31
|
+
// the raw path so a non-symlinked match still holds.
|
|
32
|
+
resolved = argvPath;
|
|
33
|
+
}
|
|
34
|
+
return moduleUrl === pathToFileURL(resolved).href;
|
|
35
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { agentGateHalt } from "./agent-gate.js";
|
|
2
|
+
import { withForbiddenHint } from "./forbidden-hint.js";
|
|
3
|
+
/**
|
|
4
|
+
* DX-5549 / DX-5724 - the one way a tool reaches the dashboard, built over late-bound access and the agent gate (read at CALL time, as `index.ts` binds them in `boot()`).
|
|
5
|
+
* The agent gate comes first - a session holding no team agent does no dashboard work and gets the sign-in's own answer - except for a call the gate leaves open
|
|
6
|
+
* (`opts.gateOpen`: `isAgentGateOpenCall` for the proposal routes - proposing an agent is how a session with none gets one - and the no-agent `50arms_api_list` / `50arms_api_spec` answers about them). Then the sign-in halt.
|
|
7
|
+
*
|
|
8
|
+
* DX-4434: a refused call's `403 forbidden` gains its `request_permission` hint here, the ONE place it is added.
|
|
9
|
+
*/
|
|
10
|
+
export function makeGuardedCalls(deps) {
|
|
11
|
+
const guardedCall = async (run, opts = {}) => {
|
|
12
|
+
if (opts.gateOpen !== true) {
|
|
13
|
+
const noAgent = agentGateHalt(deps.gate);
|
|
14
|
+
if (noAgent !== null)
|
|
15
|
+
return { halt: noAgent };
|
|
16
|
+
}
|
|
17
|
+
const guarded = await deps.access().guard(run);
|
|
18
|
+
return "halt" in guarded ? guarded : { result: withForbiddenHint(guarded.result) };
|
|
19
|
+
};
|
|
20
|
+
return { guardedCall };
|
|
21
|
+
}
|