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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5418-mcp-subscriptions-the-claude-accounts-th → main 5 commits fork 2 threads · 2 unresolved
5 files
changed +118
−0
docs/MCP.md +1
| @@ -151,6 +151,7 @@ for a tunnel and a forge that sleeps. | ||
| 151 | 151 | | `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 | |
| 152 | 152 | | `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 | |
| 153 | 153 | | `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 | | | |
| 154 | 155 | | `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 | |
| 155 | 156 | | `system` | the machine the forge runs on, now: CPU, load, memory, disk, network, processes; a forge admin, or an agent granted `telemetry:read` | | |
| 156 | 157 | | `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"; | ||
| 126 | 126 | export { tool as subscription_budget } from "./subscription_budget.ts"; |
| 127 | 127 | export { tool as subscription_delete } from "./subscription_delete.ts"; |
| 128 | 128 | export { tool as subscription_wake } from "./subscription_wake.ts"; |
| 129 | export { tool as subscriptions } from "./subscriptions.ts"; | |
| 129 | 130 | export { tool as sync } from "./sync.ts"; |
| 130 | 131 | export { tool as system } from "./system.ts"; |
| 131 | 132 | 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, | |
| 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 |
||
| 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", () => { | ||
| 281 | 281 | "threads", |
| 282 | 282 | "snapshot_now", |
| 283 | 283 | "spend", |
| 284 | "subscriptions", | |
| 284 | 285 | "usage", |
| 285 | 286 | "labels", |
| 286 | 287 | "repo_labels", |
new above
offline
Add
id: s.idhere.subscription_wake,subscription_budgetandsubscription_deleteall need{ id }, and this list is the only place over MCP an agent could learn it.