@50arms/platform-mcp 0.1.440 → 0.1.442

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,7 @@ BOARD-ONLY (DX-1171): board is the first-level concept; repo is DERIVED from boa
18
18
  ## Signing in (DX-4390): request and approve
19
19
 
20
20
  An operator session has no stored credential. Signed out, `plan_connect` (optionally with `title`, the session's own Claude title) posts `POST /api/access-requests` and answers at once `{state: "approval_required", approvalUrl, confirmCode, expiresAt, instruction}`: show the user the code and open the URL; they approve in the dashboard. The server polls `POST /api/access-requests/claim` in the background, so the key is stored when the approval lands; the next `plan_connect` waits up to about 45 seconds for it, then connects, or answers `approval_pending`. One tool call never blocks for minutes. `denied` is reported once; a request that lapsed (the dashboard deletes it within about a minute of expiry, so a claim answers 404) starts a fresh one on the next call.
21
+ - **A launch token (DX-6801, DEC-522).** `plan_connect` and `card_connect` take an optional `launch_token`: the one-use token the session that started this one passed in its prompt. A signed-out session given one redeems it once per process through the public `POST /api/access-requests/claim` with `{secret: <token>, labelId: <this session's id>}` (`labelId` is sent only on a redeem); `approved` stores the key like an approval and answers signed in, with no access request. Any other answer (`claimed`, `expired`, 404, 409 `launch_grant_refused` with its `reason`, 429) spends the token and falls through to the usual person's request, the answer carrying a `note` that names why the token was not used. A signed-in session ignores the token.
21
22
 
22
23
  - **Storage.** `~/.config/danxbot/session-keys/<sha12(dashboard url)>/<session id>.json`, mode 0600, written write-then-rename, pruned after 7 days. One file per dashboard and per session; this server reads it at boot, and `restart_notice` reads the same file through the `session-key` credential source and proves the fingerprint. The key lapses on the dashboard after 24 hours without use (every authenticated call, the relay's heartbeat included, renews it), and is revocable from the account page.
23
24
  - **A 401 on the stored key** deletes the file and returns the sign-in halt (in `plan_connect`: a new request in the same call). That is a key that lapsed. A key a PERSON revoked is answered `401 key_revoked` (DX-4418): the key file is deleted and EVERY dashboard tool, `plan_connect` included, returns one stop halt (who revoked it and when; commit work in progress and end; a dispatched agent makes no further call), for the rest of the process. No access request is made. The worker finds the revocation itself and ends the dispatch as revoked (every dashboard route, the dispatch-complete one included, refuses a revoked key). `plan_events_wait` answers `stopped` with the reason `key_revoked` the same way. **A 404 on create** means the dashboard predates request-and-approve and says so.
@@ -74,9 +74,14 @@ export async function createAccessRequest(fetchImpl, dashboardUrl, input) {
74
74
  return { kind: "rate_limited", retryAfterSeconds: retryAfterSeconds(headers) };
75
75
  return { kind: "refused", status, body: json };
76
76
  }
77
- export async function claimAccessRequest(fetchImpl, dashboardUrl, secret) {
77
+ /**
78
+ * DX-6801: `labelId` rides along ONLY when redeeming a launch token (the secret is then the token a parent session passed in this
79
+ * session's prompt, and `labelId` this session's id, which the dashboard stores the key under); a normal poll sends `{secret}` alone.
80
+ * A launch redeem the dashboard refuses answers 409 `launch_grant_refused` with a `reason`: that is a `refused` answer, body as sent.
81
+ */
82
+ export async function claimAccessRequest(fetchImpl, dashboardUrl, secret, labelId) {
78
83
  const url = `${baseOf(dashboardUrl)}/api/access-requests/claim`;
79
- const { status, headers, json } = await postPublic(fetchImpl, url, { secret });
84
+ const { status, headers, json } = await postPublic(fetchImpl, url, labelId === undefined ? { secret } : { secret, labelId });
80
85
  if (status === 404)
81
86
  return { kind: "not_found" };
82
87
  if (status === 429)
@@ -19,6 +19,15 @@ export function githubSlugOf(remote) {
19
19
  return match === null ? null : `${match[2]}/${match[3]}`;
20
20
  }
21
21
  const runGit = async (cwd, args) => (await execFileAsync("git", [...args], { cwd, windowsHide: true })).stdout.trim();
22
+ /** DX-6888: `owner/name` of the GitHub repo `cwd`'s checkout was cloned from, or null for a folder with no checkout, no `origin`, or a remote that is not on GitHub. Sent with the session's sign-in request so the guide and the approver can name the repo. */
23
+ export async function readCheckoutRepo(cwd, git = runGit) {
24
+ try {
25
+ return githubSlugOf(await git(cwd, ["remote", "get-url", "origin"]));
26
+ }
27
+ catch {
28
+ return null; // `git` failed: no checkout or no `origin`, which is the same answer as a remote not on GitHub
29
+ }
30
+ }
22
31
  /** What `cwd`'s checkout says, or null for a folder that is not a checkout of a GitHub repo. */
23
32
  export async function readCheckoutDocsRoot(cwd, git = runGit, exists = existsSync) {
24
33
  try {
package/dist/index.js CHANGED
@@ -95,7 +95,7 @@ import { SessionAccess } from "./session-access.js";
95
95
  import { PermissionRequests } from "./permission-request.js";
96
96
  import { makeGuardedCalls } from "./guarded-calls.js";
97
97
  import { readOwnVersion } from "./version-floor.js";
98
- import { createCheckoutDocsRootReporter } from "./checkout-docs-root.js";
98
+ import { createCheckoutDocsRootReporter, readCheckoutRepo } from "./checkout-docs-root.js";
99
99
  import { isPickupConnect, projectKeyOf, recordSessionConnectionAfterConnect, removeSessionConnectionAfterLeave } from "./session-connection.js";
100
100
  import { createListenerReminder, isPlanConnectedOnThisMachine, } from "./listener-reminder.js";
101
101
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -219,6 +219,7 @@ function boot() {
219
219
  sessionId: session?.id,
220
220
  clientVersion: readOwnVersion(),
221
221
  team: readTeamRef(),
222
+ readRepo: () => readCheckoutRepo(process.cwd()),
222
223
  setToken: (token) => dashboardClient.setToken(token),
223
224
  initial: resolveInitialCredential(dashboardUrl, session?.id),
224
225
  log: (line) => console.error(line),
@@ -907,6 +908,11 @@ strictTool("plan_connect",
907
908
  'The `naming` block\'s exact `{Name}: PLAN-NNN {short title}`, after renaming this thread. Omit on the first connect.'),
908
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."),
909
910
  disconnect: z.boolean().optional().describe("true = leave `plan_id` (your current plan); no `title`."),
911
+ launch_token: z
912
+ .string()
913
+ .min(1)
914
+ .optional()
915
+ .describe("The one-use token the session that started this one passed in its prompt. With it a signed-out session signs in with no person approving it; a token that cannot be used says why and the session asks a person as usual. Omit it otherwise."),
910
916
  person_sign_in: z
911
917
  .boolean()
912
918
  .optional()
@@ -926,7 +932,7 @@ async (args) => {
926
932
  return haltResult("plan_connect: `disconnect: true` needs `plan_id`, the plan this session is currently on");
927
933
  }
928
934
  // DX-4390 — no key yet: ask for one (or wait for the approval already asked for).
929
- const ready = await access.ensureAccess(args.title);
935
+ const ready = await access.ensureAccess(args.title, args.launch_token);
930
936
  if (!ready.signedIn)
931
937
  return accessReply(ready);
932
938
  if (args.plan_id === undefined) {
@@ -1014,8 +1020,13 @@ strictTool("card_connect",
1014
1020
  .describe("The `naming` block's exact `{Name}: {CARD-ID} {card title}`, after renaming this thread. Omit on the first connect."),
1015
1021
  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."),
1016
1022
  disconnect: z.boolean().optional().describe("true = let go of `card_id` (the card you hold); no `title`."),
1023
+ launch_token: z
1024
+ .string()
1025
+ .min(1)
1026
+ .optional()
1027
+ .describe("The one-use token the session that started this one passed in its prompt. With it a signed-out session signs in with no person approving it; a token that cannot be used says why and the session asks a person as usual. Omit it otherwise."),
1017
1028
  }, async (args) => {
1018
- const ready = await access.ensureAccess(args.title);
1029
+ const ready = await access.ensureAccess(args.title, args.launch_token);
1019
1030
  if (!ready.signedIn)
1020
1031
  return accessReply(ready);
1021
1032
  const result = spillRoleInstruction(await cardConnect(client, args));
@@ -250,7 +250,7 @@ function resolveToolkitVersion(stamped, readManifest) {
250
250
  return stamped ?? readManifest();
251
251
  }
252
252
  var readOwnManifest = () => JSON.parse(readFileSync2(new URL("../../package.json", import.meta.url), "utf8")).version;
253
- var TOOLKIT_VERSION = resolveToolkitVersion(false ? void 0 : "0.1.440", readOwnManifest);
253
+ var TOOLKIT_VERSION = resolveToolkitVersion(false ? void 0 : "0.1.442", readOwnManifest);
254
254
  function envelope(now) {
255
255
  return { contract_version: CONTRACT_VERSION, toolkit_version: TOOLKIT_VERSION, profile: PROFILE, measured_at: now.toISOString() };
256
256
  }
@@ -56,6 +56,34 @@ const unknownTeamError = (team) => `the dashboard has no team "${team}" (DANXBOT
56
56
  const NO_SESSION_ID_ERROR = "this server has no CLAUDE_CODE_SESSION_ID, so it cannot request a session key";
57
57
  const CLAIMED_LOST_ERROR = "the dashboard says this request was already claimed, but this process never received the key; " +
58
58
  "start a new request";
59
+ /** DX-6801: why a launch token signed nobody in; the session then asks a person as any signed-out session does. */
60
+ const launchNote = (why) => `The launch token was not used: ${why}. This session asks a person to approve it instead: show the user the confirm code and URL.`;
61
+ function launchRefusalWhy(claim) {
62
+ switch (claim.kind) {
63
+ case "claimed":
64
+ return "it was already redeemed (a launch token works once)";
65
+ case "expired":
66
+ return "it expired or was withdrawn";
67
+ case "not_found":
68
+ return "the dashboard does not know it";
69
+ case "denied":
70
+ return "the dashboard answered denied";
71
+ case "pending":
72
+ return "the dashboard answered pending, which a launch token never is";
73
+ case "rate_limited":
74
+ return `the dashboard rate limited the redeem (retry after ${claim.retryAfterSeconds}s) and a token is tried once per process`;
75
+ case "refused": {
76
+ const body = claim.body;
77
+ if (claim.status === 409 && body?.error === "launch_grant_refused") {
78
+ const missing = Array.isArray(body.missingPermissions) && body.missingPermissions.length > 0
79
+ ? ` (missing permissions: ${body.missingPermissions.join(", ")})`
80
+ : "";
81
+ return `the dashboard refused the launch grant: ${String(body.reason)}${missing}`;
82
+ }
83
+ return `the dashboard refused the redeem (${claim.status}): ${JSON.stringify(claim.body)}`;
84
+ }
85
+ }
86
+ }
59
87
  const EXPIRED_NOTE = "The previous request expired before it was approved: the dashboard stopped hearing from this session. This is a new " +
60
88
  "request: show the user its new confirm code and URL.";
61
89
  export const defaultSleep = (ms) => new Promise((resolve) => {
@@ -76,6 +104,8 @@ export class SessionAccess {
76
104
  // DX-4390: two concurrent signed-out plan_connect calls must not each post a request.
77
105
  ensureQueue = Promise.resolve();
78
106
  lastKeyFileWrite = 0;
107
+ // DX-6801: a launch token is redeemed at most once per process, whatever the answer.
108
+ launchTokenTried = false;
79
109
  // DX-5882: why this server holds no credential (null while it holds one); logged with every "Not signed in" halt.
80
110
  signedOutCause = null;
81
111
  fetchImpl;
@@ -203,13 +233,17 @@ export class SessionAccess {
203
233
  * Make sure this session holds a key. Signed in: `{signedIn: true}`. Otherwise the answer
204
234
  * `plan_connect` returns: the request just made, the one still pending (after waiting up to
205
235
  * `awaitMs` for it), or why there is none. Calls run one at a time.
236
+ *
237
+ * DX-6801 / DEC-522: a signed-out session given the `launchToken` the session that started it passed in its prompt redeems it
238
+ * (once per process) through the public claim route, with no person approving. Any answer but `approved` spends the token and
239
+ * falls through to the person's request, the answer carrying a `note` that says why the token was not used.
206
240
  */
207
- ensureAccess(title) {
208
- const run = this.ensureQueue.then(() => this.ensureAccessNow(title));
241
+ ensureAccess(title, launchToken) {
242
+ const run = this.ensureQueue.then(() => this.ensureAccessNow(title, launchToken));
209
243
  this.ensureQueue = run.catch(() => undefined);
210
244
  return run;
211
245
  }
212
- async ensureAccessNow(title) {
246
+ async ensureAccessNow(title, launchToken) {
213
247
  // DX-4418: a person revoked this session's access; it asks again only after `personSignIn` (DX-6894).
214
248
  const revokedHalt = this.revokedHalt();
215
249
  if (revokedHalt !== null)
@@ -221,6 +255,21 @@ export class SessionAccess {
221
255
  return notReady({ state: "no_session_id", error: NO_SESSION_ID_ERROR }, true);
222
256
  }
223
257
  let note;
258
+ // DX-6801: only while no request of this process is open (a person's approval already in flight stays the way in).
259
+ if (launchToken !== undefined && !this.launchTokenTried && this.pending === null) {
260
+ this.launchTokenTried = true;
261
+ const claim = await claimAccessRequest(this.fetchImpl, this.deps.dashboardUrl, launchToken, sessionId);
262
+ if (claim.kind === "approved") {
263
+ const stored = this.storeKey(claim.apiKey);
264
+ if (stored.kind === "approved")
265
+ return { signedIn: true };
266
+ if (stored.kind === "failed")
267
+ return notReady({ state: "request_failed", error: stored.error }, true);
268
+ }
269
+ else {
270
+ note = launchNote(launchRefusalWhy(claim));
271
+ }
272
+ }
224
273
  if (this.pending !== null) {
225
274
  const pending = this.pending;
226
275
  const outcome = await Promise.race([pending.settled, this.sleep(this.awaitMs).then(() => null)]);
@@ -238,15 +287,22 @@ export class SessionAccess {
238
287
  note = EXPIRED_NOTE;
239
288
  }
240
289
  }
241
- return this.createRequest(sessionId, title, note);
290
+ const created = await this.createRequest(sessionId, title, note);
291
+ // DX-6801: a refused or failed request carries the launch note too, so the reason is never lost.
292
+ if (note !== undefined && !created.signedIn && !("note" in created.answer)) {
293
+ return { ...created, answer: { ...created.answer, note } };
294
+ }
295
+ return created;
242
296
  }
243
297
  async createRequest(sessionId, title, note) {
298
+ const repo = this.deps.readRepo === undefined ? null : await this.deps.readRepo();
244
299
  const created = await createAccessRequest(this.fetchImpl, this.deps.dashboardUrl, {
245
300
  kind: "session",
246
301
  labelId: sessionId,
247
302
  title: title ?? sessionId.slice(0, TITLE_FALLBACK_LENGTH),
248
303
  machine: this.deps.machine ?? hostname(),
249
304
  clientVersion: this.deps.clientVersion,
305
+ ...(repo === null ? {} : { repo }),
250
306
  ...(this.deps.team === undefined ? {} : { team: this.deps.team }),
251
307
  });
252
308
  switch (created.kind) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@50arms/platform-mcp",
3
- "version": "0.1.440",
3
+ "version": "0.1.442",
4
4
  "description": "Stdio MCP server fronting the 50 Arms dashboard API for agents (operator sessions and dispatched workers alike): the generic 50arms_api/50arms_api_list/50arms_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (plan_connect, request_permission, attach_file, and 50arms_slack_reply) (DX-3465, PLN-11 R-25).",
5
5
  "license": "MIT",
6
6
  "type": "module",