Public: anyone can read this; the crew can change it.
MCP `subscription_add`: put a Claude account in the crew's pool, its token write-only, with the same refusals as the Subscriptions page #5484
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5465-mcp-subscription-add-put-a-claude-accoun → main 7 commits fork 8 threads · 8 unresolved
docs/MCP.md +1
| @@ -130,6 +130,7 @@ for a tunnel and a forge that sleeps. | ||
| 130 | 130 | | `spawn` | `{ name, apps?, crew?, dryRun? }`: a child forge, planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make | yes | |
| 131 | 131 | | `ssh_keys` | `{ action?, title?, key?, id? }`: list your own SSH keys (default), add one (`key` is an authorized_keys line, `title` optional) or remove one by `id`; the API's rules and errors, add and remove need an `rw` token | yes | |
| 132 | 132 | | `stop` | stop one run by its id from `now` | yes | |
| 133 | | `subscription_add` | `{ name, token, apiBase? }` (forge admin): put a Claude account in the crew's pool, as the Subscriptions page does, with its refusals; answers `{ id, name, says }`; the token is never read back | yes | | |
| 133 | 134 | | `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 | |
| 134 | 135 | | `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 | |
| 135 | 136 | | `system` | the machine the forge runs on, now: CPU, load, memory, disk, network, processes; a forge admin, or an agent granted `telemetry:read` | | |
src/http/mcp/tools/index.ts +1
| @@ -112,6 +112,7 @@ export { tool as spawn } from "./spawn.ts"; | ||
| 112 | 112 | export { tool as spend } from "./spend.ts"; |
| 113 | 113 | export { tool as ssh_keys } from "./ssh_keys.ts"; |
| 114 | 114 | export { tool as stop } from "./stop.ts"; |
| 115 | export { tool as subscription_add } from "./subscription_add.ts"; | |
| 115 | 116 | export { tool as subscription_budget } from "./subscription_budget.ts"; |
| 116 | 117 | export { tool as subscription_wake } from "./subscription_wake.ts"; |
| 117 | 118 | export { tool as system } from "./system.ts"; |
src/http/mcp/tools/subscription_add.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | import { addSubscription } from "../../../core/subscriptions.ts"; | |
| 2 | import { addRefusal } from "../../../core/subscriptions_words.ts"; | |
| 3 | import { good, bad, type Tool } from "../shared.ts"; | |
| 4 | ||
| 5 | export const tool: Tool = { | |
| 6 | name: "subscription_add", | |
| 7 | doc: "`{ name, token, apiBase? }` (forge admin): put a Claude account in the crew's pool, as the Subscriptions page does, with its refusals; answers `{ id, name, says }`; the token is never read back", | |
| 8 | description: "Admin: add a Claude login.", | |
| 9 | inputSchema: { | |
| 10 | type: "object", | |
| 11 | properties: { name: { type: "string" }, token: { type: "string" }, apiBase: { type: "string" } }, | |
| 12 | required: ["name", "token"], | |
| 13 | }, | |
| 14 | writes: true, | |
outdated
Low: replace this hand-written scope check with |
||
| 15 | adminOnly: true, | |
Good: the admin and admin-scope gate comes from the shared |
||
| 16 | needsAdminScope: true, | |
| 17 | run(ctx, user, args) { | |
| 18 | const str = (v: unknown): string => (typeof v === "string" ? v : ""); | |
| 19 | const name = str(args["name"]); | |
| 20 | const added = addSubscription(ctx.app.db, ctx.app.config.dataDir, { | |
| 21 | name, | |
| 22 | token: str(args["token"]).trim(), | |
| 23 | apiBase: str(args["apiBase"]).trim(), | |
| 24 | createdBy: user.id, | |
| 25 | }); | |
| 26 | if (!added.ok) return bad(added.error, addRefusal(added.error, name)); | |
| 27 | return good({ | |
| 28 | id: added.value.id, | |
| 29 | name: added.value.name, | |
| 30 | says: `${added.value.name} is in the pool. Grant it to a repository under its Settings → Crew, or to an organization on its page.`, | |
| 31 | }); | |
| 32 | }, | |
| 33 | }; |
test/mcp-subscription-add.test.ts added +125
| @@ -0,0 +1,125 @@ | ||
| 1 | /** | |
| 2 | * The `subscription_add` tool is the Subscriptions page's add, over MCP: an | |
| 3 | * admin only, the page's words for each refusal, and the token never read back. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, 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 { listSubscriptions } from "../src/core/subscriptions.ts"; | |
| 11 | import { addRefusal } from "../src/core/subscriptions_words.ts"; | |
| 12 | import { createSession } from "../src/core/sessions.ts"; | |
| 13 | import { createToken } from "../src/core/tokens.ts"; | |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | ||
| 17 | describe("MCP subscription_add", () => { | |
| 18 | let dataDir: string; | |
| 19 | let app: App; | |
| 20 | let servers: Servers; | |
| 21 | let alice: string; | |
| 22 | let bob: string; | |
| 23 | let aliceId: string; | |
| 24 | let id = 1; | |
| 25 | const TOKEN = "sk-ant-oat01-secret-token-value-xyz"; | |
| 26 | ||
| 27 | const rpc = async (token: string, method: string, params: unknown) => { | |
| 28 | const res = await fetch(`${servers.url}/mcp`, { | |
| 29 | method: "POST", | |
| 30 | headers: { | |
| 31 | "content-type": "application/json", | |
| 32 | accept: "application/json, text/event-stream", | |
| 33 | authorization: `Bearer ${token}`, | |
| 34 | }, | |
| 35 | body: JSON.stringify({ jsonrpc: "2.0", id: id++, method, params }), | |
| 36 | }); | |
| 37 | return (await res.json()) as any; | |
| 38 | }; | |
| 39 | const tool = async (token: string, args: unknown) => { | |
| 40 | const body = await rpc(token, "tools/call", { name: "subscription_add", arguments: args }); | |
| 41 | if (body.error) return { error: JSON.stringify(body.error) }; | |
| 42 | const text: string = body.result.content[0].text; | |
| 43 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text), text }; | |
| 44 | }; | |
| 45 | ||
| 46 | beforeAll(async () => { | |
| 47 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-subadd-")); | |
| 48 | app = createApp({ dataDir, port: 0 }); | |
| 49 | servers = await serve(app); | |
| 50 | const a = await createUser(app.db, { origin: "seed", trusted: true, | |
| 51 | username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 52 | if (!a.ok) throw new Error(a.error); | |
| 53 | const b = await createUser(app.db, { origin: "seed", trusted: true, | |
| 54 | username: "bob", email: "[email protected]", password: "a-good-passphrase" }); | |
| 55 | if (!b.ok) throw new Error(b.error); | |
| 56 | aliceId = a.value.id; | |
| 57 | alice = createToken(app.db, a.value.id, "admin", "admin").token; | |
| 58 | bob = createToken(app.db, b.value.id, "admin", "admin").token; | |
| 59 | }, 30_000); | |
| 60 | ||
| 61 | afterAll(async () => { | |
| 62 | servers.stop(); | |
| 63 | app.close(); | |
| 64 | await rm(dataDir, { recursive: true, force: true }); | |
| 65 | }); | |
| 66 | ||
| 67 | test("an admin adds one and it is in the pool, marked as a write", async () => { | |
| 68 | const added = await tool(alice, { name: "Team Max", token: TOKEN }); | |
| 69 | expect(added.value.name).toBe("Team Max"); | |
| 70 | expect(added.value.says).toContain("is in the pool"); | |
| 71 | const listed = listSubscriptions(app.db); | |
| 72 | expect(listed.map((s) => s.id)).toContain(added.value.id); | |
| 73 | expect(listed.find((s) => s.id === added.value.id)!.createdBy).toBe(aliceId); | |
| 74 | const page = await fetch(`${servers.url}/settings/subscriptions`, { | |
| 75 | headers: { cookie: `forge_session=${createSession(app.db, aliceId).token}` }, | |
| 76 | }).then((r) => r.text()); | |
| 77 | expect(page).toContain("Team Max"); | |
| 78 | expect(page).not.toContain(TOKEN); | |
| 79 | const tools = (await rpc(alice, "tools/list", {})).result.tools; | |
| 80 | const t = tools.find((x: any) => x.name === "subscription_add"); | |
| 81 | expect(t.annotations?.readOnlyHint).toBeUndefined(); | |
| 82 | }); | |
| 83 | ||
| 84 | test("a taken name is refused", async () => { | |
| 85 | const again = await tool(alice, { name: "Team Max", token: TOKEN }); | |
| 86 | expect(again.error).toContain(addRefusal("name_taken", "Team Max")); | |
| 87 | }); | |
| 88 | ||
| 89 | test("a bad token is refused with the page's words", async () => { | |
| 90 | const bad = await tool(alice, { name: "Other", token: "not a token" }); | |
| 91 | expect(bad.error).toContain(addRefusal("invalid_token", "Other")); | |
outdated
This exercises |
||
| 92 | expect(bad.error).toContain("claude setup-token"); | |
| 93 | const base = await tool(alice, { name: "Other", token: TOKEN, apiBase: "https://x.example" }); | |
| 94 | expect(base.error).toContain(addRefusal("invalid_api_base", "Other")); | |
| 95 | }); | |
| 96 | ||
| 97 | test("a person who is not an admin is refused", async () => { | |
| 98 | const res = await rpc(bob, "tools/call", { | |
| 99 | name: "subscription_add", | |
| 100 | arguments: { name: "Bobs", token: TOKEN }, | |
| 101 | }); | |
| 102 | expect(res.error ?? res.result.isError).toBeTruthy(); | |
| 103 | expect(listSubscriptions(app.db).some((s) => s.name === "Bobs")).toBe(false); | |
| 104 | }); | |
| 105 | ||
| 106 | test("an admin's rw token is refused, and nothing is stored", async () => { | |
| 107 | const rw = createToken(app.db, aliceId, "rw", "rw").token; | |
| 108 | const res = await tool(rw, { name: "Sneaky", token: TOKEN }); | |
| 109 | expect(res.error).toContain("no tool named"); | |
| 110 | expect(res.error).not.toContain(TOKEN); | |
| 111 | expect(listSubscriptions(app.db).some((s) => s.name === "Sneaky")).toBe(false); | |
| 112 | }); | |
| 113 | ||
| 114 | test("neither the answer nor any stored event carries the token", async () => { | |
| 115 | const added = await tool(alice, { name: "Quiet", token: TOKEN }); | |
| 116 | expect(added.text).not.toContain(TOKEN); | |
| 117 | const bad = await tool(alice, { name: "Quiet", token: TOKEN }); | |
| 118 | expect(bad.error).not.toContain(TOKEN); | |
| 119 | const tables = app.db | |
| 120 | .query("SELECT name FROM sqlite_master WHERE type='table' AND (name LIKE '%event%' OR name LIKE '%log%' OR name LIKE '%audit%')") | |
| 121 | .all() as { name: string }[]; | |
| 122 | for (const { name } of tables) | |
| 123 | expect(JSON.stringify(app.db.query(`SELECT * FROM "${name}"`).all())).not.toContain(TOKEN); | |
| 124 | }); | |
| 125 | }); |
test/mcp-write-scope.test.ts +1
| @@ -24,6 +24,7 @@ const ADMIN_ARGS: Record<string, Record<string, unknown>> = { | ||
| 24 | 24 | org_teams: { org: "acme", action: "create", team: "core", permission: "read" }, |
| 25 | 25 | agent: { action: "create", username: "robo", displayName: "Robo", model: "m", brief: "b" }, |
| 26 | 26 | subscription_budget: { id: "u1", usd: 5 }, |
| 27 | subscription_add: { name: "Sneaky", token: "sk-ant-oat01-aaaaaaaaaaaaaaaaaaaa" }, | |
| 27 | 28 | subscription_wake: { id: "u1" }, |
| 28 | 29 | prompts: { repo: "alice/game", action: "remove", id: "p1" }, |
| 29 | 30 | telemetry_grant: { agent: "robo", on: true }, |
High (reachability): a bare object schema plus a description that names no arguments means an agent cannot discover
name,tokenorapiBasethrough MCP. Declare them, withrequired: ["name","token"], as subscription_budget.ts does.