Public: anyone can read this; the crew can change it.

MCP `tokens`: list the caller's own API tokens, with name, scope and when last used, never the secret #5488

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/5475-mcp-tokens-list-the-caller-s-own-api-tok → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/5475-mcp-tokens-list-the-caller-s-own-api-tok was merged into main as 5122c72.

Review

Sign in to review this change.
5 files changed +101 −0
docs/MCP.md +1
@@ -139,6 +139,7 @@ for a tunnel and a forge that sleeps.
139139 | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes |
140140 | `thought` | `{ id }`: where one of your thoughts went (its id comes from `tell` without a repo): the placement, and the earlier thoughts it repeats or takes back and the later ones that did the same; not yours and not there answer alike, `not_found` | |
141141 | `threads` | `{ repo, number, anchor?, resolved? }`: a pull's review threads with their comments; with `anchor` (from the list) resolve it (`resolved` true, the default) or reopen it (false), by the API's rules: a writer or someone who commented in the thread | yes |
142 | `tokens` | your own API tokens, as `GET /api/v1/tokens` lists them: id, name, scope, created and last used; never a token or its hash | |
142143 | `trail` | `{ repo, shown?, dryRun? }`: where a migrated repository came from (`provenance`: source, sourceUrl, parents, migratedAt) and whether it is shown; with `shown`, shows or hides the trail — planned first (`dryRun` is true unless `false`), only the repository's admin with an admin token, never an agent. Hiding keeps the record; a hidden one reads `null` to anyone but an admin | yes |
143144 | `upstream` | `{ repo, enabled?, url?, branch?, check? }`: the repository a fork follows (an admin reads it); with `enabled` sets it, with `check: true` checks it now; setting and checking need an admin token | yes |
144145 | `usage` | `{ repo, days? }`: what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue, and each account's 5-hour and weekly standing; a writer's | |
src/http/mcp/tools/index.ts +1
@@ -122,6 +122,7 @@ export { tool as telemetry_history } from "./telemetry_history.ts";
122122 export { tool as tell } from "./tell.ts";
123123 export { tool as thought } from "./thought.ts";
124124 export { tool as threads } from "./threads.ts";
125 export { tool as tokens } from "./tokens.ts";
125126 export { tool as trail } from "./trail.ts";
126127 export { tool as upstream } from "./upstream.ts";
127128 export { tool as usage } from "./usage.ts";
src/http/mcp/tools/tokens.ts added +13
@@ -0,0 +1,13 @@
1 import { listTokens } from "../../../core/tokens.ts";
2 import { good, type Tool } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "tokens",
6 doc: "your own API tokens, as `GET /api/v1/tokens` lists them: id, name, scope, created and last used; never a token or its hash",
7 description: "Your API tokens, no secrets.",
8 inputSchema: { type: "object", properties: {} },
9 writes: false,
10 run(ctx, user) {
11 return good({ items: listTokens(ctx.app.db, user.id) });
12 },
13 };
test/mcp-tokens.test.ts added +85
@@ -0,0 +1,85 @@
1 /**
2 * The `tokens` tool lists the caller's own API tokens and nobody else's,
3 * never a secret or a hash.
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { seedAdmin } from "./helpers/admin.ts";
13 import { promote } from "./helpers/members.ts";
14 import { createUser } from "../src/core/users.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18
19 let dir: string;
20 let app: App;
21 let servers: Servers;
22 let id = 1;
23 const mine: { token: string; name: string }[] = [];
24 let bobToken = "";
25
26 const rpc = async (token: string, method: string, params: unknown = {}) => {
27 const res = await fetch(`${servers.url}/mcp`, {
28 method: "POST",
29 headers: {
30 "content-type": "application/json",
31 accept: "application/json, text/event-stream",
32 authorization: `Bearer ${token}`,
33 },
34 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method, params }),
35 });
36 return (await res.json()) as any;
37 };
38 const tokens = async (token: string) => {
39 const body = await rpc(token, "tools/call", { name: "tokens", arguments: {} });
40 const text: string = body.result.content[0].text;
41 return { text, value: JSON.parse(text) };
42 };
43
44 beforeAll(async () => {
45 dir = await mkdtemp(join(tmpdir(), "forge-mcp-tokens-"));
46 app = createApp({ dataDir: dir, port: 0 });
47 servers = await serve(app);
48 const a = await seedAdmin(app, { username: "olive", email: "[email protected]", password: "owner-passphrase" });
49 if (!a.ok) throw new Error(a.error);
50 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
51 if (!b.ok) throw new Error(b.error);
52 promote(app.db, b.value);
53 mine.push({ ...createToken(app.db, a.value.id, "laptop", "rw"), name: "laptop" });
54 mine.push({ ...createToken(app.db, a.value.id, "reader", "ro"), name: "reader" });
55 bobToken = createToken(app.db, b.value.id, "bobs-secret-name", "ro").token;
56 }, 30_000);
57
58 afterAll(async () => {
59 servers.stop();
60 app.close();
61 await rm(dir, { recursive: true, force: true });
62 });
63
64 test("a person sees both their tokens, and no secret", async () => {
65 const got = await tokens(mine[0]!.token);
66 expect(got.value.items.map((t: { name: string }) => t.name).sort()).toEqual(["laptop", "reader"]);
67 expect(got.value.items[0]).toHaveProperty("scope");
68 expect(got.value.items[0]).toHaveProperty("createdAt");
69 for (const t of mine) expect(got.text).not.toContain(t.token);
70 expect(got.text).not.toMatch(/hash/i);
71 });
72
73 test("another person's tokens are never listed", async () => {
74 const got = await tokens(mine[0]!.token);
75 expect(got.text).not.toContain("bobs-secret-name");
76 expect(got.text).not.toContain(bobToken);
77 const bob = await tokens(bobToken);
78 expect(bob.value.items.map((t: { name: string }) => t.name)).toEqual(["bobs-secret-name"]);
79 });
80
81 test("tools/list marks it read-only", async () => {
82 const body = await rpc(mine[0]!.token, "tools/list");
83 const t = body.result.tools.find((x: { name: string }) => x.name === "tokens");
84 expect(t.annotations.readOnlyHint).toBe(true);
85 });
test/mcp.test.ts +1
@@ -214,6 +214,7 @@ describe("the forge over MCP", () => {
214214 "accounts",
215215 "whoami_can",
216216 "mint_invite",
217 "tokens",
217218 "invites",
218219 "account_role",
219220 "account_suspend",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs