@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.
Files changed (75) hide show
  1. package/README.md +114 -2
  2. package/dist/access-request.js +101 -0
  3. package/dist/agent-gate.js +69 -0
  4. package/dist/board-id.js +27 -0
  5. package/dist/bounded-retry.js +25 -0
  6. package/dist/checkout-docs-root.js +54 -0
  7. package/dist/clear-control.js +77 -0
  8. package/dist/compaction-control.js +142 -0
  9. package/dist/credential.js +104 -0
  10. package/dist/danxbot-api-examples.js +97 -0
  11. package/dist/dashboard-identity.js +14 -0
  12. package/dist/design-screenshot.js +138 -0
  13. package/dist/digest-hold.js +67 -0
  14. package/dist/docs-tools.js +140 -0
  15. package/dist/durable-write.js +56 -0
  16. package/dist/entrypoint.js +35 -0
  17. package/dist/forbidden-hint.js +23 -0
  18. package/dist/guarded-calls.js +21 -0
  19. package/dist/handlers.js +385 -0
  20. package/dist/heartbeat-reporter.js +156 -0
  21. package/dist/http-client.js +190 -0
  22. package/dist/index.js +1128 -0
  23. package/dist/json-body.js +12 -0
  24. package/dist/key-revoked-halt.js +30 -0
  25. package/dist/lifecycle.js +113 -0
  26. package/dist/listen.js +999 -0
  27. package/dist/listener-reminder.js +119 -0
  28. package/dist/machine-daemon/autostart.js +160 -0
  29. package/dist/machine-daemon/beat.js +77 -0
  30. package/dist/machine-daemon/cli.js +42 -0
  31. package/dist/machine-daemon/install.js +168 -0
  32. package/dist/machine-daemon/state.js +64 -0
  33. package/dist/machine-daemon/version.js +13 -0
  34. package/dist/machine-daemon.js +553 -0
  35. package/dist/machine-toolkit.js +460 -0
  36. package/dist/mini-app-build.js +159 -0
  37. package/dist/mini-app-checkout.js +215 -0
  38. package/dist/mini-app-output.js +111 -0
  39. package/dist/mini-app-publish.js +154 -0
  40. package/dist/mini-app-restore.js +175 -0
  41. package/dist/one-line.js +25 -0
  42. package/dist/pacing-relay.js +16 -0
  43. package/dist/permission-request.js +134 -0
  44. package/dist/permission-watch.js +145 -0
  45. package/dist/plan-events-relay.js +572 -0
  46. package/dist/plan-events-tool.js +9 -0
  47. package/dist/plugin-publish.js +38 -0
  48. package/dist/plugin-screenshot.js +81 -0
  49. package/dist/request-bound.js +21 -0
  50. package/dist/restart-notice.js +271 -0
  51. package/dist/result-limit.js +31 -0
  52. package/dist/role-instruction-file.js +36 -0
  53. package/dist/route-board-scope.generated.js +95 -0
  54. package/dist/route-board-scope.js +71 -0
  55. package/dist/session-access.js +322 -0
  56. package/dist/session-connection.js +264 -0
  57. package/dist/session-control-file.js +58 -0
  58. package/dist/session-key.js +125 -0
  59. package/dist/session-options.js +60 -0
  60. package/dist/session-stats.js +235 -0
  61. package/dist/session-stream.js +345 -0
  62. package/dist/session-title.js +129 -0
  63. package/dist/shutdown.js +38 -0
  64. package/dist/stop-fixes.js +50 -0
  65. package/dist/subagents-live.js +162 -0
  66. package/dist/terms-halt.js +39 -0
  67. package/dist/text-limit.js +24 -0
  68. package/dist/tool-access.js +114 -0
  69. package/dist/tool-defs.js +58 -0
  70. package/dist/tool-surface.js +59 -0
  71. package/dist/transcript-lineage.js +105 -0
  72. package/dist/types.js +45 -0
  73. package/dist/urgent-records.js +47 -0
  74. package/dist/version-floor.js +146 -0
  75. 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());