@50arms/platform-mcp 0.1.440 → 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 +1 -0
- package/dist/access-request.js +7 -2
- package/dist/index.js +12 -2
- package/dist/machine-toolkit.js +1 -1
- package/dist/session-access.js +58 -4
- package/package.json +1 -1
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.
|
package/dist/access-request.js
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -907,6 +907,11 @@ strictTool("plan_connect",
|
|
|
907
907
|
'The `naming` block\'s exact `{Name}: PLAN-NNN {short title}`, after renaming this thread. Omit on the first connect.'),
|
|
908
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."),
|
|
909
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."),
|
|
910
915
|
person_sign_in: z
|
|
911
916
|
.boolean()
|
|
912
917
|
.optional()
|
|
@@ -926,7 +931,7 @@ async (args) => {
|
|
|
926
931
|
return haltResult("plan_connect: `disconnect: true` needs `plan_id`, the plan this session is currently on");
|
|
927
932
|
}
|
|
928
933
|
// DX-4390 — no key yet: ask for one (or wait for the approval already asked for).
|
|
929
|
-
const ready = await access.ensureAccess(args.title);
|
|
934
|
+
const ready = await access.ensureAccess(args.title, args.launch_token);
|
|
930
935
|
if (!ready.signedIn)
|
|
931
936
|
return accessReply(ready);
|
|
932
937
|
if (args.plan_id === undefined) {
|
|
@@ -1014,8 +1019,13 @@ strictTool("card_connect",
|
|
|
1014
1019
|
.describe("The `naming` block's exact `{Name}: {CARD-ID} {card title}`, after renaming this thread. Omit on the first connect."),
|
|
1015
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."),
|
|
1016
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."),
|
|
1017
1027
|
}, async (args) => {
|
|
1018
|
-
const ready = await access.ensureAccess(args.title);
|
|
1028
|
+
const ready = await access.ensureAccess(args.title, args.launch_token);
|
|
1019
1029
|
if (!ready.signedIn)
|
|
1020
1030
|
return accessReply(ready);
|
|
1021
1031
|
const result = spillRoleInstruction(await cardConnect(client, args));
|
package/dist/machine-toolkit.js
CHANGED
|
@@ -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.
|
|
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
|
}
|
package/dist/session-access.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|