@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
package/dist/index.js ADDED
@@ -0,0 +1,1128 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @50arms/platform-mcp
4
+ *
5
+ * Stdio MCP server wrapping danxbot's dashboard `/api/issues/*`
6
+ * normalized DB-backed HTTP routes (DX-704 Phase 2 / DX-811). Replaces
7
+ * the legacy "agents Edit/Write YAML files directly" pattern: every
8
+ * tool here POSTs/GETs against the dashboard, which atomically applies
9
+ * the change via the transactional layer and publishes SSE.
10
+ *
11
+ * Tool surface (all exposed as `mcp__platform__<name>` once wired
12
+ * through the workspace `mcp.template.json`):
13
+ *
14
+ * - 50arms_api the generic surface for every non-agent-specific dashboard
15
+ * operation (DX-3465 / PLN-11 R-25) — list/get/create/update/delete
16
+ * against the uniform resource routes that used to each have their
17
+ * own per-operation tool (issue_*, plan_* (except plan_connect),
18
+ * brief_*, repo_knowledge_*, failure_category_*,
19
+ * dispatch_transcript_search, quality_gate_instruction — all deleted
20
+ * here). See `SERVER_INSTRUCTIONS` below for the calling convention.
21
+ * - 50arms_api_list lists the routes your credential may call, one string each (DX-3465
22
+ * A5, backed by DX-3528's permission-aware discovery). Call this
23
+ * first to find the route for something you need to do.
24
+ * - 50arms_api_spec discovers the exact request/response shape of any route
25
+ * `50arms_api` can call, before calling it.
26
+ * - request_permission POST /api/permission-requests (DX-4434) — asks a person to grant
27
+ * this key ONE MORE permission while the agent works; answers at once
28
+ * with the approval URL and confirm code, and the grant is picked up in
29
+ * the background (`permission-request.ts`). A dashboard call refused
30
+ * `403 forbidden` carries a hint to call it (`forbidden-hint.ts`).
31
+ * - plan_connect POST /api/plan-sessions/me/plan — SESSION-BOUND (kept as a
32
+ * dedicated tool; every plan READ or WRITE operation goes
33
+ * through 50arms_api now, e.g. GET /api/plans/mine or
34
+ * POST /api/plans/mine/records)
35
+ *
36
+ * DX-2683, updated DX-3465 — PLAN WRITES ARE SESSION-BOUND, and
37
+ * asymmetrically so relative to reads. There is no dedicated plan-write
38
+ * tool any more (DX-3465 deleted every `plan_add_*`/`plan_update_*`/
39
+ * `plan_delete_*`/`plan_remove_card`/`plan_rename`/`plan_reorder_*` tool);
40
+ * a plan write is an ordinary `50arms_api` call to a `/api/plans/mine/...`
41
+ * route. Reads may name any plan by id (`/api/plans/:id/...`); a WRITE
42
+ * route instead takes NO plan id at all and acts on the plan this session
43
+ * is connected to (`plan_connect`), resolved server-side from the session
44
+ * id this package forwards on every request. A session therefore cannot
45
+ * modify a plan it is not connected to, whatever id it believes. A write
46
+ * from an unconnected session is refused loudly (`session_not_connected`),
47
+ * never silently dropped and never guessed at.
48
+ *
49
+ * BOARD-ONLY (DX-1171): board is the first-level concept; repo is
50
+ * DERIVED from board server-side, never passed. The package composes the
51
+ * dispatch's board ref from DANXBOT_BOARD_ID at boot and sends it as
52
+ * `board` — the dashboard owns board→repo resolution.
53
+ *
54
+ * Env at boot (validated fail-loud — a missing required var → process.exit(1)):
55
+ * DANXBOT_DASHBOARD_URL dashboard base (e.g. http://danxbot-dashboard:5555)
56
+ * the CREDENTIAL (DX-4390), resolved by the ONE resolver this package's event relay
57
+ * also uses (`credential.ts`, DX-2862), and never a reason to exit:
58
+ * DANX_DASHBOARD_CREDENTIAL the bearer verbatim (a dispatch, substituted into
59
+ * the materialized `.mcp.json` from its overlay)
60
+ * otherwise this session's approved key (`session-key.ts`), else none: the
61
+ * server boots SIGNED OUT, every tool but `plan_connect`
62
+ * halts, and `plan_connect` requests access (`session-access.ts`)
63
+ * DANXBOT_TEAM OPTIONAL (DX-4446), non-secret: the team slug (or id) whose
64
+ * approvers see this session's access request in their danxbot
65
+ * site. Set it in your shell or a repo's committed `.claude/settings.json` `env` block.
66
+ * Unset: the request is answered by link only. Set but not a
67
+ * valid ref, or unknown to the dashboard: fails loud, never a
68
+ * silent fall back to the link flow.
69
+ * DANXBOT_BOARD_ID OPTIONAL (DX-4555): the dispatch's board, the board's ref number the worker injects;
70
+ * a positive integer (anything else exits loudly), appended as ?board=<ref>.
71
+ * Each tool's optional `board` arg (a board ref)
72
+ * overrides it per-call for cross-board reads/writes.
73
+ *
74
+ * Envelope contract: every tool returns the dashboard's response body
75
+ * verbatim wrapped as `{ok, status, body}`. Refusals (4xx with the v2
76
+ * `{error, ...extra}` shape) come through as `{ok: false, ...}`; the
77
+ * agent reads `body.error` + structured fields to decide next action.
78
+ * 5xx and network failures throw — never silently swallowed.
79
+ */
80
+ import { MACHINE_DAEMON_SUBCOMMANDS, runMachineDaemonCommand } from "./machine-daemon/cli.js";
81
+ import { isEntrypointModule } from "./entrypoint.js";
82
+ import { compactionFilesIn } from "./compaction-control.js";
83
+ import { clearFilesIn } from "./clear-control.js";
84
+ import { PlanEventRelay, WAIT_MAX_MS, WAIT_MIN_MS } from "./plan-events-relay.js";
85
+ import { READ_IDLE_TIMEOUT_MS } from "./listen.js";
86
+ import { REQUEST_TIMEOUT_MS, realScheduleInterval, realSleep } from "./session-stream.js";
87
+ import { servePlanEventsWait } from "./plan-events-tool.js";
88
+ import { lookupRestartNotice, RESTART_NOTICE_ARGS } from "./restart-notice.js";
89
+ import { makeSessionStop, stopWhenSessionEnds } from "./shutdown.js";
90
+ import { SUBAGENTS_LIVE_SUBCOMMAND, runSubagentsLiveCommand } from "./subagents-live.js";
91
+ import { resolveBootCredential } from "./credential.js";
92
+ import { SessionKeyUnusableError, deleteSessionKey } from "./session-key.js";
93
+ import { assertMcpTeamRef } from "./access-request.js";
94
+ import { SessionAccess } from "./session-access.js";
95
+ import { PermissionRequests } from "./permission-request.js";
96
+ import { makeGuardedCalls } from "./guarded-calls.js";
97
+ import { readOwnVersion } from "./version-floor.js";
98
+ import { createCheckoutDocsRootReporter } from "./checkout-docs-root.js";
99
+ import { isPickupConnect, projectKeyOf, recordSessionConnectionAfterConnect, removeSessionConnectionAfterLeave } from "./session-connection.js";
100
+ import { createListenerReminder, isPlanConnectedOnThisMachine, } from "./listener-reminder.js";
101
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
102
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
103
+ import { z } from "zod";
104
+ import { boardRefArgSchema, parseBoardRefText, repoNameArgSchema } from "./board-id.js";
105
+ import { DashboardHttpClient } from "./http-client.js";
106
+ import { cardConnect, claimSessionAgent, planConnect, danxbotApi, danxbotApiList, danxbotApiSpec, attachFile, DANXBOT_API_METHODS, } from "./handlers.js";
107
+ import { miniAppPublish } from "./mini-app-publish.js";
108
+ import { miniAppRestore } from "./mini-app-restore.js";
109
+ import { pluginPublish } from "./plugin-publish.js";
110
+ import { docsRead, docsSearch, docsSourceShape } from "./docs-tools.js";
111
+ import { spillRoleInstruction } from "./role-instruction-file.js";
112
+ import { DEFAULT_LIMIT, MAX_SEARCH_LIMIT } from "@danxbot/docs/agent";
113
+ import { boundedResultText } from "./result-limit.js";
114
+ import { bodyValueSchema, queryValueSchema } from "./types.js";
115
+ import { termsAcceptanceCallHalt, termsHaltFor } from "./terms-halt.js";
116
+ import { danxbotApiExamplesText } from "./danxbot-api-examples.js";
117
+ import { assertWithinTextLimit } from "./text-limit.js";
118
+ import { registerLifecycleTools, presentCallbacksFromEnv } from "./lifecycle.js";
119
+ import { LEGACY_TOOL_NAMES, TOOL_ACCESS } from "./tool-access.js";
120
+ import { ToolAccessSync, ToolSurface } from "./tool-surface.js";
121
+ import { PermissionWatch } from "./permission-watch.js";
122
+ import { AgentGate, isAgentGateOpenCall, isAgentGateOpenSpec, onlyAgentProposalRoutes } from "./agent-gate.js";
123
+ import { SYNC_ATTEMPTS, retryBounded } from "./bounded-retry.js";
124
+ import { dashboardIdentity } from "./dashboard-identity.js";
125
+ function readEnvOrDie(name) {
126
+ const v = process.env[name];
127
+ if (typeof v !== "string" || v === "") {
128
+ console.error(`[platform-mcp] required env var ${name} is missing or empty`);
129
+ process.exit(1);
130
+ }
131
+ return v;
132
+ }
133
+ /**
134
+ * Read an OPTIONAL env var, treating empty string as absent. DX-1398 — the
135
+ * worker injects `DANXBOT_TRACEPARENT` for a traced dispatch and substitutes it
136
+ * to `""` for an untraced one (the optional-placeholder default), so empty
137
+ * MUST read as "no trace" rather than a literal value. Never exits — an
138
+ * untraced dispatch is valid.
139
+ */
140
+ function readEnvOptional(name) {
141
+ const v = process.env[name];
142
+ return typeof v === "string" && v !== "" ? v : undefined;
143
+ }
144
+ /** DX-4555 / DX-5512: optional `DANXBOT_BOARD_ID`; set, it must be a positive integer or boot fails loud (a name or a malformed value is never a board). */
145
+ function readBoardId() {
146
+ const text = readEnvOptional("DANXBOT_BOARD_ID");
147
+ if (text === undefined)
148
+ return undefined;
149
+ const id = parseBoardRefText(text);
150
+ if (id === null) {
151
+ console.error(`[platform-mcp] DANXBOT_BOARD_ID must be a positive integer (the board's ref), got "${text}"`);
152
+ process.exit(1);
153
+ }
154
+ return id;
155
+ }
156
+ /** DX-4446: optional `DANXBOT_TEAM`; set, it must be a valid team ref or boot fails loud (no fallback to the link flow). */
157
+ function readTeamRef() {
158
+ const team = readEnvOptional("DANXBOT_TEAM");
159
+ if (team === undefined)
160
+ return undefined;
161
+ try {
162
+ return assertMcpTeamRef(team);
163
+ }
164
+ catch (err) {
165
+ console.error(err.message);
166
+ process.exit(1);
167
+ }
168
+ }
169
+ // The dispatch config + HTTP client are LATE-BOUND (assigned by `boot()` at
170
+ // entrypoint, below). Tool registration happens at module import and the
171
+ // callbacks close over these `let` bindings, reading them at CALL time — so the
172
+ // module can be IMPORTED without env (the DX-1606 tool-defs generator + its
173
+ // drift test introspect `server`'s tool schemas without spawning a dispatch),
174
+ // while a real run still fails loud on missing env via `boot()`.
175
+ let config;
176
+ let client;
177
+ // DX-3997 — late-bound with `client`; see `listener-reminder.ts`.
178
+ let listenerReminder;
179
+ /**
180
+ * DX-4390 — this server's credential: a dispatch's env bearer, this session's approved key, or
181
+ * none (signed out). The plan event relay in this server signs in with it directly (`access.currentToken()`).
182
+ * `restart_notice` runs against other sessions' credentials and cannot see it, so `access.connection()` (the URL, where the credential is, its
183
+ * fingerprint; never the credential itself) is written to the session's connection record on a successful `plan_connect`
184
+ * for it.
185
+ */
186
+ let access;
187
+ // DX-4434 — late-bound with `access` and `client`; see `permission-request.ts`.
188
+ let permissionRequests;
189
+ // DX-4523 - late-bound with `access` and `client`; see `tool-surface.ts` and `permission-watch.ts`.
190
+ let toolAccessSync;
191
+ let permissionWatch;
192
+ // DX-4233 - late-bound with `access`; see `plan-events-relay.ts`. The plugin's relay loop (a person's session) and an orchestrator's waiter sub-agent (DX-4667) call `plan_events_wait` on it.
193
+ let planEventRelay;
194
+ /**
195
+ * Read the dispatch's default board from DANXBOT_BOARD_ID (the board's ref number the worker injects at the spawn site) and build the HTTP client.
196
+ * Unset (DX-4555) leaves the server board-less: a call that needs a board must then carry its own `board`
197
+ * argument, or the dashboard answers 400 (DX-4592: the client no longer refuses on its behalf). A value that is not a positive integer fails loud.
198
+ */
199
+ function boot() {
200
+ const dashboardUrl = readEnvOrDie("DANXBOT_DASHBOARD_URL");
201
+ const board = readBoardId();
202
+ const session = readSessionConfig();
203
+ config = {
204
+ baseUrl: dashboardUrl,
205
+ // DX-4390 — set by `SessionAccess` below; "" while signed out.
206
+ token: "",
207
+ board,
208
+ // DX-1398 — OPTIONAL cross-process trace context. Present → stamped on every
209
+ // outbound call so the agent's card writes chain under the launch; absent
210
+ // (untraced dispatch) → omitted, and the dashboard mints a fresh root.
211
+ traceparent: readEnvOptional("DANXBOT_TRACEPARENT"),
212
+ // DX-2683 — the working session, when there is one. See below.
213
+ session,
214
+ };
215
+ client = new DashboardHttpClient(config);
216
+ const dashboardClient = client;
217
+ access = new SessionAccess({
218
+ dashboardUrl,
219
+ sessionId: session?.id,
220
+ clientVersion: readOwnVersion(),
221
+ team: readTeamRef(),
222
+ setToken: (token) => dashboardClient.setToken(token),
223
+ initial: resolveInitialCredential(dashboardUrl, session?.id),
224
+ log: (line) => console.error(line),
225
+ });
226
+ permissionRequests = new PermissionRequests({
227
+ access,
228
+ request: (args) => dashboardClient.request(args),
229
+ requestOwn: (args) => dashboardClient.requestOwn(args),
230
+ log: (line) => console.error(line),
231
+ });
232
+ listenerReminder = createListenerReminder({
233
+ sessionId: config.session?.id,
234
+ fetchHealth: () => dashboardClient.requestOwn({ method: "GET", basePath: "", path: "/api/plan-sessions/me/listener-health" }),
235
+ isPlanConnected: isPlanConnectedOnThisMachine,
236
+ now: Date.now,
237
+ logLine: (line) => console.error(line),
238
+ });
239
+ // DX-4523 - a session sees only the tools it can call (`toolsFor`, `tool-access.ts`). Applied here (inside `boot()`,
240
+ // before `server.connect()`) as a session with no key: the bootstrap set plus the callback tools this dispatch's worker
241
+ // gave a URL for. `syncToolsAfterSignIn` widens it once the key's permissions are read. The DX-1606 tool-defs
242
+ // generator imports this module WITHOUT calling `boot()`, so it still sees every tool enabled.
243
+ const surface = new ToolSurface(registeredTools, presentCallbacksFromEnv(), {
244
+ // A dispatch's credential is the env bearer its worker injected; a person's session holds none or its own session key.
245
+ operatorSession: access.connection()?.source.kind !== "env",
246
+ onChanged: () => server.sendToolListChanged(),
247
+ });
248
+ surface.apply(new Set());
249
+ toolAccessSync = new ToolAccessSync({
250
+ surface,
251
+ readPermissions: readHeldPermissions,
252
+ log: (line) => console.error(line),
253
+ });
254
+ // DX-4233 - the relay signs in as THIS server does, with the key it holds now, as this session.
255
+ planEventRelay = new PlanEventRelay({
256
+ auth: () => {
257
+ const connection = access.connection();
258
+ return connection === null || session === undefined
259
+ ? null
260
+ : { dashboardUrl: connection.dashboardUrl, token: access.currentToken(), sessionId: session.id };
261
+ },
262
+ fetch: (input, init) => fetch(input, init),
263
+ sleep: realSleep,
264
+ now: Date.now,
265
+ readIdleTimeoutMs: READ_IDLE_TIMEOUT_MS,
266
+ requestTimeoutMs: REQUEST_TIMEOUT_MS,
267
+ scheduleInterval: realScheduleInterval,
268
+ compactionFiles: compactionFilesIn(),
269
+ clearFiles: clearFilesIn(),
270
+ onAgentCreated: adoptCreatedAgent,
271
+ });
272
+ permissionWatch = new PermissionWatch({
273
+ dashboardUrl,
274
+ token: () => access.currentToken(),
275
+ sync: () => toolAccessSync.sync(),
276
+ log: (line) => console.error(line),
277
+ });
278
+ }
279
+ /**
280
+ * DX-4523 - this key's current permissions (`GET /api/auth/permissions`, DX-6369: the key list alone, where `/api/auth/me` composed a 27.6 KB payload that took seconds under load). A dashboard failure throws, a 401 or 403 included: a key the
281
+ * dashboard refuses holds nothing to advertise, and narrowing the tool list quietly would hide that.
282
+ */
283
+ async function readKeyPermissions() {
284
+ if (!access.isSignedIn())
285
+ return { outcome: "no-key" };
286
+ // DX-5076: this process reading its own key's permissions is not the agent acting.
287
+ const guarded = await access.guard(() => client.requestOwn({ method: "GET", basePath: "", path: "/api/auth/permissions" }));
288
+ if ("halt" in guarded)
289
+ return { outcome: "no-key" };
290
+ const { result } = guarded;
291
+ if (termsHaltFor(result) !== null)
292
+ return { outcome: "terms-pending" };
293
+ if (!result.ok) {
294
+ throw new Error(`GET /api/auth/permissions answered ${result.status}: ${JSON.stringify(result.body)}`);
295
+ }
296
+ const permissions = result.body?.permissions;
297
+ if (!Array.isArray(permissions) || !permissions.every((p) => typeof p === "string")) {
298
+ throw new Error(`GET /api/auth/permissions answered without a permissions list: ${JSON.stringify(result.body)}`);
299
+ }
300
+ return { outcome: "held", permissions: new Set(permissions) };
301
+ }
302
+ /** The tool surface's input: the key's permissions, or null for a session that holds none (bootstrap set only). */
303
+ async function readHeldPermissions() {
304
+ const key = await readKeyPermissions();
305
+ return key.outcome === "held" ? key.permissions : null;
306
+ }
307
+ /**
308
+ * How hard a failed permission read is for this session. A dispatch cannot work without its tools, so it retries a few times (a
309
+ * dashboard blip at spawn must not cost it its MCP server) and then fails the boot; an operator session logs it and keeps the
310
+ * bootstrap set until its next `plan_connect` or the permission stream's next connect.
311
+ */
312
+ function syncPolicy() {
313
+ const isDispatch = access.connection()?.source.kind === "env";
314
+ return { attempts: isDispatch ? SYNC_ATTEMPTS : 1, fatal: isDispatch };
315
+ }
316
+ async function syncToolsWithPolicy() {
317
+ const { attempts, fatal } = syncPolicy();
318
+ try {
319
+ await retryBounded(() => toolAccessSync.sync(), { attempts, sleep: sleepHoldingProcess });
320
+ }
321
+ catch (err) {
322
+ if (fatal)
323
+ throw err;
324
+ console.error(`[platform-mcp] could not read this key's permissions, tools stay at the bootstrap set: ${err.message}`);
325
+ }
326
+ }
327
+ /**
328
+ * DX-4523 - show the tools the key's permissions allow, and watch for a grant. Called at boot and again whenever a
329
+ * `plan_connect` finds the session signed in, which is the moment a first approval or a re-approved key takes effect.
330
+ */
331
+ async function syncToolsAfterSignIn() {
332
+ if (!access.isSignedIn())
333
+ return;
334
+ await syncToolsWithPolicy();
335
+ permissionWatch.start();
336
+ }
337
+ /**
338
+ * DX-6157 - a person created the agent this session proposed, and the dashboard signed the session in as it (and connected it to its plan or card) without a `plan_connect` from the session. The
339
+ * session therefore never saw a connect answer, so this does here what one that binds does: it opens the agent gate (`50arms_api` stops answering `agent_needed`), shows the tools the key's
340
+ * permissions allow, restarts the listener reminder's health read and, for a session now on a plan or card, writes the local connection record `restart_notice` and the listener reminder read.
341
+ * Called by the relay's listener as it writes the notice; a failure is a stderr line, never silence (the notice itself still reaches the session).
342
+ */
343
+ function adoptCreatedAgent(created) {
344
+ agentGate.record({ agent: created.agent });
345
+ listenerReminder.invalidate();
346
+ const connection = access.connection();
347
+ if (created.plan !== null || created.card !== null) {
348
+ // DX-6157: the frame arrived over this key's own stream, so no credential here is a broken invariant: said loudly, as plan_connect throws on it.
349
+ if (connection === null) {
350
+ console.error("agent created: no credential after the dashboard signed this session in; the local connection record was not written");
351
+ }
352
+ else {
353
+ const recorded = recordSessionConnectionAfterConnect({ ok: true }, { sessionId: config.session?.id, projectKey: projectKeyOf(process.cwd()), dashboardUrl: connection.dashboardUrl, source: connection.source, fingerprint: connection.fingerprint });
354
+ if (recorded.connection_record_error !== undefined)
355
+ console.error(`agent created: ${recorded.connection_record_error}`);
356
+ }
357
+ }
358
+ syncToolsAfterSignIn().catch((err) => console.error(`agent created: showing the tools after the sign-in failed: ${err.message}`));
359
+ }
360
+ /**
361
+ * Before `server.connect` nothing reads stdin, so an unref'd timer (`defaultSleep`) would let the process exit silently with
362
+ * code 0 in the middle of the retry. This one keeps the process alive until it fires.
363
+ */
364
+ const sleepHoldingProcess = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
365
+ /**
366
+ * DX-2862 / DX-4390 — the credential this server boots with, through the ONE resolver
367
+ * `restart_notice` also uses (`session-options.ts`), so a session's tools and its lookups can never sign in as
368
+ * two different identities. None is not an error: the server boots signed out. A stored key that
369
+ * is there but unusable is reported loudly and deleted - it is a cache of a revocable key, and the
370
+ * session asks for a new one.
371
+ */
372
+ function resolveInitialCredential(dashboardUrl, sessionId) {
373
+ try {
374
+ return resolveBootCredential(process.env, { dashboardUrl, sessionId });
375
+ }
376
+ catch (err) {
377
+ if (!(err instanceof SessionKeyUnusableError) || sessionId === undefined)
378
+ throw err;
379
+ console.error(`[platform-mcp] ${err.message}; deleting it, this session must request access again`);
380
+ deleteSessionKey(err.path);
381
+ return null;
382
+ }
383
+ }
384
+ /**
385
+ * DX-2683 — WHO THIS SESSION IS, read from the environment Claude Code
386
+ * already provides.
387
+ *
388
+ * `CLAUDE_CODE_SESSION_ID` is set on every process an interactive Claude Code
389
+ * session spawns — this server among them — so no launcher, no dispatch
390
+ * overlay and no agent cooperation is needed for a session to be identifiable.
391
+ *
392
+ * OPTIONAL, unlike every other env this package reads. A process running
393
+ * outside a Claude Code session has no session to report; it sends no session
394
+ * headers and behaves exactly as this package did before. Requiring it would
395
+ * break the tool-defs generator, the drift test, and any non-Claude consumer.
396
+ *
397
+ * NO TITLE HERE AT ALL (DX-2816, revised). This used to guess one from
398
+ * `basename(process.cwd())`, which is how a dashboard Connect list ended up
399
+ * showing "gpt-manager" for a session Claude itself calls "Fix program
400
+ * status tracking" — the repo name told two sessions in the same checkout
401
+ * apart from EACH OTHER, but never matched what the operator sees in Claude.
402
+ * A later `DANX_SESSION_TITLE` env fallback was tried next and also removed:
403
+ * DX-2816's research found no documented env var, hook input, or
404
+ * MCP-server-reachable signal carries Claude's own session title — this
405
+ * SERVER genuinely cannot read it. The AGENT can, though (a Desktop-side
406
+ * `get_session` tool this server has no access to), and the agent is what
407
+ * calls `plan_connect` — so the title travels as that tool's own `title`
408
+ * argument (see `handlers.ts#planConnect`) at connect time, not as a header
409
+ * stamped here. DX-4461: the session's TRANSCRIPT carries every rename as a
410
+ * `custom-title` record, so the event relay in this server reads it from
411
+ * there and reports it on its heartbeat (`session-title.ts`) and a later rename needs no
412
+ * agent call. A session that never connects with a title keeps the
413
+ * dashboard's own placeholder (`session <id prefix>`) until the relay reports
414
+ * one — an honest placeholder beats a guess.
415
+ */
416
+ function readSessionConfig() {
417
+ const id = readEnvOptional("CLAUDE_CODE_SESSION_ID");
418
+ if (id === undefined)
419
+ return undefined;
420
+ return { id };
421
+ }
422
+ // DX-5549: whether this session holds a team agent; closed by a plan_connect answer of `agent_needed` / `agent_limit_reached`, opened only by one carrying an agent.
423
+ const agentGate = new AgentGate();
424
+ /**
425
+ * DX-3465 (PLN-11 R-25) — the conventions shared by every `50arms_api` call,
426
+ * stated once here instead of on each tool. DX-4525 / DX-4015: its first paragraph makes `50arms_api` THE channel and says
427
+ * to read the spec first (an agent skipping the spec is a behaviour to fix, not a reason to keep a tool per job).
428
+ *
429
+ * DX-4318 — this block is the ONE home of the stale-hash rule and of the
430
+ * writing standard (PLN-11 R-24): it is the only text every caller of these
431
+ * tools receives (operator sessions, their sub-agents, every dispatch
432
+ * profile). It was 3,916 characters and Claude Code delivered the first
433
+ * 2,048, so both rules were cut from every session. `assertWithinTextLimit`
434
+ * below refuses a block that would be cut again. Behaviour only, no rationale.
435
+ */
436
+ const SERVER_INSTRUCTIONS = `\
437
+ \`50arms_api\` is your primary channel to the dashboard: every card, plan, Slack and dispatch-callback job (ending
438
+ yours too) is a call to it. Find a route with \`50arms_api_list\`. Read its exact shape with
439
+ \`50arms_api_spec\` before the first call to any route you have not called before; never guess a body.
440
+
441
+ RESOURCES. Most collections take GET (list) and POST (create); a single resource (\`<collection>/<id>\`) takes
442
+ GET, PATCH or PUT (send only the fields you change) and DELETE. A card is edited at
443
+ \`PATCH /api/issues/<id>/edit\`. A card get answers the compact row; the card list answers a compact row per card, one page of 50 with
444
+ \`total\` (page with \`limit\` and \`offset\`). Name each other field or relation you need in the \`fields\` query
445
+ tree. \`GET /api/resources/<resource>/fields\` lists the names.
446
+
447
+ STALE HASH. A write to contested text sends back the \`content_hash\` or \`base_hash\` you read. A stale content write is
448
+ never refused: \`collided\` says what it met, so re-read. Only 409 \`merge_base_unknown\` is: reapply your
449
+ change to the text and hash it returns. Settings and sign-offs still refuse a stale hash: re-read, then write again.
450
+
451
+ REFUSALS. A 4xx body is \`{error, ...}\`, and its other fields name the fix: \`offending_keys\`, \`failed_gate\`,
452
+ \`unresolved_items\`, \`did_you_mean\`.
453
+
454
+ WRITING STANDARD, for every title, summary, description, comment, problem, solution and plan record you write. The
455
+ reader is new to the subject and will not read the code or docs: what you write is their documentation. Open with
456
+ plain background (what it is, why, what is wrong or changes) before evidence.
457
+ Define each term a newcomer would not know. Cite exact ids (\`PBLM-2757\`, not "problem 2757"), SHAs and paths. Be brief; never transcribe reasoning. A title or summary alone must let the reader decide.
458
+
459
+ DESIGN. UI designs and style options go on the card they concern (\`mini_app_publish\`), never an Artifact or HTML page.
460
+ Options are design states, never a chat or text question; a person chooses.
461
+ `;
462
+ assertWithinTextLimit("the server instructions", SERVER_INSTRUCTIONS);
463
+ export const server = new McpServer({
464
+ name: "platform-mcp",
465
+ version: "0.1.0",
466
+ }, { instructions: SERVER_INSTRUCTIONS });
467
+ function haltResult(text) {
468
+ return { content: [{ type: "text", text }], isError: true };
469
+ }
470
+ /** The dashboard this session is connected to, named (`dashboard-identity.ts`). ONE source: `config.baseUrl`, the URL every call goes to. */
471
+ function connectedDashboard() {
472
+ return dashboardIdentity(config.baseUrl);
473
+ }
474
+ /**
475
+ * DX-4390 — `plan_connect`'s answer while access is not in place: the request (or why there is none),
476
+ * an error when it names a failure. `note` says why a new request was needed.
477
+ */
478
+ function accessReply(ready, note) {
479
+ // the dashboard this session is on is named in the sign-in answer, so a session aimed at the wrong dashboard shows it before any write
480
+ if (ready.signedIn)
481
+ return jsonResult({ state: "signed_in", ...(note ? { note } : {}), dashboard: connectedDashboard() });
482
+ // DX-4418: a person revoked this session's access: the one stop halt, verbatim, never a request.
483
+ if (ready.halt !== undefined)
484
+ return haltResult(ready.halt);
485
+ // DX-6392: every access answer names the dashboard it is on, not only the signed-in one (AC 57978).
486
+ const answer = { ...ready.answer, dashboard: connectedDashboard(), ...(note ? { note } : {}) };
487
+ return ready.isError ? haltResult(JSON.stringify(answer)) : jsonResult(answer);
488
+ }
489
+ // DX-4390 / DX-4434 / DX-5549 / DX-5550: every tool's dashboard call goes through `guardedCall` (the agent gate, then the sign-in halt; see `guarded-calls.ts`).
490
+ const { guardedCall } = makeGuardedCalls({ access: () => access, gate: agentGate });
491
+ /** DX-4390 — a tool's dashboard call behind the sign-in halt (and the 401 sign-out). */
492
+ async function guardedJson(run) {
493
+ const guarded = await guardedCall(run);
494
+ return "halt" in guarded ? haltResult(guarded.halt) : jsonResult(guarded.result);
495
+ }
496
+ /** DX-4400 — `guardedJson` for the read tools whose answer can be a whole document: bounded. */
497
+ async function guardedBoundedJson(run, opts) {
498
+ const guarded = await guardedCall(run, opts);
499
+ return "halt" in guarded ? haltResult(guarded.halt) : boundedJsonResult(guarded.result);
500
+ }
501
+ function jsonResult(value) {
502
+ return {
503
+ // DX-4400: compact — nothing parses the indentation and it costs every call 16% to 55% more characters.
504
+ content: [{ type: "text", text: JSON.stringify(value) }],
505
+ };
506
+ }
507
+ /** DX-4400 / DX-6117 — a dashboard route's result, its body delivered as a file when over `MCP_MAX_RESULT_CHARS`. */
508
+ function boundedJsonResult(result) {
509
+ return { content: [{ type: "text", text: boundedResultText(result) }] };
510
+ }
511
+ /** DX-4523 - every tool `strictTool` registered, by name: what `ToolSurface` switches. */
512
+ const registeredTools = new Map();
513
+ /**
514
+ * DX-3238 — every tool on this server MUST reject an undeclared/mistyped
515
+ * argument instead of silently stripping it. `server.tool(name, description,
516
+ * shape, cb)` builds its input schema as a bare `z.object(shape)` internally
517
+ * (via the SDK's own `objectFromShape`), and zod's default behaviour for an
518
+ * object schema is to STRIP any key not in `shape` — so a caller's typo (or a
519
+ * field the route accepts but this tool never declared, e.g. the issue-create
520
+ * route's `priority` before this card) vanished before the HTTP request was even
521
+ * built, and the server's own fail-loud checks (e.g. `parseFilterObject`'s
522
+ * "Unknown filter key" 400) never got a chance to run.
523
+ *
524
+ * `strictTool` is the ONE place that fixes this for every tool at once: it
525
+ * wraps `shape` in `z.object(shape).strict()` itself and registers it via
526
+ * `registerTool` (the SDK preserves a pre-built Zod object schema's `.strict()`
527
+ * verbatim through `normalizeObjectSchema` — passing the same `shape` to the
528
+ * deprecated `server.tool(...)` overload instead would NOT: that overload only
529
+ * accepts a raw shape, which the SDK rebuilds as a plain `z.object(shape)`
530
+ * with no strict flag). A zod-rejected extra/misspelled key now throws
531
+ * `McpError(InvalidParams, ...)` naming the offending key and the accepted
532
+ * set, exactly like the dashboard's own `parseFilterObject` refusal.
533
+ */
534
+ function strictTool(name, description, shape, cb) {
535
+ // DX-4318 — the one registration path, so no tool can ship a description an agent would receive cut.
536
+ assertWithinTextLimit(`the "${name}" tool description`, description);
537
+ // DX-4523 - the same path, so no tool can ship without declaring what makes it usable.
538
+ if (!Object.hasOwn(TOOL_ACCESS, name)) {
539
+ throw new Error(`the "${name}" tool has no entry in TOOL_ACCESS (tool-access.ts): declare what makes it usable`);
540
+ }
541
+ const tool = server.registerTool(name, { description, inputSchema: z.object(shape).strict() }, cb);
542
+ registeredTools.set(name, tool);
543
+ return tool;
544
+ }
545
+ /**
546
+ * DX-6847 / DX-6864 (DEC-571): registers a tool the rename gave a new name (`LEGACY_TOOL_NAMES`) under its current name AND the name it had before. Both
547
+ * registrations share one schema and one handler (built per name, so an answer names the tool that was called), which is what makes the two answer identically;
548
+ * the old name's description only points at the new one. DX-6864 removes the old registration.
549
+ */
550
+ function renamedTool(name, description, shape, handlerFor) {
551
+ strictTool(name, description, shape, handlerFor(name));
552
+ const legacy = LEGACY_TOOL_NAMES[name];
553
+ strictTool(legacy, `The previous name of \`${name}\`: the same tool, answering identically. Use \`${name}\`; this name will be removed.`, shape, handlerFor(legacy));
554
+ }
555
+ // ---------------- lifecycle tool set (DX-3465 / PLN-11 R-25, DX-4525) ----------------
556
+ // One tool is left, `50arms_slack_reply` (it reads local files); every other dispatch callback is a `50arms_api` call
557
+ // (see `lifecycle.ts`). `toolsFor` (`tool-access.ts`) decides whether a session sees it.
558
+ registerLifecycleTools(renamedTool);
559
+ // Optional per-call board override shared by every tool. Omitted → the
560
+ // dispatch's env-derived board (`DANXBOT_BOARD_ID`) is used; provided → that
561
+ // board is targeted instead (the dashboard owns board→repo and 404s an
562
+ // unknown board). The override is a board ref (a number, or its decimal
563
+ // text), forwarded as the number: what the dashboard accepts beyond a ref
564
+ // is decided by its `parseRefNum`, never here. Spread into each
565
+ // tool's Zod input object so cross-board work is reachable from any
566
+ // dispatch without rebinding the MCP server.
567
+ const boardField = {
568
+ board: boardRefArgSchema
569
+ .optional()
570
+ .describe("Board ref (the number); omit for this dispatch's board (none when this server has no default board), except on `GET /api/issues`, where omitting it lists the whole team across every board. Unknown → 404."),
571
+ };
572
+ // DX-3465 (code-architecture gate finding, at 275f82c0d) — `resource_fields`
573
+ // (DX-3426) was deleted from here: it was a dedicated wrapper around a plain
574
+ // `GET /api/resources/:resource/fields`, which `50arms_api` already reaches
575
+ // with no agent-specific behavior earning it its own tool (R-25 keeps a
576
+ // dedicated tool only for what's genuinely agent-specific — session binding,
577
+ // local-file reads, dispatch lifecycle). Call
578
+ // `50arms_api({method:"GET", path:"/api/resources/<resource>/fields"})`
579
+ // instead; the route and its response shape are unchanged.
580
+ // ---------------- 50arms_api (DX-3465, PLN-11 R-25) ----------------
581
+ renamedTool("50arms_api",
582
+ // DX-3997 — `reminders[]` also relays the dashboard's own listener reminder
583
+ // to a plan-connected session; the reminder's text explains itself.
584
+ "Call any dashboard API route your credential may reach. The answer is the route's JSON body: act on its " +
585
+ "`reminders[]` when present. " +
586
+ // DX-4015 / DX-4525 — narrows SERVER_INSTRUCTIONS' general spec-first rule: a write shown below is
587
+ // copied from its example (3 of 3 canary sessions did, with zero 4xx), so only a write NOT shown, and
588
+ // always the retro, needs a spec call first.
589
+ "Before your first write to a route not shown below, and always before `PUT /api/issues/:id/retro`, " +
590
+ "call `50arms_api_spec`. " +
591
+ // DX-3923 — the shapes agents guessed wrong most often, shown before the
592
+ // first call instead of learned from a 4xx.
593
+ danxbotApiExamplesText(), {
594
+ method: z.enum(DANXBOT_API_METHODS),
595
+ path: z
596
+ .string()
597
+ .min(1)
598
+ .startsWith("/api/")
599
+ .describe('Full path starting with "/api/" — never a route template.'),
600
+ // DX-3465 — a query value may be a scalar, a field TREE (a plain
601
+ // object, e.g. `{"description":true,"comments":{"limit":20}}` — see
602
+ // SERVER_INSTRUCTIONS above and the OpenAPI spec's `fields` schema), an
603
+ // array of scalars (e.g. `ids` on the batch-get route); a list's sort is
604
+ // two scalars, `sort` + `order` (DX-4492).
605
+ // `DashboardHttpClient#buildUrl` JSON-encodes a plain object but
606
+ // comma-joins an array, matching the shapes the server actually parses
607
+ // (a JSON tree vs. a comma-separated list param).
608
+ query: queryValueSchema.optional(),
609
+ // DX-3685 — was `z.unknown()` (JSON Schema `{}`), which gave the model
610
+ // no structural hint that a body must be an OBJECT, so it sometimes
611
+ // sent `body` as a JSON-encoded string; `http-client.ts` then
612
+ // `JSON.stringify`'d that string again, and every dashboard route
613
+ // refused the resulting JSON-string-literal body with `body: must be an
614
+ // object` (every route's body schema is object-only — see
615
+ // `bodyValueSchema`'s own doc in `types.ts`). A string/array/etc body
616
+ // now fails zod validation at this boundary with a clear message
617
+ // instead of reaching the dashboard malformed.
618
+ body: bodyValueSchema.optional(),
619
+ // DX-5724 - the raw-file counterpart of `body`: this server reads the file, so its bytes never pass through your context.
620
+ body_file: z
621
+ .string()
622
+ .min(1)
623
+ .optional()
624
+ .describe("Absolute path of an image file (png, jpg, webp, svg; max 1 MB) sent as the raw body instead of `body`."),
625
+ ...boardField,
626
+ // DX-5033 - sends x-danx-plugin-background (see DanxbotApiArgs.background); an agent setting it would hide its own calls from the pause and idle logic.
627
+ background: z.boolean().optional().describe("Set only by plugin hooks; an agent never sets it."),
628
+ },
629
+ // DX-3997 — the result rides back verbatim, plus the dashboard's own `reminders[]` entries
630
+ // for this plan-connected session (the server decides; `listener-reminder.ts` only relays).
631
+ (calledAs) => async (args) => {
632
+ // DX-4148 — an agent never accepts the Terms, and a terms refusal halts it (see terms-halt.ts).
633
+ const refused = termsAcceptanceCallHalt(args, config.baseUrl);
634
+ if (refused)
635
+ return haltResult(refused);
636
+ const guarded = await guardedCall(() => danxbotApi(client, args), { gateOpen: isAgentGateOpenCall(args.method, args.path) });
637
+ if ("halt" in guarded)
638
+ return haltResult(guarded.halt);
639
+ const result = guarded.result;
640
+ const halt = termsHaltFor(result);
641
+ if (halt)
642
+ return haltResult(halt);
643
+ // DX-6089: `GET /api/plan-sessions/me/role`'s role text lands in a local file; the reply names it.
644
+ const spilled = spillRoleInstruction(result);
645
+ // DX-6556: a manual pickup that connected the session is a connect, whatever tool carried it.
646
+ if (isPickupConnect(spilled))
647
+ return finishConnect(calledAs, spilled, false);
648
+ return boundedJsonResult(await listenerReminder.apply(spilled));
649
+ });
650
+ // ---------------- 50arms_api_list (DX-3465 A5, PLN-11 R-25) ----------------
651
+ renamedTool("50arms_api_list",
652
+ // DX-3528 — the list is filtered by the same permission check every request takes.
653
+ "Start here to find the route for any job: lists the dashboard API routes your credential may call, `{routes: " +
654
+ "[\"METHOD /path/template\", ...]}`, the strings `50arms_api_spec` takes (`POST /api/issues/:id/transition`). A route " +
655
+ "not listed is not callable by you.", {},
656
+ // DX-5765: with no agent held it lists exactly the two proposal routes an agent-less session may call.
657
+ () => async () => {
658
+ const noAgent = agentGate.current() !== null;
659
+ return guardedBoundedJson(async () => {
660
+ const listed = await danxbotApiList(client);
661
+ return noAgent ? onlyAgentProposalRoutes(listed) : listed;
662
+ }, { gateOpen: noAgent });
663
+ });
664
+ // ---------------- 50arms_api_spec (DX-3465, PLN-11 R-25) ----------------
665
+ renamedTool("50arms_api_spec",
666
+ // DX-3527 — the contract is generated from the route table. DX-4015 / DX-4525 — read before the first call to a route you
667
+ // have not called: `50arms_api` is the only channel, and a guessed body is a 4xx or a wrong terminal call.
668
+ "Read this before your first `50arms_api` call to any route you have not called before. The OpenAPI 3.1 contract for the " +
669
+ "routes you name: request and response shape, required permission and " +
670
+ 'every documented error. `endpoints`: `"METHOD /path/template"` strings as `50arms_api_list` reports them ' +
671
+ '(e.g. `"POST /api/issues/:id/transition"`), never a real path with ids. An endpoint the dashboard does ' +
672
+ "not serve, or your credential may not call, is refused and named.", {
673
+ endpoints: z.array(z.string().min(1)).min(1),
674
+ },
675
+ // DX-5765: with no agent held it answers for the two proposal routes only; any other endpoint keeps the no-agent answer.
676
+ () => async (args) => {
677
+ return guardedBoundedJson(() => danxbotApiSpec(client, args), { gateOpen: isAgentGateOpenSpec(args.endpoints) });
678
+ });
679
+ // ---------------- attach_file (DX-3465 A4, checklist item 36140) ----------------
680
+ //
681
+ // The "25 MB" ceiling quoted below tracks the backend route's
682
+ // MAX_DECODED_BYTES (`src/issues/write/attachments.ts`). This package is a
683
+ // separate published artifact and cannot import that constant, so the
684
+ // number is restated here as prose — keep the two in sync if the backend
685
+ // ceiling moves. Opt-in (R-25's "operations about the agent itself" carve-
686
+ // out plus a role-by-role grant, see `tool-defs.ts`'s `MCP_SERVER_OPT_IN_TOOLS`
687
+ // — a role needs local-file-upload only when it deals with files an agent
688
+ // might hold locally, e.g. the Slack recovery agent's CSV path).
689
+ strictTool("attach_file", "Upload a LOCAL file. This server reads and uploads the bytes, so they never pass through your context. " +
690
+ "`target: \"card\"` (default) attaches it to a card; `target: \"inline_image\"` uploads an image for a markdown body and replies `{url, markdown}` (below). The card attachment mirrors to the card's linked Trello card and Slack thread. 25 MB limit (413). " +
691
+ "Card target returns `{issue, attachment_id, url, thumbnail_url, is_primary}`: `url` is permanent, embed it in any " +
692
+ "markdown field as `![description](url)` and it renders inline. Use it when a screenshot or file judges something faster than prose, or instead of pasting a " +
693
+ "large CSV or log into a card field. " +
694
+ "`target: \"inline_image\"` instead uploads a local png/jpeg/gif/webp/avif (25 MB) with NO card and returns `{url, markdown}`: put the " +
695
+ "markdown in any markdown body, notably a `POST /api/feedback` body (`50arms_api`'s `body_file` sends one raw png/jpg/webp/svg of at most 1 MB to a route that takes a file; this tool adds the upload routes, gif/avif, and the 25 MB limit).", {
696
+ target: z.enum(["card", "inline_image"]).optional().describe("`card` (default): attach to a card. `inline_image`: upload an image for a markdown body; takes only `path`."),
697
+ card_id: z.string().min(1).optional().describe("The card id to attach the file to. Required for target `card`; not allowed with `inline_image`."),
698
+ path: z.string().min(1).describe("Absolute local path on the machine this server runs on (`/tmp/a.png` or `C:\\x\\a.png`)."),
699
+ filename: z.string().min(1).optional().describe("Override for the stored filename; default: path's basename."),
700
+ content_type: z.string().min(1).optional().describe("Override for the stored MIME type; default: an extension guess."),
701
+ replaces: z
702
+ .number()
703
+ .int()
704
+ .positive()
705
+ .optional()
706
+ .describe("Id of this card's attachment to replace in place: it keeps its id, name and cover flag. Not with `filename`."),
707
+ primary: z.boolean().optional().describe("Make this attachment the card's single cover (lead image)."),
708
+ ...boardField,
709
+ }, async (args) => guardedJson(() => attachFile(client, args)));
710
+ // ---------------- mini_app_publish (DX-5294, replaces DX-4077's design_publish) ----------------
711
+ //
712
+ // Agent-specific, so a dedicated tool (R-25): it runs the repo's own build command in the agent's OWN checkout, which the dashboard
713
+ // cannot do (no connected repo's source or toolchain), and uploads the result. Opt-in (`tool-defs.ts`'s `MCP_SERVER_OPT_IN_TOOLS`) and
714
+ // granted to no profile here: a default tool would add its schema to every default-all role's first turn, and a role with no worktree or
715
+ // toolchain can only fail it. The package takes no `vite` or `playwright` dependency: the build is the repo's command, and the browser is
716
+ // resolved from the repo at run time (`design-screenshot.ts`).
717
+ strictTool("mini_app_publish", "Build a page with this repo's own mini app and publish it as a revision of one of an existing card's designs, or as a new design on it. " +
718
+ "The design is a gitignored, untracked working folder (`dir`) of text files that the repo's committed build command turns into a page; it may be a whole front-end app, and how to split its states is your call. " +
719
+ "The board's own design guidance lives in the `plan-design` gate's reviewer prompt: `50arms_api` GET " +
720
+ "/api/boards/<board> with fields {controls: true}, the row keyed `quality_gate.plan-design.reviewer_prompt`, field `value`. " +
721
+ "`target.name` is one of the repo's mini app targets (GET /api/repos/<repo> with fields {mini_app_targets: true}): a build command, run in the repo root with no shell " +
722
+ "and `dir` (repo-relative) and a fresh temporary output folder (absolute) as its last two arguments, which it writes `index.html`, one .js and one .css into. Its script must be committed and unchanged from HEAD. " +
723
+ "`target.repo` defaults to the card's board's repo; name it for another repo's mini app. " +
724
+ "`destination.card_id` is the card (not an Epic or Feature). " +
725
+ "A card holds several designs: pass `destination.design_title` with `destination.design_element` (what it decides) to start a NEW design, or `destination.design_no` (from the card's `designs` relation) to add a revision; exactly one. " +
726
+ "`option_groups` are the axes a person picks on; restate them every revision (the latest decides). " +
727
+ "A new design joins its element's open problem; else send `problem_*`. " +
728
+ "Returns `{card_id, design_no, revision_no, dashboard_url, local_html_path, " +
729
+ "screenshot}`: `local_html_path` is the built page; `screenshot` is " +
730
+ "`captured` (1280x800) or `none: <reason>` (the publish still succeeds). A build error comes back as the command's own output, and a " +
731
+ "refused revision (400, 409, 413) verbatim with the `card_id`: fix the " +
732
+ "design and call again. Limits: the frame has no network (use fixture props); on Windows name `node` and a committed script, never npm or npx.", {
733
+ target: z
734
+ .object({
735
+ repo: repoNameArgSchema.optional().describe("The name of the repo whose mini app builds the page; default: the repo of the card's board."),
736
+ name: z.string().min(1).describe("A mini app target's name, as listed in `mini_app_targets` of `GET /api/repos/<repo name>` (`50arms_api`, fields {mini_app_targets: true})."),
737
+ })
738
+ .strict(),
739
+ dir: z
740
+ .string()
741
+ .min(1)
742
+ .describe("The design folder: absolute, or relative to this server's cwd. Inside the repo checkout, gitignored and untracked."),
743
+ note: z
744
+ .string()
745
+ .min(1)
746
+ .describe("The revision note, shown beside the revision on the card: what changed since the last revision, which decision note it answers, what is still open."),
747
+ destination: z
748
+ .object({
749
+ kind: z.literal("card_design").describe("Where the page goes: a design of a card."),
750
+ card_id: z.string().min(1).describe("The card this design goes on: any existing card but an Epic or Feature."),
751
+ design_no: z.number().int().positive().optional().describe("Add a revision to this existing design of the card (its `designs` relation). Not with `design_title`."),
752
+ design_title: z.string().min(1).optional().describe("Start a NEW design on the card with this title, fixed for good. Not with `design_no`."),
753
+ design_element: z.string().min(1).optional().describe("With `design_title` (required there, refused with `design_no`): the one thing this design decides, a few words (\"Indicator pill\", \"Popover layout\"). It joins that element's open design problem, matched ignoring case and extra spaces."),
754
+ })
755
+ .strict(),
756
+ option_groups: z
757
+ .array(z
758
+ .object({
759
+ key: z.string().describe("The stable id a pick is recorded under (non-blank, at most 40 characters, unique among the groups)."),
760
+ label: z.string().describe("The group's name as the viewer shows it (non-blank, at most 30 characters)."),
761
+ options: z.array(z.string()).describe("The group's choices, each one short phrase, word or letter (non-blank, at most 30 characters, unique in the group)."),
762
+ multiple: z.boolean().describe("true when a person may pick several options, false for exactly one."),
763
+ recommended: z.array(z.string()).describe("Your recommended pick(s), each one of `options`: exactly one when `multiple` is false, at least one when true. Every group starts on them."),
764
+ })
765
+ .strict())
766
+ .optional()
767
+ .describe("The option groups a person picks from when choosing this design: one per axis of LOOK they decide on (a layout, a style, a marker), with your recommendation. The viewer draws the groups above the design and the design reads the picks with its repo's kit (in danxbot, `useDesignOption`); never hand-build a toggle row for one, and never make the STATE of the example contents (empty, loading, error, many items) a group: show state inside the page. " +
768
+ "RESTATE the groups on every revision: omitting this declares none and the latest revision's groups decide what is offered. Genuinely different approaches are separate designs, each with its own groups. " +
769
+ "The server is the only validator (at most 12 groups, 30 options each); a broken rule comes back as a 400 naming it."),
770
+ checklist: z
771
+ .array(z.string())
772
+ .min(1)
773
+ .describe("The design checklist: 1 to 30 brief items (each at most 300 characters), one per thing this design adds or changes: name it, where it is and why it is there, so the reviewer ignores everything that already existed. " +
774
+ "Never the option groups, the states or widths to look at, or steps to follow. " +
775
+ "RESTATE it whole every revision (a new revision starts unticked). Ticking never blocks approval. A missing or empty list, or a blank item, is a 400."),
776
+ browser_check: z
777
+ .object({
778
+ status: z.enum(["checked", "not_checked"]).describe("checked: you looked at it in a browser; not_checked: you had none."),
779
+ note: z
780
+ .string()
781
+ .regex(/\S/)
782
+ .max(500)
783
+ .describe("checked: what you checked; not_checked: why you had no browser."),
784
+ })
785
+ .strict()
786
+ .describe("REQUIRED: did you check this revision in a browser? not_checked shows a red warning and tells the operator; never claim checked unseen."),
787
+ problem_statement: z
788
+ .string()
789
+ .min(1)
790
+ .optional()
791
+ .describe("With `design_title`; required when this publish opens a design problem (no open one for `design_element`), refused otherwise: one plain question naming what is being designed, at most 200 characters."),
792
+ problem_summary: z.string().min(1).optional().describe("With `problem_statement`: 1-3 plain sentences."),
793
+ problem_context: z.string().min(1).optional().describe("With `problem_statement`: markdown background for a first-time reader."),
794
+ }, async (args) => guardedJson(() => miniAppPublish(client, args, { baseUrl: config.baseUrl, cwd: process.cwd() })));
795
+ // ---------------- mini_app_restore (DX-6070) ----------------
796
+ //
797
+ // Agent-specific like mini_app_publish: it WRITES local files, which `50arms_api` cannot. A design's source is stored with every revision on the
798
+ // dashboard; this puts one revision's folder back on disk so a designer in a checkout that lacks it can revise instead of starting empty.
799
+ // Opt-in and granted wherever mini_app_publish is.
800
+ strictTool("mini_app_restore", "Write a design revision's stored source back to disk, in this repo checkout, so you can revise a design whose gitignored folder is missing here (another worktree, another machine). " +
801
+ "Every revision keeps its design folder's text files on the dashboard; run this BEFORE editing and publishing the next revision of a design whose `design_dir` is missing or empty in your checkout. " +
802
+ "`revision_no` defaults to the design's latest. Each file goes at its repo-relative path under this server's checkout (the git top level of its cwd). " +
803
+ "Refused before any write, naming each path: a path outside the revision's `design_dir`, or a file that exists locally with DIFFERENT content (unpublished work is never overwritten). " +
804
+ "Files with equal content are `unchanged`; files in the folder that the revision lacks are left alone and listed in `extra_local`. " +
805
+ "Returns `{design_dir, revision_no, built_from_commit, written, unchanged, extra_local}`.", {
806
+ card_id: z.string().min(1).describe("The card the design is on."),
807
+ design_no: z.number().int().positive().describe("The design's number on the card (its `designs` relation)."),
808
+ revision_no: z.number().int().positive().optional().describe("The revision to restore; default: the design's latest."),
809
+ }, async (args) => guardedJson(() => miniAppRestore(client, args, { cwd: process.cwd() })));
810
+ // ---------------- plugin_publish (DX-5237, parent DX-4679) ----------------
811
+ //
812
+ // Agent-specific like mini_app_publish: it builds in the agent's OWN checkout (`npm run build` in a short-lived child process) and uploads the
813
+ // result, which the dashboard cannot do. The build, archive and upload are `@danxbot/plugin-sdk/publish`, shared with the `danx-plugin`
814
+ // CLI; the screenshot is this tool's alone. Opt-in for the same reason as mini_app_publish (`tool-defs.ts`'s `MCP_SERVER_OPT_IN_TOOLS`).
815
+ strictTool("plugin_publish", "Build a card-type plugin project (Vite, `danxPlugin()` from `@danxbot/plugin-sdk/vite`, a lockfile) with its own `npm run build` and publish it as a new revision of the team's plugin " +
816
+ "`plugin` (a new key creates it): uploads `dist/`, the source `.tar.gz` and a screenshot of the plugin's own render (its `CardView` drawn with a sample card on the dashboard's `/plugin-preview` page). " +
817
+ "Returns `{ok: true, status, body: {plugin, plugin_id, revision_id, revision_no, approval_url, screenshot}}`: `approval_url` is Settings > Plugins, where a team admin approves the revision; you cannot. " +
818
+ "`screenshot` is `captured` or `none: <reason>` (the publish still succeeds). A build error throws with the build's output; a refused upload comes back verbatim as `{ok: false, status, body, plugin, step}`.", {
819
+ dir: z.string().min(1).describe("The project directory: absolute, or relative to this server's cwd."),
820
+ plugin: z.string().min(1).describe("The plugin's key: lowercase letters, digits, - and _, at most 64."),
821
+ note: z.string().min(1).describe("What this revision changes, shown to the approver."),
822
+ name: z.string().min(1).optional().describe("Display name for a plugin this call creates; default: the key."),
823
+ }, async (args) => guardedJson(() => pluginPublish(client, args, { baseUrl: config.baseUrl, cwd: process.cwd() })));
824
+ // ---------------- docs_search / docs_read (DX-5582) ----------------
825
+ //
826
+ // One pair of tools over three places a docs root lives (`source`): this checkout's `docs/pages`, another local docs root, or a team repo's hosted
827
+ // docs on the dashboard. They answer with file paths and sizes, never page text (`docs-tools.ts`): `50arms_api` answers into context and cannot write
828
+ // the local file a remote page is read from. Opt-in like `plugin_publish` (`tool-defs.ts`'s `MCP_SERVER_OPT_IN_TOOLS`).
829
+ strictTool("docs_search", "Search docs with the shared @danxbot/docs engine. Returns `{query, results: [{id, type, title, summary, file, bytes, lines, look_in?}]}`, best first, never a page body; " +
830
+ "then `docs_read` the id you pick. `file` is an absolute path for local/dir and the page's path in the repo's docs for remote; `bytes`/`lines` size it for offset/limit reads.", {
831
+ source: docsSourceShape,
832
+ query: z.string().min(1).describe("A question or the words a page would use."),
833
+ limit: z.number().int().min(1).max(MAX_SEARCH_LIMIT).optional().describe(`Most results (default ${DEFAULT_LIMIT}).`),
834
+ }, async (args) => guardedJson(() => docsSearch(client, args, { cwd: process.cwd() })));
835
+ strictTool("docs_read", "Get a docs page as a file on this machine: returns `{id, path, bytes, lines}`, never the text. local/dir: the page's own file. remote: a fresh copy fetched from the dashboard into the temp directory. " +
836
+ "Read `path` with your own file tools (offset/limit, grep). An unknown id is refused with the nearest ids.", {
837
+ source: docsSourceShape,
838
+ id: z.string().min(1).describe("A page id from docs_search, e.g. topic.mini-apps."),
839
+ }, async (args) => guardedJson(() => docsRead(client, args, { cwd: process.cwd() })));
840
+ // ---------------- request_permission (DX-4434, parent DX-4432) ----------------
841
+ strictTool("request_permission", "Ask a person to grant this key ONE MORE permission. Call it when a call was refused `403 forbidden` naming a " +
842
+ "`permission`, or you know you need one more. It returns AT ONCE with `{state, approvalUrl, confirmCode, " +
843
+ "instruction}`: show the person the code and URL, and keep working on anything else. It waits until this session " +
844
+ "ends; if expired, reconnect and ask again. If they approve, a " +
845
+ "later call just succeeds with the same key. The same ask while " +
846
+ "pending returns the pending request. A refusal (unknown permission, already held, rate limited) comes back as " +
847
+ "`{ok:false, status, body}`. If you are blocked without it, raise a problem on the card.", {
848
+ permissions: z
849
+ .array(z.string().min(1))
850
+ .min(1)
851
+ .describe("The permission keys to ask for: the `permission` a `403 forbidden` named."),
852
+ reason: z.string().min(1).describe("Why you need them; shown to the person who approves."),
853
+ }, async (args) => {
854
+ const asked = await permissionRequests.ask(args);
855
+ if ("halt" in asked)
856
+ return haltResult(asked.halt);
857
+ if ("result" in asked) {
858
+ // DX-4148 — a terms refusal halts here as on every dashboard call.
859
+ const halt = termsHaltFor(asked.result);
860
+ return halt ? haltResult(halt) : jsonResult(asked.result);
861
+ }
862
+ return jsonResult(asked.answer);
863
+ });
864
+ // ---------------- plans (DX-2683) ----------------
865
+ // THE BINDING ASYMMETRY IS IN THE SCHEMAS, NOT IN PROSE. `GET /api/plans/:planId`
866
+ // takes a plan id in its path; the plan records, plan cards and
867
+ // architecture-section routes under `/api/plans/mine/...` take NO plan id in
868
+ // any form, so a connected session cannot even express "write to that other
869
+ // plan". `plan_connect` takes a plan id because binding a session to a plan
870
+ // is the one operation that is ABOUT a plan id — and it can only ever bind
871
+ // the caller's own session. `POST /api/plans` also takes no plan id, but for
872
+ // a different reason: it MAKES a plan rather than acting on one, so there is
873
+ // no existing plan for an id to name yet.
874
+ strictTool("plan_connect",
875
+ // DX-4493 — the `naming` block carries the thread title the session must take before it connects.
876
+ // DX-4318 — states only what to do with the reply. What it used to add arrives where it is used: a rejected
877
+ // action's rule in the event line itself (`listen.ts`), the mantra in the reply's own `reminders[]` text.
878
+ // DX-4390 — signed out, this is also how the session asks for access.
879
+ "Connect THIS session to a plan; one call is enough to start. Signed out, it requests access instead: " +
880
+ "the reply is `state: approval_required` or `approval_pending` with an `approvalUrl` and `confirmCode` " +
881
+ "(show the code, open the URL, then call again; it waits until this session ends; a call re-asks an expired one). " +
882
+ "Signed in, the reply carries `owedComments` (person comments no agent has answered yet: read first, answer each, ack then reply) and `missedEvents` (operator " +
883
+ "events no session had been told about: read next), " +
884
+ "`browserInstruction` (do it now), `briefing` (the plan's goals, rules, caveats, architecture " +
885
+ "titles and a first page of open cards) and `listenerHealth` (when its `state` is not `healthy`, do its " +
886
+ "`nextStep`). A session is on at most one plan: connecting elsewhere moves it (`movedFrom` names the " +
887
+ "plan it left). A plan holds one live session: when another is on it the connect is refused " +
888
+ "`plan_has_live_session`, naming that session; do not retry: tell the person which session holds " +
889
+ "it and connect elsewhere. A session whose plan another took while it was quiet is refused its next `/api/plans/mine/...` call: connect elsewhere or ask the person. Afterwards `50arms_api` calls to `/api/plans/mine/...` act on this plan, no plan id. " +
890
+ "Connecting signs you in as a team agent: `agent`/`agentInstruction` say who you are. " +
891
+ "No agent to sign in as: `agent_needed` (none exists for you; propose one) or `agent_limit_reached` (the member's or team's cap on sessions signed in as an agent is full; the person ends another session or raises the cap), nothing connects: follow its `instruction`, " +
892
+ "no other dashboard work until `plan_connect` holds an agent. A `naming` block (status `needed`) gives the exact " +
893
+ "`title`, `{Name}: PLAN-NNN {short title}`; rename this thread to it (`set_session_title`, or ask for `/rename`), then connect again with that `title`. " +
894
+ // DX-4608 / DX-5549: a title in the format naming another agent than the held one is refused.
895
+ "A `title` in that format naming another agent than yours is refused `session_title_not_agent_name` with the title to use.", {
896
+ plan_id: z
897
+ .number()
898
+ .int()
899
+ .positive()
900
+ .optional()
901
+ .describe("The plan's number within your team (the `n` of `PLAN-n`; a plan's `ref` in `50arms_api` `GET /api/plans`). Omit only to sign in (it still takes your team agent)."),
902
+ title: z
903
+ .string()
904
+ .min(1)
905
+ .optional()
906
+ .describe(
907
+ // DX-4608: stored as sent; a wrong one does not refuse the connect, the reply's `naming` says what to rename it to.
908
+ 'The `naming` block\'s exact `{Name}: PLAN-NNN {short title}`, after renaming this thread. Omit on the first connect.'),
909
+ assignment_id: z.number().int().positive().optional().describe("The assignment this connect answers, when a person's assignment message names one. Refused `assignment_ended` once it is over: then do nothing more about it."),
910
+ disconnect: z.boolean().optional().describe("true = leave `plan_id` (your current plan); no `title`."),
911
+ },
912
+ // DX-2862 — the connect is also the moment the session's connection record is written for `restart_notice` and the listener reminder: of the
913
+ // dashboard MCP servers a session may have configured, the one that connected the plan is the one whose credential they
914
+ // must resolve (the relay needs none of this: it lives in each server, with that server's own credential).
915
+ async (args) => {
916
+ // DX-4390 — a leave names the plan it leaves, and needs a key like any other call.
917
+ if (args.disconnect === true && args.plan_id === undefined) {
918
+ return haltResult("plan_connect: `disconnect: true` needs `plan_id`, the plan this session is currently on");
919
+ }
920
+ // DX-4390 — no key yet: ask for one (or wait for the approval already asked for).
921
+ const ready = await access.ensureAccess(args.title);
922
+ if (!ready.signedIn)
923
+ return accessReply(ready);
924
+ if (args.plan_id === undefined) {
925
+ // DX-4523 - signed in (possibly just now): show the tools this key's permissions allow.
926
+ await syncToolsAfterSignIn();
927
+ // DX-5549: signing in takes a team agent. The dashboard's answer (the agent and the instruction to use its name, or why there is none) is the reply.
928
+ const claimed = await claimSessionAgent(client);
929
+ const claimHalt = access.refusalHalt(claimed);
930
+ if (claimHalt !== null)
931
+ return haltResult(claimHalt);
932
+ if (!claimed.ok)
933
+ return jsonResult(claimed);
934
+ agentGate.record(claimed.body);
935
+ if (agentGate.current() === null)
936
+ void reportCheckoutDocsRoot();
937
+ return jsonResult({
938
+ ...claimed.body,
939
+ ...(agentGate.current() === null ? { instruction: "This session is signed in. Call `plan_connect` with `plan_id` to connect it to a plan." } : {}),
940
+ });
941
+ }
942
+ // DX-6089: the role text lands in a local file; the reply names it (`role-instruction-file.ts`).
943
+ const result = spillRoleInstruction(await planConnect(client, { ...args, plan_id: args.plan_id }));
944
+ // DX-4418 — a person revoked this key: the stop halt, and nothing asks again.
945
+ const revokedHalt = access.refusalHalt(result);
946
+ if (revokedHalt !== null)
947
+ return haltResult(revokedHalt);
948
+ // DX-4390 — the dashboard no longer accepts the stored key: drop it and ask again in the same call.
949
+ if (result.status === 401 && access.dropKey(result)) {
950
+ return accessReply(await access.ensureAccess(args.title), "the dashboard no longer accepted this session's key");
951
+ }
952
+ return finishConnect("plan_connect", result, args.disconnect === true);
953
+ });
954
+ // DX-6735: started by each connect that holds an agent, and never awaited by it.
955
+ const reportCheckoutDocsRoot = createCheckoutDocsRootReporter(() => client, process.cwd());
956
+ /**
957
+ * What `plan_connect` and `card_connect` do with a connect answer once the key is known good (DX-6157 - one path, so a wiring test through either tool covers it), and `50arms_api` with a manual pickup that connected (DX-6556).
958
+ * DX-5549: the dashboard connects only a session that holds a team agent. With none it answers why (`agent_needed` / `agent_limit_reached`) and binds nothing: that answer is handed to the agent
959
+ * unchanged and every other dashboard tool stays closed until a later connect finds an agent. The tools are shown and the listener reminder reset BEFORE that block is handed back (DX-4523, DX-3997),
960
+ * so a session whose key was just approved can run `plan_events_wait` and hear the person's Create. A leave (DX-2985) drops the local connection record; a connect writes it.
961
+ */
962
+ async function finishConnect(tool, result, disconnect) {
963
+ await syncToolsAfterSignIn();
964
+ listenerReminder.invalidate();
965
+ if (!disconnect) {
966
+ agentGate.record(result.body);
967
+ if (agentGate.current() !== null)
968
+ return jsonResult(result.body);
969
+ void reportCheckoutDocsRoot();
970
+ }
971
+ if (disconnect) {
972
+ removeSessionConnectionAfterLeave(result, config.session?.id);
973
+ return jsonResult(result);
974
+ }
975
+ const connection = access.connection();
976
+ if (connection === null)
977
+ throw new Error(`${tool}: no credential after a successful connect`);
978
+ return jsonResult({
979
+ ...result,
980
+ dashboard: connectedDashboard(),
981
+ ...recordSessionConnectionAfterConnect(result, {
982
+ sessionId: config.session?.id,
983
+ projectKey: projectKeyOf(process.cwd()),
984
+ dashboardUrl: connection.dashboardUrl,
985
+ source: connection.source,
986
+ fingerprint: connection.fingerprint,
987
+ }),
988
+ });
989
+ }
990
+ // ---------------- card_connect (DX-5553) ----------------
991
+ strictTool("card_connect",
992
+ // DX-5553 - the stand-alone counterpart of `plan_connect`: one card on no plan instead of a plan.
993
+ "Hold ONE card that is on no plan instead of a plan: for a one-off, a quick fix or an epic that is not a plan. You then hear that card's and its descendants' events, " +
994
+ "and no other card's. The reply carries `owedComments` (every person comment on it no agent has answered yet, oldest first: read them first and answer each, ack then reply), `missedEvents` (operator events no session had been told about on it: read them next), `naming` and `agent`/`agentInstruction`. " +
995
+ "Refused `card_on_plan` when the card or any card above it is on a plan (connect to that plan with `plan_connect`), `card_held` when another live session holds it. " +
996
+ "A session holding no team agent is first signed in as one, as `plan_connect` does: with none free the answer is `agent_needed` (propose one) or `agent_limit_reached`, and nothing is held. " +
997
+ "Holding it assigns the card to you, like a manual pickup; a card " +
998
+ "with children is held as a whole and its children are picked up normally. A session holds a plan or a card, never both: connecting to either lets go of the other. " +
999
+ "A `naming` block (status `needed`) gives the exact `title`, `{Name}: {CARD-ID} {card title}`; rename this thread to it (`set_session_title`, or ask for `/rename`), then connect " +
1000
+ "again with that `title`. A card later added to a plan moves you off it: you are told on your next plan call.", {
1001
+ card_id: z.string().min(1).describe("The id of the card to hold, as the board shows it."),
1002
+ title: z
1003
+ .string()
1004
+ .min(1)
1005
+ .optional()
1006
+ .describe("The `naming` block's exact `{Name}: {CARD-ID} {card title}`, after renaming this thread. Omit on the first connect."),
1007
+ assignment_id: z.number().int().positive().optional().describe("The assignment this connect answers, when a person's assignment message names one. Refused `assignment_ended` once it is over: then do nothing more about it."),
1008
+ disconnect: z.boolean().optional().describe("true = let go of `card_id` (the card you hold); no `title`."),
1009
+ }, async (args) => {
1010
+ const ready = await access.ensureAccess(args.title);
1011
+ if (!ready.signedIn)
1012
+ return accessReply(ready);
1013
+ const result = spillRoleInstruction(await cardConnect(client, args));
1014
+ const revokedHalt = access.refusalHalt(result);
1015
+ if (revokedHalt !== null)
1016
+ return haltResult(revokedHalt);
1017
+ if (result.status === 401 && access.dropKey(result)) {
1018
+ return accessReply(await access.ensureAccess(args.title), "the dashboard no longer accepted this session's key");
1019
+ }
1020
+ return finishConnect("card_connect", result, args.disconnect === true);
1021
+ });
1022
+ // ---------------- plan_events_wait (DX-4233) ----------------
1023
+ strictTool("plan_events_wait",
1024
+ // DX-4882 - `plan_id` is null for a signed-in session on no plan: its stream still carries its account's pacing and resume messages.
1025
+ // DX-4233 - for the 50arms plugin's relay loop. DX-4667 - and for a background waiter sub-agent of an orchestrator, which
1026
+ // loops on it until events exist and returns them (and the cursor) to the orchestrator it wakes. It answers `events` (each
1027
+ // `{cursor, text, urgent, acks}`; empty after `timeout_ms`), the cursor to send being the last delivered event's, or `stopped` with the remedy
1028
+ // to tell the session. General-audience text (DX-4550): nothing here names our own setup.
1029
+ "Waits for events for this session: the events of the plan it is connected to, and the messages about its usage. For a relay " +
1030
+ "or a background waiter that calls it in a loop and hands every event to the session; call it for nothing else. " +
1031
+ "`plan_id` is the plan the session is on, or null when it is on no plan (then only the usage messages arrive; a different " +
1032
+ "plan than the last call's, or a move to or from null, starts clean). `cursor` is the `cursor` of the last event delivered, as answered (null " +
1033
+ "when none). Answers `{events: [{cursor, text, urgent, acks}]}`, with `events` empty once `timeout_ms` passes, or `{stopped: " +
1034
+ "{reason, detail, fix}}` when the stream ended: tell the session the `fix`. An event stays until a later call's " +
1035
+ "`cursor` passes it. `urgent` is true for an event the session must hear at once (a critical usage limit, a revoked " +
1036
+ "key) and false for every other: deliver an urgent one to the session even while it is working, and any other once its " +
1037
+ "current turn ends. `acks`: events to acknowledge as `text` says, else re-sent. " +
1038
+ "One wait per session: a newer call ends the older. " +
1039
+ "`transcript_path` is the session's transcript file, for its usage and title.", {
1040
+ plan_id: z.number().int().positive().nullable().describe("Number of the plan this session is connected to (the `n` of `PLAN-n`, within your team), or null when it is on no plan."),
1041
+ cursor: z
1042
+ .string()
1043
+ .nullable()
1044
+ .describe("The `cursor` of the last event delivered to the session, exactly as answered, or null when there is none."),
1045
+ timeout_ms: z
1046
+ .number()
1047
+ .int()
1048
+ .min(WAIT_MIN_MS)
1049
+ .max(WAIT_MAX_MS)
1050
+ .describe(`How long to wait for an event, ${WAIT_MIN_MS} to ${WAIT_MAX_MS} ms.`),
1051
+ transcript_path: z
1052
+ .string()
1053
+ .min(1)
1054
+ .optional()
1055
+ .describe("Absolute path of this session's transcript (.jsonl file)."),
1056
+ },
1057
+ // The relay is bound to the session the server booted for (the id every other tool on this server also sends); a server
1058
+ // with none holds no session key, so the surface never shows this tool and the relay would answer `credential_unavailable`.
1059
+ async (args, extra) => jsonResult(await servePlanEventsWait(planEventRelay, args, extra.signal)));
1060
+ // ---------------- restart_notice (DX-4632) ----------------
1061
+ strictTool("restart_notice",
1062
+ // DX-4632 - for the 50arms plugin's session-start hook, not for a model to call. General-audience text (DX-4550).
1063
+ "Which plan the session this one replaced was working, and what waits there. For the 50arms plugin's session start: " +
1064
+ "a model never calls it. Works before sign-in. Answers " +
1065
+ "`{notice: text}` (tell the session), `{notice: null}` (nothing to say) or `{stopped: {reason, detail, fix}}`.", RESTART_NOTICE_ARGS, async (args) => jsonResult(await lookupRestartNotice({
1066
+ predecessorId: args.predecessor_id,
1067
+ transcriptPath: args.transcript_path,
1068
+ sessionId: config.session?.id,
1069
+ cwd: process.cwd(),
1070
+ env: process.env,
1071
+ fetch: (input, init) => fetch(input, init),
1072
+ })));
1073
+ // ---------------- main ----------------
1074
+ async function main() {
1075
+ boot();
1076
+ await syncToolsAfterSignIn();
1077
+ stopWhenSessionEnds({
1078
+ server: server.server,
1079
+ stdin: process.stdin,
1080
+ signals: process,
1081
+ exit: (code) => process.exit(code),
1082
+ stop: makeSessionStop(permissionWatch, planEventRelay),
1083
+ });
1084
+ const transport = new StdioServerTransport();
1085
+ await server.connect(transport);
1086
+ console.error(`platform-mcp running on stdio (dashboard=${config.baseUrl}, board=${config.board ?? "none, each call names its own"})`);
1087
+ }
1088
+ // Boot the stdio server ONLY when run as the entrypoint (the published bin).
1089
+ // Importing this module (the tool-defs generator + its drift test) registers
1090
+ // the tools on `server` without reading env or attaching stdin — so the
1091
+ // schemas can be introspected without spawning a dispatch. The check is
1092
+ // symlink-aware (DX-1647) so it holds under the symlinked `npx` bin the worker
1093
+ // spawns, not just a direct `node dist/index.js`.
1094
+ //
1095
+ // The only subcommand is `subagents-live`, which the 50arms plugin's plan pane runs (DX-4508); it does not boot the MCP
1096
+ // server; it reads the declared model prices once at start with the session key (DX-5611). A session's plan events are served in this process by `plan_events_wait` (DX-4233).
1097
+ //
1098
+ // Any OTHER argument is refused rather than ignored, so a typo cannot
1099
+ // silently start an MCP server on stdio where a subcommand was meant to run.
1100
+ if (isEntrypointModule(import.meta.url, process.argv[1])) {
1101
+ const [subcommand, ...rest] = process.argv.slice(2);
1102
+ if (MACHINE_DAEMON_SUBCOMMANDS.includes(subcommand ?? "")) {
1103
+ // DX-5902 - install, status or remove the machine daemon on this machine. The daemon itself is the separate bundle dist/machine-daemon.js.
1104
+ runMachineDaemonCommand(subcommand, rest).then((code) => process.exit(code), (err) => {
1105
+ console.error(`[platform-mcp] ${subcommand} fatal: ${err.message}`);
1106
+ process.exit(1);
1107
+ });
1108
+ }
1109
+ else if (subcommand === SUBAGENTS_LIVE_SUBCOMMAND) {
1110
+ // DX-4508 — the plugin pane's local live sub-agent feed: stdout is its JSON Lines channel, so a fatal goes to
1111
+ // stderr only and exits 1; a refused path exits 2 from the command itself.
1112
+ runSubagentsLiveCommand(rest).then((code) => process.exit(code), (err) => {
1113
+ console.error(`[platform-mcp] ${SUBAGENTS_LIVE_SUBCOMMAND} fatal: ${err.message}`);
1114
+ process.exit(1);
1115
+ });
1116
+ }
1117
+ else if (subcommand !== undefined) {
1118
+ console.error(`[platform-mcp] unknown subcommand "${subcommand}" (the only one is ` +
1119
+ `"${SUBAGENTS_LIVE_SUBCOMMAND}")`);
1120
+ process.exit(2);
1121
+ }
1122
+ else {
1123
+ main().catch((err) => {
1124
+ console.error(`[platform-mcp] fatal: ${err.message}`);
1125
+ process.exit(1);
1126
+ });
1127
+ }
1128
+ }