@50arms/platform-mcp 0.1.439 → 0.1.441

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)
package/dist/index.js CHANGED
@@ -881,14 +881,13 @@ strictTool("plan_connect",
881
881
  "(show the code, open the URL, then call again; it waits until this session ends; a call re-asks an expired one). " +
882
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
883
  "events no session had been told about: read next), " +
884
- "`browserInstruction` (do it now), `briefing` (the plan's goals, rules, caveats, architecture " +
884
+ "`browserInstruction` (do it now), `briefing` (goals, rules, caveats, architecture " +
885
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`, " +
886
+ "`nextStep`), and `docsLandJob` (on a docs land assignment: the conflict job's text, or `unavailable` with why). 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: another on it refuses the connect (`plan_has_live_session`, " +
888
+ "naming that session); do not retry: tell the person 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. " +
889
+ "Connecting signs you in as a team agent (`agent`, `agentInstruction`). " +
890
+ "No agent to sign in as: `agent_needed` (none exists for you; propose one) or `agent_limit_reached` (the cap on agent sessions is full; the person ends another or raises it), nothing connects: follow its `instruction`, " +
892
891
  "no other dashboard work until `plan_connect` holds an agent. A `naming` block (status `needed`) gives the exact " +
893
892
  "`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
893
  // DX-4608 / DX-5549: a title in the format naming another agent than the held one is refused.
@@ -908,6 +907,11 @@ strictTool("plan_connect",
908
907
  'The `naming` block\'s exact `{Name}: PLAN-NNN {short title}`, after renaming this thread. Omit on the first connect.'),
909
908
  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
909
  disconnect: z.boolean().optional().describe("true = leave `plan_id` (your current plan); no `title`."),
910
+ launch_token: z
911
+ .string()
912
+ .min(1)
913
+ .optional()
914
+ .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."),
911
915
  person_sign_in: z
912
916
  .boolean()
913
917
  .optional()
@@ -927,7 +931,7 @@ async (args) => {
927
931
  return haltResult("plan_connect: `disconnect: true` needs `plan_id`, the plan this session is currently on");
928
932
  }
929
933
  // DX-4390 — no key yet: ask for one (or wait for the approval already asked for).
930
- const ready = await access.ensureAccess(args.title);
934
+ const ready = await access.ensureAccess(args.title, args.launch_token);
931
935
  if (!ready.signedIn)
932
936
  return accessReply(ready);
933
937
  if (args.plan_id === undefined) {
@@ -1015,8 +1019,13 @@ strictTool("card_connect",
1015
1019
  .describe("The `naming` block's exact `{Name}: {CARD-ID} {card title}`, after renaming this thread. Omit on the first connect."),
1016
1020
  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."),
1017
1021
  disconnect: z.boolean().optional().describe("true = let go of `card_id` (the card you hold); no `title`."),
1022
+ launch_token: z
1023
+ .string()
1024
+ .min(1)
1025
+ .optional()
1026
+ .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."),
1018
1027
  }, async (args) => {
1019
- const ready = await access.ensureAccess(args.title);
1028
+ const ready = await access.ensureAccess(args.title, args.launch_token);
1020
1029
  if (!ready.signedIn)
1021
1030
  return accessReply(ready);
1022
1031
  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.439", readOwnManifest);
253
+ var TOOLKIT_VERSION = resolveToolkitVersion(false ? void 0 : "0.1.441", 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,7 +287,12 @@ 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) {
244
298
  const created = await createAccessRequest(this.fetchImpl, this.deps.dashboardUrl, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@50arms/platform-mcp",
3
- "version": "0.1.439",
3
+ "version": "0.1.441",
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",