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

MCP `subscriptions`: the Claude accounts the crew works on and where each is used, never a token, for a forge admin #6993

Merged opened this

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

Merged

scout/forge:scout/5418-mcp-subscriptions-the-claude-accounts-th → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/5418-mcp-subscriptions-the-claude-accounts-th was merged into main as fa8fa56.

Review

Sign in to review this change.
5 files changed +118 −0
docs/MCP.md +1
@@ -151,6 +151,7 @@ for a tunnel and a forge that sleeps.
151151 | `subscription_budget` | `{ id, usd }`: set an account's whole-life budget in dollars (up to the `subscription.budget_max_usd` limit), or clear it with `usd: null`, as the Subscriptions page does; a locked budget can only be lowered; a forge admin with an admin token | yes |
152152 | `subscription_delete` | `{ id }`: remove a subscription and its grants, as the Subscriptions page does; runs it paid for keep it in their history; a forge admin's, with an admin token | yes |
153153 | `subscription_wake` | `{ id }`: use a resting Claude subscription again now, as the Subscriptions page's Wake button does; answers `{ id, says }`; a forge admin, an admin token | yes |
154 | `subscriptions` | the Claude accounts the crew works on: id (the handle `subscription_wake`, `subscription_budget` and `subscription_delete` take), name, kind, the repositories and organizations each pays for, budget and spend, and whether it is resting; never a token. A forge admin's | |
154155 | `sync` | `{ repo, originId? }`: sync a mirror now, or with `originId` (admin token) reconnect its origin; refused as `POST …/sync` and `…/origin/reconnect` are, a fork is pointed to `take_updates` | yes |
155156 | `system` | the machine the forge runs on, now: CPU, load, memory, disk, network, processes; a forge admin, or an agent granted `telemetry:read` | |
156157 | `take_updates` | a fork takes its parent's updates when git can do it alone; a conflict is refused. On a spawned forge's code repository it opens the one `forge/take-updates` pull and never moves the default branch | yes |
src/http/mcp/tools/index.ts +1
@@ -126,6 +126,7 @@ export { tool as subscription_add } from "./subscription_add.ts";
126126 export { tool as subscription_budget } from "./subscription_budget.ts";
127127 export { tool as subscription_delete } from "./subscription_delete.ts";
128128 export { tool as subscription_wake } from "./subscription_wake.ts";
129 export { tool as subscriptions } from "./subscriptions.ts";
129130 export { tool as sync } from "./sync.ts";
130131 export { tool as system } from "./system.ts";
131132 export { tool as take_updates } from "./take_updates.ts";
src/http/mcp/tools/subscriptions.ts added +32
@@ -0,0 +1,32 @@
1 import { budgetsOf, listSubscriptions, scopesOf } from "../../../core/subscriptions.ts";
2 import { good, type Tool } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "subscriptions",
6 doc: "the Claude accounts the crew works on: id (the handle `subscription_wake`, `subscription_budget` and `subscription_delete` take), name, kind, the repositories and organizations each pays for, budget and spend, and whether it is resting; never a token. A forge admin's",
7 description: "Claude accounts: scopes, budgets.",
8 inputSchema: { type: "object", properties: {} },
9 writes: false,
10 adminOnly: true,
11 run(ctx) {
12 const scopes = scopesOf(ctx.app.db);
13 const budgets = budgetsOf(ctx.app.db);
14 return good({
15 items: listSubscriptions(ctx.app.db).map((s) => {
16 const b = budgets.get(s.id);
17 return {
18 id: s.id,
outdated

Add id: s.id here. subscription_wake, subscription_budget and subscription_delete all need { id }, and this list is the only place over MCP an agent could learn it.

19 name: s.name,
20 kind: s.kind,
21 apiBase: s.apiBase,
22 scopes: scopes.get(s.id) ?? [],
23 budgetUsd: b?.budgetUsd ?? null,
24 spentUsd: b?.spentUsd ?? 0,
25 restingUntil: s.restingUntil,
26 restingSaid: s.restingSaid,
27 lastUsedAt: s.lastUsedAt,
28 };
29 }),
30 });
31 },
32 };
test/mcp-subscriptions.test.ts added +83
@@ -0,0 +1,83 @@
1 /** The `subscriptions` tool shows what the pool page shows, and never a token. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createToken } from "../src/core/tokens.ts";
8 import { serve, type Servers } from "../src/http/server.ts";
9 import { addSubscription, EVERYONE, setBudget, setGrants } from "../src/core/subscriptions.ts";
10 import { createUser } from "../src/core/users.ts";
11 import { seedAdmin } from "./helpers/admin.ts";
12 import { promote } from "./helpers/members.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14
15 quietLogs();
16
17 let dir: string;
18 let app: App;
19 let servers: Servers;
20 let admin = "";
21 let bob = "";
22 let id = 1;
23 let oliveId = "";
24 const SECRET = "sk-ant-oat01-supersecretvalue-0123456789abcdef";
25
26 const call = async (token: string, name: string) => {
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: "tools/call", params: { name, arguments: {} } }),
35 });
36 const body = (await res.json()) as any;
37 const text: string = body.result?.content[0].text ?? body.error?.message ?? "";
38 return { text, failed: body.error !== undefined || body.result.isError === true };
39 };
40
41 beforeAll(async () => {
42 dir = await mkdtemp(join(tmpdir(), "forge-mcp-subs-"));
43 app = createApp({ dataDir: dir, port: 0 });
44 servers = await serve(app);
45 const a = await seedAdmin(app, { username: "olive", email: "[email protected]", password: "owner-passphrase" });
46 if (!a.ok) throw new Error(a.error);
47 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
48 if (!b.ok) throw new Error(b.error);
49 promote(app.db, b.value);
50 oliveId = a.value.id;
51 admin = createToken(app.db, a.value.id, "admin", "admin").token;
52 bob = createToken(app.db, b.value.id, "bob", "admin").token;
53 }, 30_000);
54
55 afterAll(async () => {
56 servers.stop();
57 app.close();
58 await rm(dir, { recursive: true, force: true });
59 });
60
61 test("an admin sees a subscription, scopes and budget, and the token is nowhere", async () => {
62 const added = addSubscription(app.db, dir, { name: "work", token: SECRET });
63 if (!added.ok) throw new Error(String(added.error));
64 expect(setBudget(app.db, added.value.id, 12.5)).toBe(true);
65 setGrants(app.db, oliveId, EVERYONE, [added.value.id]);
66
67 const res = await call(admin, "subscriptions");
68 expect(res.failed).toBe(false);
69 expect(res.text).not.toContain(SECRET);
70 expect(res.text).not.toContain("supersecretvalue");
71 const item = JSON.parse(res.text).items.find((i: { name: string }) => i.name === "work");
72 expect(item.id).toBe(added.value.id);
73 expect(item.spentUsd).toBe(0);
74 expect(item.restingUntil).toBeNull();
outdated

Assert item.id === added.value.id here (and spentUsd === 0), so the id the other tools need is covered by the test.

75 expect(item.budgetUsd).toBe(12.5);
76 expect(item.scopes).toEqual(["@olive"]);
77 });
78
79 test("a non-admin is refused", async () => {
80 const res = await call(bob, "subscriptions");
81 expect(res.failed).toBe(true);
82 expect(res.text).not.toContain("work");
83 });
test/mcp.test.ts +1
@@ -281,6 +281,7 @@ describe("the forge over MCP", () => {
281281 "threads",
282282 "snapshot_now",
283283 "spend",
284 "subscriptions",
284285 "usage",
285286 "labels",
286287 "repo_labels",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs