@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 +1 -0
- package/dist/access-request.js +7 -2
- package/dist/checkout-docs-root.js +9 -0
- package/dist/index.js +14 -3
- package/dist/machine-toolkit.js +1 -1
- package/dist/session-access.js +60 -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)
|
|
@@ -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));
|
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.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
|
}
|
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,15 +287,22 @@ 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) {
|
|
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.
|
|
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",
|