@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 +1 -0
- package/dist/access-request.js +7 -2
- package/dist/index.js +18 -9
- 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
|
@@ -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` (
|
|
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:
|
|
888
|
-
"
|
|
889
|
-
"
|
|
890
|
-
"
|
|
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));
|
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",
|