@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,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `platform-mcp subagents-live <absolute main transcript .jsonl path>` — the LOCAL live feed of a session's
|
|
3
|
+
* sub-agents for the 50arms plugin's plan pane (DX-4508).
|
|
4
|
+
*
|
|
5
|
+
* WHY IT EXISTS. The pane's dashboard read (`GET /api/plan-sessions/:id/subagents`) lags the transcript by about 80s
|
|
6
|
+
* (the relay's 5s transcript poll, its 15s heartbeat flush, the pane's 60s read). A hooks module cannot tail the files
|
|
7
|
+
* itself (`$.fs.read` has no offset and refuses files over 4 MiB), but it can spawn a long-lived child and read its
|
|
8
|
+
* stdout. This is that child: it tails the session's transcript tree on this machine and prints each change within one
|
|
9
|
+
* 1s poll of Claude Code writing it.
|
|
10
|
+
*
|
|
11
|
+
* ONE PLACE COMPUTES EACH NUMBER. It runs the same `SessionLogWatcher` the relay runs (`session-stats.ts`), over the
|
|
12
|
+
* same session layout (the main transcript, and `<sessionDir>/<sessionId>/subagents/agent-<id>.jsonl` beside it), into
|
|
13
|
+
* the shared `createSubagentLiveView`, which feeds the same activity observer, usage ledger counter and pricing rule
|
|
14
|
+
* the dashboard's numbers come from. Nothing is counted or priced here.
|
|
15
|
+
*
|
|
16
|
+
* THE CONTRACT.
|
|
17
|
+
* - stdout carries ONLY lines of `{"subagents":[<snapshot>, ...]}` (`SubagentLiveSnapshot`), one per poll in which
|
|
18
|
+
* any snapshot changed, holding just the changed ones. The first line, printed once the whole tree has been read
|
|
19
|
+
* from byte 0, carries every sub-agent discovered (an empty array for a session with none, so a reader knows the
|
|
20
|
+
* catch-up is done). Every later line reads only bytes appended since.
|
|
21
|
+
* - diagnostics go to stderr.
|
|
22
|
+
* - a missing, relative or non-`.jsonl` path is refused: one stderr line, exit 2.
|
|
23
|
+
* - it runs until killed: SIGTERM, SIGINT or a closed stdout stops it cleanly with exit 0.
|
|
24
|
+
* - the transcript is a local file; the prices are not. The ONE network call is `GET /api/model-versions` at start (DX-5611: the
|
|
25
|
+
* telemetry package holds no prices, so the declared `model_version_prices` price every reply, as the dashboard's ledger does),
|
|
26
|
+
* authenticated with the session's approved dashboard key (`session-key.ts`) on `DANXBOT_DASHBOARD_URL`, else the plugin's hosted
|
|
27
|
+
* dashboard. A start that cannot read the prices exits 1 with the reason on stderr; a cost is never guessed.
|
|
28
|
+
*/
|
|
29
|
+
import { statSync } from "node:fs";
|
|
30
|
+
import { basename, dirname, isAbsolute } from "node:path";
|
|
31
|
+
import { DEFAULT_DASHBOARD_URL } from "./machine-daemon/install.js";
|
|
32
|
+
import { sendSessionRequest, tryParseJsonBody } from "./restart-notice.js";
|
|
33
|
+
import { readSessionKeyFile, sessionKeyPath } from "./session-key.js";
|
|
34
|
+
import { SessionLogWatcher, priceCardsOfCatalog, priceLookupOfCards, createSubagentLiveView, } from "@50arms/session-telemetry";
|
|
35
|
+
export const SUBAGENTS_LIVE_SUBCOMMAND = "subagents-live";
|
|
36
|
+
/** How often the transcript tree is polled: the pane's ~5s freshness target with room to spare. */
|
|
37
|
+
export const SUBAGENTS_LIVE_POLL_MS = 1_000;
|
|
38
|
+
const USAGE = `usage: platform-mcp ${SUBAGENTS_LIVE_SUBCOMMAND} <absolute path to the session's main transcript .jsonl>`;
|
|
39
|
+
/** The validated transcript path, or the reason it is refused. */
|
|
40
|
+
export function parseSubagentsLiveArgs(args) {
|
|
41
|
+
if (args.length !== 1)
|
|
42
|
+
return { ok: false, reason: `expected exactly one argument, got ${args.length}` };
|
|
43
|
+
const path = args[0];
|
|
44
|
+
if (!isAbsolute(path))
|
|
45
|
+
return { ok: false, reason: `"${path}" is not an absolute path` };
|
|
46
|
+
if (!path.endsWith(".jsonl"))
|
|
47
|
+
return { ok: false, reason: `"${path}" is not a .jsonl transcript` };
|
|
48
|
+
let isFile;
|
|
49
|
+
try {
|
|
50
|
+
isFile = statSync(path).isFile();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return { ok: false, reason: `"${path}" does not exist` };
|
|
54
|
+
}
|
|
55
|
+
if (!isFile)
|
|
56
|
+
return { ok: false, reason: `"${path}" is not a file` };
|
|
57
|
+
return { ok: true, path };
|
|
58
|
+
}
|
|
59
|
+
/** The one stdout line a poll's changes are printed as. */
|
|
60
|
+
export function formatSubagentsLine(subagents) {
|
|
61
|
+
return `${JSON.stringify({ subagents })}\n`;
|
|
62
|
+
}
|
|
63
|
+
export async function runSubagentsLiveCommand(args, deps = realDeps()) {
|
|
64
|
+
const parsed = parseSubagentsLiveArgs(args);
|
|
65
|
+
if (!parsed.ok) {
|
|
66
|
+
deps.writeError(`[platform-mcp] ${SUBAGENTS_LIVE_SUBCOMMAND}: ${parsed.reason}. ${USAGE}`);
|
|
67
|
+
return 2;
|
|
68
|
+
}
|
|
69
|
+
const path = parsed.path;
|
|
70
|
+
let priceOf;
|
|
71
|
+
try {
|
|
72
|
+
priceOf = await deps.loadPrices(basename(path, ".jsonl"));
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
deps.writeError(`[platform-mcp] ${SUBAGENTS_LIVE_SUBCOMMAND}: the model prices could not be read: ${err.message}`);
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
const view = createSubagentLiveView(priceOf);
|
|
79
|
+
let stopped = false;
|
|
80
|
+
let caughtUp = false;
|
|
81
|
+
let watcher = null;
|
|
82
|
+
let resolveStopped = () => { };
|
|
83
|
+
const stoppedHere = new Promise((resolve) => {
|
|
84
|
+
resolveStopped = resolve;
|
|
85
|
+
});
|
|
86
|
+
const emit = () => {
|
|
87
|
+
if (stopped)
|
|
88
|
+
return;
|
|
89
|
+
const changed = view.takeChanged();
|
|
90
|
+
// DX-4508 — the first poll's line is printed even when empty: it tells the reader the catch-up read is complete.
|
|
91
|
+
if (changed.length === 0 && caughtUp)
|
|
92
|
+
return;
|
|
93
|
+
caughtUp = true;
|
|
94
|
+
if (!deps.writeLine(formatSubagentsLine(changed)))
|
|
95
|
+
stop();
|
|
96
|
+
};
|
|
97
|
+
const stop = () => {
|
|
98
|
+
stopped = true;
|
|
99
|
+
watcher?.stop();
|
|
100
|
+
resolveStopped();
|
|
101
|
+
};
|
|
102
|
+
watcher = deps.makeWatcher({
|
|
103
|
+
cwd: dirname(path),
|
|
104
|
+
sessionDir: dirname(path),
|
|
105
|
+
sessionId: basename(path, ".jsonl"),
|
|
106
|
+
// DX-4508 — no stored cursors: every file is read from byte 0 once, then only its appended bytes.
|
|
107
|
+
startCursors: {},
|
|
108
|
+
pollIntervalMs: SUBAGENTS_LIVE_POLL_MS,
|
|
109
|
+
onSourceDiscovered: view.onSourceDiscovered,
|
|
110
|
+
onPollComplete: emit,
|
|
111
|
+
});
|
|
112
|
+
watcher.onUsageLine(view.onLine);
|
|
113
|
+
// Either the process is told to stop, or stdout refused a line (`emit`).
|
|
114
|
+
const stopRequested = Promise.race([deps.untilStopped(), stoppedHere]);
|
|
115
|
+
await watcher.start();
|
|
116
|
+
await stopRequested;
|
|
117
|
+
stop();
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
function realDeps() {
|
|
121
|
+
return {
|
|
122
|
+
makeWatcher: (options) => new SessionLogWatcher(options),
|
|
123
|
+
writeLine: (line) => {
|
|
124
|
+
if (process.stdout.destroyed || !process.stdout.writable)
|
|
125
|
+
return false;
|
|
126
|
+
process.stdout.write(line);
|
|
127
|
+
return true;
|
|
128
|
+
},
|
|
129
|
+
writeError: (line) => process.stderr.write(`${line}\n`),
|
|
130
|
+
loadPrices: (sessionId) => loadDeclaredPrices(sessionId),
|
|
131
|
+
untilStopped: () => new Promise((resolve) => {
|
|
132
|
+
process.once("SIGTERM", () => resolve());
|
|
133
|
+
process.once("SIGINT", () => resolve());
|
|
134
|
+
// A reader that closes the pipe ends the command: there is no one left to print to.
|
|
135
|
+
process.stdout.once("error", () => resolve());
|
|
136
|
+
process.stdout.once("close", () => resolve());
|
|
137
|
+
}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/** How long the one price read may take before the command refuses to start. */
|
|
141
|
+
const PRICES_REQUEST_TIMEOUT_MS = 10_000;
|
|
142
|
+
/**
|
|
143
|
+
* DX-5611 - read the declared prices from the dashboard this session is signed in to, with the session's own approved key
|
|
144
|
+
* (`DANXBOT_DASHBOARD_URL`, else the plugin's hosted dashboard, as every other command here resolves it). `sendSessionRequest` carries the
|
|
145
|
+
* bound and the plugin-background header, so this read never extends the session's key.
|
|
146
|
+
*/
|
|
147
|
+
export async function loadDeclaredPrices(sessionId, io = {}) {
|
|
148
|
+
const envUrl = (io.env ?? process.env).DANXBOT_DASHBOARD_URL;
|
|
149
|
+
const dashboardUrl = (envUrl === undefined || envUrl === "" ? DEFAULT_DASHBOARD_URL : envUrl).replace(/\/+$/, "");
|
|
150
|
+
const token = readSessionKeyFile(sessionKeyPath(dashboardUrl, sessionId, io.home));
|
|
151
|
+
const fields = encodeURIComponent(JSON.stringify({ model_id: true, prices: true }));
|
|
152
|
+
const sent = await sendSessionRequest({ dashboardUrl, token, sessionId }, `/api/model-versions?fields=${fields}`, { method: "GET" }, { fetch: io.fetch ?? fetch, requestTimeoutMs: PRICES_REQUEST_TIMEOUT_MS });
|
|
153
|
+
if ("ok" in sent)
|
|
154
|
+
throw new Error(`GET /api/model-versions failed: ${sent.reason}`);
|
|
155
|
+
if (sent.status !== 200)
|
|
156
|
+
throw new Error(`GET /api/model-versions answered ${sent.status}`);
|
|
157
|
+
const parsed = tryParseJsonBody(sent.text);
|
|
158
|
+
const versions = parsed.ok ? parsed.body?.model_versions : undefined;
|
|
159
|
+
if (!Array.isArray(versions))
|
|
160
|
+
throw new Error("GET /api/model-versions answered no model_versions");
|
|
161
|
+
return priceLookupOfCards(priceCardsOfCatalog(versions));
|
|
162
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const TERMS_ACCEPTANCE_PATH = "/api/account/terms-acceptance";
|
|
2
|
+
// Mirrors `termsAcceptUrl("/")` in src/lib/terms-accept-path.ts (a published package cannot import it);
|
|
3
|
+
// terms-gate.integration.test.ts pins the server's link to the same literal. Only meaningful for an
|
|
4
|
+
// operator session: a dispatch's DANXBOT_DASHBOARD_URL is internal, and a dispatch's own key is its
|
|
5
|
+
// owner's API key (DX-4191), so a dispatched agent can meet this refusal too and hands the link to the
|
|
6
|
+
// owner's operator.
|
|
7
|
+
const ACCEPT_SCREEN_PATH = "/terms/accept?return=%2F";
|
|
8
|
+
/** Keep in step with `termsRefusalInstruction` in src/dashboard/route-policy.ts. */
|
|
9
|
+
export function haltText(acceptUrl, owner) {
|
|
10
|
+
return ("STOP ALL WORK NOW. The dashboard refuses your access until " +
|
|
11
|
+
(owner === undefined ? "the person you act for" : `${owner}, the user you act for,`) +
|
|
12
|
+
" accepts the current Terms of Use.\n\n" +
|
|
13
|
+
`Give the person this link to accept them: ${acceptUrl}\n\n` +
|
|
14
|
+
"Do not retry, do not look for another route, and never accept the Terms yourself. " +
|
|
15
|
+
"A dispatched agent ends with 50arms_api POST /api/dispatch-callbacks/complete {status: \"failed\", summary} " +
|
|
16
|
+
"naming this link (stop if that call is refused too), and never blocks the card.");
|
|
17
|
+
}
|
|
18
|
+
/** The halt text for a `403 terms_not_accepted` result, or null for any other result. */
|
|
19
|
+
export function termsHaltFor(result) {
|
|
20
|
+
if (result.ok || result.status !== 403)
|
|
21
|
+
return null;
|
|
22
|
+
const body = result.body;
|
|
23
|
+
if (body?.error !== "terms_not_accepted")
|
|
24
|
+
return null;
|
|
25
|
+
if (typeof body.acceptUrl !== "string") {
|
|
26
|
+
throw new Error("[platform-mcp] a terms_not_accepted refusal carried no acceptUrl");
|
|
27
|
+
}
|
|
28
|
+
if (typeof body.owner !== "string") {
|
|
29
|
+
throw new Error("[platform-mcp] a terms_not_accepted refusal carried no owner");
|
|
30
|
+
}
|
|
31
|
+
return haltText(body.acceptUrl, body.owner);
|
|
32
|
+
}
|
|
33
|
+
/** The refusal for a call that would accept the Terms (never an agent's act), or null for any other call. */
|
|
34
|
+
export function termsAcceptanceCallHalt(args, dashboardUrl) {
|
|
35
|
+
const path = args.path.split("?")[0].replace(/\/+$/, "");
|
|
36
|
+
if (args.method !== "POST" || path !== TERMS_ACCEPTANCE_PATH)
|
|
37
|
+
return null;
|
|
38
|
+
return haltText(`${dashboardUrl.replace(/\/+$/, "")}${ACCEPT_SCREEN_PATH}`);
|
|
39
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4318 — Claude Code delivers at most 2,048 characters (string length, not
|
|
3
|
+
* bytes) of an MCP server's instructions and of each tool description, then
|
|
4
|
+
* appends `… [truncated]`; nothing fails. Measured 2026-10-03 on three texts
|
|
5
|
+
* of this server, each cut at index 2,048. The limit here keeps 48 characters
|
|
6
|
+
* of headroom under that cut.
|
|
7
|
+
*
|
|
8
|
+
* Whether Claude Code also cuts a parameter description is unverified: none
|
|
9
|
+
* is long enough to show it. The package test holds them to the same limit, so
|
|
10
|
+
* the question stays moot.
|
|
11
|
+
*/
|
|
12
|
+
export const MCP_TEXT_LIMIT_CHARS = 2000;
|
|
13
|
+
/**
|
|
14
|
+
* Refuses a text an agent would receive cut. Called where each text is
|
|
15
|
+
* registered, so an over-long one stops the module from loading: the package
|
|
16
|
+
* tests, the `tool-defs.json` generator and the publish boot check all fail
|
|
17
|
+
* on it.
|
|
18
|
+
*/
|
|
19
|
+
export function assertWithinTextLimit(label, text) {
|
|
20
|
+
if (text.length > MCP_TEXT_LIMIT_CHARS) {
|
|
21
|
+
throw new Error(`[platform-mcp] ${label} is ${text.length} characters, over the ${MCP_TEXT_LIMIT_CHARS}-character limit: ` +
|
|
22
|
+
`Claude Code would cut it and the agent would never see the rest. Shorten it.`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4523 (parent DX-4522) - which MCP tool a session is shown, decided in ONE place.
|
|
3
|
+
*
|
|
4
|
+
* A session sees a tool only when it can call it. Every tool is declared here with what makes it callable, and
|
|
5
|
+
* `toolsFor` is the one function that turns what a session holds into the tool names it advertises. The server
|
|
6
|
+
* (`tool-surface.ts`) applies it, and the danxbot service's budgets (`src/tokens/mcp-surface.ts`) measure it, so no
|
|
7
|
+
* second list of "which session gets which tool" exists anywhere.
|
|
8
|
+
*
|
|
9
|
+
* Three kinds of tool, by what makes them usable:
|
|
10
|
+
*
|
|
11
|
+
* - bootstrap `{permission: null, callback: null}` - what a session needs BEFORE it holds a key, or to ask for more:
|
|
12
|
+
* `plan_connect` (sign in, bind to a plan), `request_permission`, and the `50arms_api` trio.
|
|
13
|
+
* - permission `{permission}` - the dashboard route the tool calls needs this permission, so a key without it can
|
|
14
|
+
* never succeed. Shown once the key holds it, which includes a grant a person approves mid-session.
|
|
15
|
+
* - callback `{callback}` - the tool posts to a per-dispatch URL the worker injected (`lifecycle.ts`), so it is usable
|
|
16
|
+
* only when that URL exists. It also names the `permission` its dashboard route needs (both must hold).
|
|
17
|
+
*
|
|
18
|
+
* This module is pure data and one function, with no imports, because the danxbot service imports it directly and
|
|
19
|
+
* cannot take this package's MCP-SDK or zod dependencies.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* The environment variable a worker sets to inject each callback's URL. `lifecycle.ts` reads them, and the worker's overlay
|
|
23
|
+
* (`src/dispatch/dispatch-callbacks.ts`) writes them, so the name of a callback exists once.
|
|
24
|
+
*/
|
|
25
|
+
export const CALLBACK_ENV = {
|
|
26
|
+
slackReply: "DANXBOT_SLACK_REPLY_URL",
|
|
27
|
+
};
|
|
28
|
+
const BOOTSTRAP = { permission: null, callback: null };
|
|
29
|
+
const needs = (permission) => ({ permission, callback: null });
|
|
30
|
+
/** DX-4632: usable with no key (a session that has not signed in yet) and only on a person's session. */
|
|
31
|
+
const OPERATOR_SESSION_BOOTSTRAP = { permission: null, callback: null, operatorSessionOnly: true };
|
|
32
|
+
const needsAndPostsTo = (permission, callback) => ({ permission, callback });
|
|
33
|
+
/**
|
|
34
|
+
* Every tool this server registers, with what makes it usable. `index.ts` refuses to register a tool that is not
|
|
35
|
+
* here, and `tool-access.test.ts` fails when an entry names no registered tool.
|
|
36
|
+
*/
|
|
37
|
+
export const TOOL_ACCESS = {
|
|
38
|
+
plan_connect: BOOTSTRAP,
|
|
39
|
+
// DX-5553 - `POST|DELETE /api/plan-sessions/me/card` (`cards.edit`: it assigns the card to the session's agent): the session holds a card on no plan.
|
|
40
|
+
card_connect: needs("cards.edit"),
|
|
41
|
+
request_permission: BOOTSTRAP,
|
|
42
|
+
"50arms_api": BOOTSTRAP,
|
|
43
|
+
"50arms_api_list": BOOTSTRAP,
|
|
44
|
+
"50arms_api_spec": BOOTSTRAP,
|
|
45
|
+
// DX-6847 / DX-6864 (DEC-571): the names the three API tools had before the rename, served beside the new ones (`LEGACY_TOOL_NAMES`) so a session whose text still
|
|
46
|
+
// names them keeps working until every install runs a plugin that names the new ones. DX-6864 removes these three entries.
|
|
47
|
+
danxbot_api: BOOTSTRAP,
|
|
48
|
+
danxbot_api_list: BOOTSTRAP,
|
|
49
|
+
danxbot_api_spec: BOOTSTRAP,
|
|
50
|
+
// `POST /api/issues/:id/attachments` (target `card`) or `POST /api/uploads/images` (target `inline_image`, replies `{url, markdown}`); one gate for both (DX-5241)
|
|
51
|
+
attach_file: needs("cards.edit"),
|
|
52
|
+
// DX-5294 (was DX-4077's design_publish) - 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
|
|
53
|
+
// reads on the way (`GET /api/boards/:id`, `GET /api/repos/:id`, `GET /api/issues/:id`) are separate
|
|
54
|
+
// checks on the dashboard whose refusals come back verbatim.
|
|
55
|
+
mini_app_publish: needs("cards.edit"),
|
|
56
|
+
// DX-6070 - reads one revision's stored source (`GET /api/issues/:id/designs/:designNo/revisions/:revisionNo/source`, `cards.view`, the bundle route's permission) and writes it to the local checkout; the latest-revision lookup is `GET /api/issues/:id`. DX-6102: it declares that route's `cards.view`, since a declaration names the permission of the route it calls; the profiles that grant it (gate-designer, orchestrator) also grant `mini_app_publish`, so the pair is still advertised together.
|
|
57
|
+
mini_app_restore: needs("cards.view"),
|
|
58
|
+
// DX-5237 - builds in the agent's own checkout, then `GET /api/plugins` (to find the plugin by key), `POST /api/plugins` (a new key) and
|
|
59
|
+
// `POST /api/plugins/:id/revisions`; both writes are `plugins.publish`, which the default agent role holds. The lookup is a read the key's
|
|
60
|
+
// role already has (`boards.view`); a refusal of any step comes back verbatim.
|
|
61
|
+
plugin_publish: needs("plugins.publish"),
|
|
62
|
+
// 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
|
|
63
|
+
// `.../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.
|
|
64
|
+
docs_search: needs("docs.view"),
|
|
65
|
+
docs_read: needs("docs.view"),
|
|
66
|
+
// DX-4233 - the plugin's relay waits on the session's plan events here: it opens the listener ticket
|
|
67
|
+
// (`POST /api/plan-sessions/me/stream-ticket`) and heartbeats (`.../me/heartbeat`), both `plans.view`.
|
|
68
|
+
// DX-4667 - not operator-session-only: an orchestrator's dispatch calls it too, through a background waiter sub-agent that
|
|
69
|
+
// wakes the orchestrator when a person answers. What keeps it off every other dispatch is the opt-in (`MCP_SERVER_OPT_IN_TOOLS`,
|
|
70
|
+
// `src/tokens/tool-defs.ts`) granted to the `orchestrator` profile alone, not this table.
|
|
71
|
+
plan_events_wait: needs("plans.view"),
|
|
72
|
+
// DX-4632 - the plugin's session-start hook asks which plan the session this one replaced was working. A restarted session boots
|
|
73
|
+
// signed out, so it holds no permission: like `plan_connect` it is a bootstrap tool, and a person's session's only (a dispatch
|
|
74
|
+
// never loads the plugin that calls it). The lookup asks the dashboard as the predecessor, with the credential the predecessor's
|
|
75
|
+
// record names.
|
|
76
|
+
restart_notice: OPERATOR_SESSION_BOOTSTRAP,
|
|
77
|
+
// The one dispatch callback that stays a tool (DX-4525): it reads local files. Its dashboard route's own policy names the
|
|
78
|
+
// permission (`route-policies/index.ts`); every other callback is a `50arms_api` call to a route under `callback-routes.ts`.
|
|
79
|
+
"50arms_slack_reply": needsAndPostsTo("slack.threads.post", "slackReply"),
|
|
80
|
+
// DX-6847 / DX-6864 (DEC-571): its name before the rename, served beside the new one like the three API tools above; DX-6864 removes this entry.
|
|
81
|
+
danxbot_slack_reply: needsAndPostsTo("slack.threads.post", "slackReply"),
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* DX-6847 / DX-6864 (DEC-571): each tool the rename gave a new name (the three generic API tools and the Slack reply), by its current name, mapped to the name it
|
|
85
|
+
* had before. The server registers the old name as a second tool with the same schema and the same handler, so the two answer identically; DX-6864 deletes this
|
|
86
|
+
* map and the four old entries above.
|
|
87
|
+
*/
|
|
88
|
+
export const LEGACY_TOOL_NAMES = {
|
|
89
|
+
"50arms_api": "danxbot_api",
|
|
90
|
+
"50arms_api_list": "danxbot_api_list",
|
|
91
|
+
"50arms_api_spec": "danxbot_api_spec",
|
|
92
|
+
"50arms_slack_reply": "danxbot_slack_reply",
|
|
93
|
+
};
|
|
94
|
+
/** True when a session holding `grant` can call the tool declared by `access`. */
|
|
95
|
+
export function isUsable(access, grant) {
|
|
96
|
+
if (access.permission !== null && !grant.permissions.has(access.permission))
|
|
97
|
+
return false;
|
|
98
|
+
if (access.callback !== null && !grant.callbacks.has(access.callback))
|
|
99
|
+
return false;
|
|
100
|
+
if (access.operatorSessionOnly === true && !grant.operatorSession)
|
|
101
|
+
return false;
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
/** The ONE mapping from what a session holds to the tool names it advertises, sorted. */
|
|
105
|
+
export function toolsFor(grant) {
|
|
106
|
+
return Object.keys(TOOL_ACCESS).filter((name) => isUsable(TOOL_ACCESS[name], grant)).sort();
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The bootstrap set a DISPATCH sees: usable with no key, no callback, and not operator-session-only. A signed-out PERSON's
|
|
110
|
+
* session is shown this plus the operator-only bootstrap tools (`restart_notice`): see `bootstrapSurfaceTools()`.
|
|
111
|
+
*/
|
|
112
|
+
export function bootstrapTools() {
|
|
113
|
+
return toolsFor({ permissions: new Set(), callbacks: new Set(), operatorSession: false });
|
|
114
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-1606 — expose THIS MCP server's tool DEFINITIONS as plain JSON Schema, so
|
|
3
|
+
* the danxbot service can count the injected-context token cost of each tool.
|
|
4
|
+
*
|
|
5
|
+
* The server registers tools via `server.tool(name, description, zodShape, …)`
|
|
6
|
+
* (in `index.ts`); the zod shapes are converted to JSON Schema by the MCP SDK
|
|
7
|
+
* when it answers `tools/list`. Rather than re-declare the schemas (drift), this
|
|
8
|
+
* module drives the SAME `tools/list` the live agent receives by linking an
|
|
9
|
+
* in-memory client to the already-registered `server` — so the emitted defs are
|
|
10
|
+
* byte-identical to what the agent's context is charged for.
|
|
11
|
+
*
|
|
12
|
+
* The danxbot service can NOT take a zod / MCP-SDK runtime dependency (and the
|
|
13
|
+
* Docker image does not install this package's node_modules), so the service
|
|
14
|
+
* reads the COMMITTED `tool-defs.json` produced by `generateToolDefsJson()`
|
|
15
|
+
* below — plain data, no runtime deps. `gen-tool-defs.ts` writes that file and
|
|
16
|
+
* `tool-defs.test.ts` asserts it is in sync (fail-loud on drift).
|
|
17
|
+
*/
|
|
18
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
19
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
20
|
+
import { server } from "./index.js";
|
|
21
|
+
/** The server id the platform catalog mcp-server artifact is keyed by. */
|
|
22
|
+
export const DANX_DASHBOARD_SERVER_ID = "platform";
|
|
23
|
+
/**
|
|
24
|
+
* Introspect `server`'s registered tools via an in-memory `tools/list` — the
|
|
25
|
+
* exact `{name, description, inputSchema}` the agent's context receives — and
|
|
26
|
+
* map them to the API's `{name, description, input_schema}` shape, sorted by
|
|
27
|
+
* name for a stable committed artifact.
|
|
28
|
+
*/
|
|
29
|
+
export async function generateToolDefs() {
|
|
30
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
31
|
+
const client = new Client({ name: "tool-defs-introspect", version: "0.0.0" });
|
|
32
|
+
await Promise.all([
|
|
33
|
+
server.connect(serverTransport),
|
|
34
|
+
client.connect(clientTransport),
|
|
35
|
+
]);
|
|
36
|
+
try {
|
|
37
|
+
const { tools } = await client.listTools();
|
|
38
|
+
return tools
|
|
39
|
+
.map((t) => ({
|
|
40
|
+
name: t.name,
|
|
41
|
+
description: t.description ?? "",
|
|
42
|
+
input_schema: t.inputSchema,
|
|
43
|
+
}))
|
|
44
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
await client.close();
|
|
48
|
+
await server.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** The full `tool-defs.json` payload (server id + the introspected defs). */
|
|
52
|
+
export async function generateToolDefsFile() {
|
|
53
|
+
return { server: DANX_DASHBOARD_SERVER_ID, tools: await generateToolDefs() };
|
|
54
|
+
}
|
|
55
|
+
/** Deterministic 2-space JSON the committed file + the drift test compare. */
|
|
56
|
+
export function serializeToolDefsFile(file) {
|
|
57
|
+
return JSON.stringify(file, null, 2) + "\n";
|
|
58
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4523 - applies `toolsFor` (`tool-access.ts`) to the tools this server registered, and keeps it applied as what the
|
|
3
|
+
* session holds changes: at boot, when sign-in completes, and when a person approves a `request_permission` grant.
|
|
4
|
+
*
|
|
5
|
+
* A changed tool list makes Claude Code refetch it (`notifications/tools/list_changed`) and invalidates the prompt cache, so the
|
|
6
|
+
* surface switches a tool ONLY when its visibility really changes and announces the change ONCE per apply: a grant that adds no
|
|
7
|
+
* tool, or a repeated sync, sends nothing. The SDK's own `enable()` / `disable()` each send a notification, so the surface sets
|
|
8
|
+
* `RegisteredTool.enabled` (a plain field the SDK's list handler reads) and calls `onChanged` once instead.
|
|
9
|
+
*/
|
|
10
|
+
import { toolsFor } from "./tool-access.js";
|
|
11
|
+
export class ToolSurface {
|
|
12
|
+
tools;
|
|
13
|
+
callbacks;
|
|
14
|
+
options;
|
|
15
|
+
constructor(tools, callbacks, options) {
|
|
16
|
+
this.tools = tools;
|
|
17
|
+
this.callbacks = callbacks;
|
|
18
|
+
this.options = options;
|
|
19
|
+
}
|
|
20
|
+
/** Show exactly the tools `permissions` and this dispatch's callbacks allow. Returns the names whose visibility changed. */
|
|
21
|
+
apply(permissions) {
|
|
22
|
+
const wanted = new Set(toolsFor({ permissions, callbacks: this.callbacks, operatorSession: this.options.operatorSession }));
|
|
23
|
+
const changed = [];
|
|
24
|
+
for (const [name, tool] of this.tools) {
|
|
25
|
+
const want = wanted.has(name);
|
|
26
|
+
if (tool.enabled === want)
|
|
27
|
+
continue;
|
|
28
|
+
tool.enabled = want;
|
|
29
|
+
changed.push(name);
|
|
30
|
+
}
|
|
31
|
+
if (changed.length > 0)
|
|
32
|
+
this.options.onChanged();
|
|
33
|
+
return changed.sort();
|
|
34
|
+
}
|
|
35
|
+
/** The tool names currently shown. */
|
|
36
|
+
advertised() {
|
|
37
|
+
return [...this.tools].filter(([, tool]) => tool.enabled).map(([name]) => name).sort();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Re-reads the key's permissions and applies them. Calls run one at a time, so two triggers never interleave. */
|
|
41
|
+
export class ToolAccessSync {
|
|
42
|
+
deps;
|
|
43
|
+
queue = Promise.resolve();
|
|
44
|
+
constructor(deps) {
|
|
45
|
+
this.deps = deps;
|
|
46
|
+
}
|
|
47
|
+
/** Resolves with the names whose visibility changed. A failed read rejects: the caller decides how loud that is. */
|
|
48
|
+
sync() {
|
|
49
|
+
const run = this.queue.then(async () => {
|
|
50
|
+
const permissions = (await this.deps.readPermissions()) ?? new Set();
|
|
51
|
+
const changed = this.deps.surface.apply(permissions);
|
|
52
|
+
if (changed.length > 0)
|
|
53
|
+
this.deps.log?.(`[platform-mcp] tool list changed: ${changed.join(", ")}`);
|
|
54
|
+
return changed;
|
|
55
|
+
});
|
|
56
|
+
this.queue = run.catch(() => undefined);
|
|
57
|
+
return run;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4234 - who a session was FORKED from, read from its own transcript. For the `restart_notice` tool.
|
|
3
|
+
*
|
|
4
|
+
* A desktop fork copies its parent's rows into the new transcript, then appends its own. The copied rows carry the session
|
|
5
|
+
* ids of the ancestors that wrote them, so a transcript runs `root ... parent ... own`: for a fork of a fork the FIRST row
|
|
6
|
+
* names the ROOT ancestor, not the parent. The parent is the LAST session id before the first row carrying this session's own
|
|
7
|
+
* id, so the read goes forward and stops at that row (it reads the copied prefix, never the rest of a file that can be tens
|
|
8
|
+
* of MB).
|
|
9
|
+
*
|
|
10
|
+
* ANSWERS:
|
|
11
|
+
* - `{kind: "fork", parent}` - the nearest earlier session id.
|
|
12
|
+
* - `{kind: "not_fork"}` - the first row naming a session is this session's own (a plain resume or a fresh start): it was
|
|
13
|
+
* not forked from anything.
|
|
14
|
+
* - throws `TranscriptUnreadable` - the file cannot say (missing, unreadable, a row that is not JSON, a row or prefix over
|
|
15
|
+
* the caps below, no row names a session). The caller must be told; it is never a "not a fork".
|
|
16
|
+
* - throws `TranscriptReadAborted` - the caller's deadline signal fired mid-read.
|
|
17
|
+
*
|
|
18
|
+
* BOUNDED: one row may be at most `TRANSCRIPT_MAX_LINE_BYTES` (a first row can be 428 KB or more) and the prefix read at most
|
|
19
|
+
* `TRANSCRIPT_MAX_PREFIX_BYTES`; over either is `TranscriptUnreadable`, never a quiet truncation.
|
|
20
|
+
*/
|
|
21
|
+
import { createReadStream } from "node:fs";
|
|
22
|
+
export const TRANSCRIPT_MAX_LINE_BYTES = 4 * 1024 * 1024;
|
|
23
|
+
export const TRANSCRIPT_MAX_PREFIX_BYTES = 64 * 1024 * 1024;
|
|
24
|
+
export class TranscriptUnreadable extends Error {
|
|
25
|
+
}
|
|
26
|
+
export class TranscriptReadAborted extends Error {
|
|
27
|
+
}
|
|
28
|
+
const NEWLINE = 0x0a;
|
|
29
|
+
export async function readTranscriptLineage(path, ownSessionId, options = {}) {
|
|
30
|
+
const source = options.open?.(path) ?? createReadStream(path);
|
|
31
|
+
const stop = () => {
|
|
32
|
+
source.destroy?.();
|
|
33
|
+
};
|
|
34
|
+
options.signal?.addEventListener("abort", stop, { once: true });
|
|
35
|
+
let carry = Buffer.alloc(0);
|
|
36
|
+
let total = 0;
|
|
37
|
+
let row = 0;
|
|
38
|
+
const seen = { nearest: null };
|
|
39
|
+
/** One complete line: the lineage when it settles the answer, else `undefined` to keep reading. */
|
|
40
|
+
const settle = (line) => {
|
|
41
|
+
if (line.toString("utf-8").trim() === "")
|
|
42
|
+
return undefined;
|
|
43
|
+
row += 1;
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(line.toString("utf-8"));
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
throw new TranscriptUnreadable(`row ${row} of ${path} is not JSON: ${err.message}`);
|
|
50
|
+
}
|
|
51
|
+
const id = parsed?.sessionId;
|
|
52
|
+
if (typeof id !== "string" || id === "")
|
|
53
|
+
return undefined;
|
|
54
|
+
if (id !== ownSessionId) {
|
|
55
|
+
seen.nearest = id;
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
return seen.nearest === null ? { kind: "not_fork" } : { kind: "fork", parent: seen.nearest };
|
|
59
|
+
};
|
|
60
|
+
try {
|
|
61
|
+
for await (const chunk of source) {
|
|
62
|
+
if (options.signal?.aborted === true)
|
|
63
|
+
throw new TranscriptReadAborted("the deadline passed");
|
|
64
|
+
total += chunk.length;
|
|
65
|
+
if (total > TRANSCRIPT_MAX_PREFIX_BYTES) {
|
|
66
|
+
throw new TranscriptUnreadable(`${path} has no row of this session within its first ${TRANSCRIPT_MAX_PREFIX_BYTES} bytes`);
|
|
67
|
+
}
|
|
68
|
+
let start = 0;
|
|
69
|
+
for (let end = chunk.indexOf(NEWLINE, start); end !== -1; end = chunk.indexOf(NEWLINE, start)) {
|
|
70
|
+
const piece = chunk.subarray(start, end);
|
|
71
|
+
if (carry.length + piece.length > TRANSCRIPT_MAX_LINE_BYTES) {
|
|
72
|
+
throw new TranscriptUnreadable(`row ${row + 1} of ${path} is over ${TRANSCRIPT_MAX_LINE_BYTES} bytes`);
|
|
73
|
+
}
|
|
74
|
+
const settled = settle(Buffer.concat([carry, piece]));
|
|
75
|
+
carry = Buffer.alloc(0);
|
|
76
|
+
if (settled !== undefined)
|
|
77
|
+
return settled;
|
|
78
|
+
start = end + 1;
|
|
79
|
+
}
|
|
80
|
+
const rest = chunk.subarray(start);
|
|
81
|
+
if (carry.length + rest.length > TRANSCRIPT_MAX_LINE_BYTES) {
|
|
82
|
+
throw new TranscriptUnreadable(`row ${row + 1} of ${path} is over ${TRANSCRIPT_MAX_LINE_BYTES} bytes`);
|
|
83
|
+
}
|
|
84
|
+
carry = Buffer.concat([carry, rest]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
if (options.signal?.aborted === true)
|
|
89
|
+
throw new TranscriptReadAborted("the deadline passed");
|
|
90
|
+
if (err instanceof TranscriptUnreadable || err instanceof TranscriptReadAborted)
|
|
91
|
+
throw err;
|
|
92
|
+
throw new TranscriptUnreadable(`cannot read ${path}: ${err.message}`);
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
options.signal?.removeEventListener("abort", stop);
|
|
96
|
+
stop();
|
|
97
|
+
}
|
|
98
|
+
const settled = settle(carry);
|
|
99
|
+
if (settled !== undefined)
|
|
100
|
+
return settled;
|
|
101
|
+
if (seen.nearest === null)
|
|
102
|
+
throw new TranscriptUnreadable(`no row of ${path} names a session`);
|
|
103
|
+
// The file ends before any row of this session: everything in it was copied from an earlier one.
|
|
104
|
+
return { kind: "fork", parent: seen.nearest };
|
|
105
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* The zod schema for `50arms_api`'s `query` argument, matching the
|
|
4
|
+
* `QueryValue` type above — a scalar, a field-tree object, or an array of
|
|
5
|
+
* scalars (comma-joined). Exported so it can be exercised
|
|
6
|
+
* directly in tests without going through the MCP tool-call machinery.
|
|
7
|
+
*/
|
|
8
|
+
export const queryValueSchema = z.record(z.union([
|
|
9
|
+
z.string(),
|
|
10
|
+
z.number(),
|
|
11
|
+
z.boolean(),
|
|
12
|
+
z.null(),
|
|
13
|
+
z.record(z.unknown()),
|
|
14
|
+
z.array(z.union([z.string(), z.number(), z.boolean()])),
|
|
15
|
+
]));
|
|
16
|
+
/**
|
|
17
|
+
* DX-3685 — the zod schema for `50arms_api`'s `body` argument. Every
|
|
18
|
+
* dashboard route that takes a body validates it against a
|
|
19
|
+
* `BodyObjectSchema` (`src/http/body-schema.ts`, this repo) whose top-level
|
|
20
|
+
* `type` is ALWAYS `"object"` — no route ever accepts a body that is a
|
|
21
|
+
* string, an array, or any other JSON type at the top level (a plain-object
|
|
22
|
+
* request is the ONLY shape `validateBody`'s `"object"` case accepts; every
|
|
23
|
+
* other top-level shape hits its `body: must be an object` refusal).
|
|
24
|
+
*
|
|
25
|
+
* The previous schema, `z.unknown()`, compiles to `{}` in the tool's
|
|
26
|
+
* published JSON Schema — "any value, no structural hint at all" — which
|
|
27
|
+
* gave the calling model zero signal that an OBJECT was expected. In
|
|
28
|
+
* production this meant the model sometimes serialized `body` as a
|
|
29
|
+
* JSON-encoded STRING (e.g. `'{"text":"test"}'` instead of
|
|
30
|
+
* `{text:"test"}`); `http-client.ts#request` then ran `JSON.stringify` on
|
|
31
|
+
* that already-stringified value, producing a JSON STRING LITERAL as the
|
|
32
|
+
* wire body instead of a JSON object, which the dashboard's shared body
|
|
33
|
+
* validator correctly rejected with `body: must be an object` on every
|
|
34
|
+
* write (DX-3685).
|
|
35
|
+
*
|
|
36
|
+
* `z.record(z.unknown())` (the same idiom `queryValueSchema` above uses for
|
|
37
|
+
* its own field-tree branch) both (a) advertises `type: "object"` in the
|
|
38
|
+
* published JSON Schema, giving the model the structural hint it was
|
|
39
|
+
* missing, and (b) makes zod itself refuse a string/array/number/etc body
|
|
40
|
+
* at the MCP validation boundary with a clear, actionable
|
|
41
|
+
* `Expected object, received string`-class error — never silently
|
|
42
|
+
* forwarded, and never auto-`JSON.parse`d back into shape (Core Principle 1
|
|
43
|
+
* forbids tolerating a second input shape).
|
|
44
|
+
*/
|
|
45
|
+
export const bodyValueSchema = z.record(z.unknown());
|