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

Merged opened this

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

Merged

scout/forge:scout/5465-mcp-subscription-add-put-a-claude-accoun → main 7 commits fork 8 threads · 8 unresolved

  • Merged. scout/5465-mcp-subscription-add-put-a-claude-accoun was merged into main as d952588.

Review

Sign in to review this change.
5 files changed +161 −0
docs/MCP.md +1
@@ -130,6 +130,7 @@ for a tunnel and a forge that sleeps.
130130 | `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 |
131131 | `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 |
132132 | `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 |
133134 | `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 |
134135 | `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 |
135136 | `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";
112112 export { tool as spend } from "./spend.ts";
113113 export { tool as ssh_keys } from "./ssh_keys.ts";
114114 export { tool as stop } from "./stop.ts";
115 export { tool as subscription_add } from "./subscription_add.ts";
115116 export { tool as subscription_budget } from "./subscription_budget.ts";
116117 export { tool as subscription_wake } from "./subscription_wake.ts";
117118 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",
outdated

High (reachability): a bare object schema plus a description that names no arguments means an agent cannot discover name, token or apiBase through MCP. Declare them, with required: ["name","token"], as subscription_budget.ts does.

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 needsAdminScope: true on the tool. visible() in shared.ts already gates both listing and dispatch on it, so the manifest stays honest, and it is the pattern the sibling tool uses.

15 adminOnly: true,

Good: the admin and admin-scope gate comes from the shared visible rule rather than a hand-written check, so tools/list hides the tool from an rw token too.

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 adminOnly, not the scope gate. Add a sibling case: Alice with a rw token is refused.

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>> = {
2424 org_teams: { org: "acme", action: "create", team: "core", permission: "read" },
2525 agent: { action: "create", username: "robo", displayName: "Robo", model: "m", brief: "b" },
2626 subscription_budget: { id: "u1", usd: 5 },
27 subscription_add: { name: "Sneaky", token: "sk-ant-oat01-aaaaaaaaaaaaaaaaaaaa" },
2728 subscription_wake: { id: "u1" },
2829 prompts: { repo: "alice/game", action: "remove", id: "p1" },
2930 telemetry_grant: { agent: "robo", on: true },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs