Public: anyone can read this; the crew can change it.
MCP `subscription_delete`: remove a subscription and its grants, as the Subscriptions page does, for a forge admin #5508
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5464-mcp-subscription-delete-remove-a-subscri → main 14 commits fork 10 threads · 10 unresolved
docs/MCP.md +1
| @@ -140,6 +140,7 @@ for a tunnel and a forge that sleeps. | ||
| 140 | 140 | | `stop` | stop one run by its id from `now` | yes | |
| 141 | 141 | | `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 | |
| 142 | 142 | | `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 | |
| 143 | | `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 | | |
| 143 | 144 | | `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 | |
| 144 | 145 | | `system` | the machine the forge runs on, now: CPU, load, memory, disk, network, processes; a forge admin, or an agent granted `telemetry:read` | | |
| 145 | 146 | | `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/core/subscriptions_words.ts +2
| @@ -19,6 +19,8 @@ export const SUBSCRIPTION_WORDS = { | ||
| 19 | 19 | "The API base URL must be https, with no credentials, query, or hash. For OpenRouter use https://openrouter.ai/api (a Claude subscription token cannot use a gateway).", |
| 20 | 20 | headroom_range: `Headroom is a whole percent from 0 to ${HEADROOM_MAX}.`, |
| 21 | 21 | not_found: "No such subscription.", |
| 22 | removed: | |
| 23 | "Removed, with its grants. Runs it paid for keep it in their history.", | |
| 22 | 24 | } as const; |
| 23 | 25 | |
| 24 | 26 | /** The sentence for an add refused with `error`; `name` is what was typed. */ |
src/http/mcp/tools/index.ts +1
| @@ -118,6 +118,7 @@ export { tool as ssh_keys } from "./ssh_keys.ts"; | ||
| 118 | 118 | export { tool as stop } from "./stop.ts"; |
| 119 | 119 | export { tool as subscription_add } from "./subscription_add.ts"; |
| 120 | 120 | export { tool as subscription_budget } from "./subscription_budget.ts"; |
| 121 | export { tool as subscription_delete } from "./subscription_delete.ts"; | |
| 121 | 122 | export { tool as subscription_wake } from "./subscription_wake.ts"; |
| 122 | 123 | export { tool as system } from "./system.ts"; |
| 123 | 124 | export { tool as take_updates } from "./take_updates.ts"; |
src/http/mcp/tools/subscription_delete.ts added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | import { deleteSubscription } from "../../../core/subscriptions.ts"; | |
| 2 | import { SUBSCRIPTION_WORDS } from "../../../core/subscriptions_words.ts"; | |
| 3 | import { good, bad, type Tool } from "../shared.ts"; | |
| 4 | ||
| 5 | export const tool: Tool = { | |
| 6 | name: "subscription_delete", | |
| 7 | doc: "`{ 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", | |
| 8 | description: "Remove a subscription, with its grants.", | |
| 9 | inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, | |
| 10 | writes: true, | |
| 11 | adminOnly: true, | |
| 12 | needsAdminScope: true, | |
| 13 | run(ctx, _user, args) { | |
| 14 | const subscription = args["id"]; | |
| 15 | if (typeof subscription !== "string" || subscription === "" || subscription.length > 64) return bad("invalid", "send the subscription's id"); | |
Nit: its siblings |
||
| 16 | if (!deleteSubscription(ctx.app.db, subscription)) return bad("not_found", SUBSCRIPTION_WORDS.not_found); | |
Nit: add a test that |
||
| 17 | return good({ id: subscription, says: SUBSCRIPTION_WORDS.removed }); | |
| 18 | }, | |
| 19 | }; |
src/web/pages/subscriptions.ts +1 −2
| @@ -443,8 +443,7 @@ export async function subscriptionDelete(ctx: Ctx): Promise<Response> { | ||
| 443 | 443 | POOL, |
| 444 | 444 | removed |
| 445 | 445 | ? { |
| 446 | message: | |
| 447 | "Removed, with its grants. Runs it paid for keep it in their history.", | |
| 446 | message: SUBSCRIPTION_WORDS.removed, | |
| 448 | 447 | } |
| 449 | 448 | : { tone: "danger", message: SUBSCRIPTION_WORDS.not_found }, |
| 450 | 449 | ); |
test/mcp-subscription-delete.test.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** MCP `subscription_delete`: the Subscriptions page's removal, from a harness. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createApp, type App } from "../src/app.ts"; | |
| 9 | import { createRun } from "../src/core/runs.ts"; | |
| 10 | import { addSubscription, getSubscription, listGrants, setGrants } from "../src/core/subscriptions.ts"; | |
| 11 | import { SUBSCRIPTION_WORDS } from "../src/core/subscriptions_words.ts"; | |
| 12 | import { createToken } from "../src/core/tokens.ts"; | |
| 13 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | const PASS = "a-long-subscription-passphrase"; | |
| 20 | const KEY = "sk-ant-api03-work-key-for-the-test"; | |
| 21 | ||
| 22 | let dataDir = ""; | |
| 23 | let app: App; | |
| 24 | let servers: Servers; | |
| 25 | ||
| 26 | const mcp = async (token: string, args: unknown) => { | |
| 27 | const res = await fetch(`${servers.url}/mcp`, { | |
| 28 | method: "POST", | |
| 29 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 30 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "subscription_delete", arguments: args } }), | |
| 31 | }); | |
| 32 | const body = (await res.json()) as any; | |
| 33 | // A non-admin's tools/list lacks the tool, so the call is a JSON-RPC error, not a result. | |
| 34 | if (body.error) return { error: true, text: body.error.message as string }; | |
| 35 | return { error: body.result.isError === true, text: body.result.content[0].text as string }; | |
| 36 | }; | |
| 37 | ||
| 38 | const pool = (name: string) => { | |
| 39 | const added = addSubscription(app.db, dataDir, { name, token: KEY }); | |
| 40 | if (!added.ok) throw new Error(added.error); | |
| 41 | return added.value; | |
| 42 | }; | |
| 43 | ||
| 44 | beforeAll(async () => { | |
| 45 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-subdel-")); | |
| 46 | app = createApp({ dataDir, port: 0 }); | |
| 47 | servers = await serve(app); | |
| 48 | for (const [username, isAdmin] of [["alice", true], ["bob", false]] as const) { | |
| 49 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: PASS, isAdmin }); | |
| 50 | if (!made.ok) throw new Error(username); | |
| 51 | } | |
| 52 | }, 30_000); | |
| 53 | ||
| 54 | afterAll(async () => { | |
| 55 | servers.stop(); | |
| 56 | app.close(); | |
| 57 | await rm(dataDir, { recursive: true, force: true }); | |
| 58 | }); | |
| 59 | ||
| 60 | const adminToken = () => createToken(app.db, getUserByName(app.db, "alice")!.id, "t", "admin").token; | |
| 61 | ||
| 62 | test("an admin removes one, and it is gone from the page with its grants", async () => { | |
| 63 | const sub = pool("gone"); | |
| 64 | setGrants(app.db, "scope-1", "*", [sub.id]); | |
| 65 | expect(listGrants(app.db, "scope-1")).toHaveLength(1); | |
| 66 | const r = await mcp(adminToken(), { id: sub.id }); | |
| 67 | expect(r.error).toBe(false); | |
| 68 | expect(JSON.parse(r.text)).toEqual({ | |
| 69 | id: sub.id, | |
| 70 | says: SUBSCRIPTION_WORDS.removed, | |
| 71 | }); | |
| 72 | expect(getSubscription(app.db, sub.id)).toBeUndefined(); | |
| 73 | expect(listGrants(app.db, "scope-1")).toHaveLength(0); | |
| 74 | }); | |
| 75 | ||
| 76 | test("a run it paid for still names it", async () => { | |
| 77 | const sub = pool("paid"); | |
| 78 | const alice = getUserByName(app.db, "alice")!; | |
| 79 | const made = await createRepo(app, alice, { name: "hello", visibility: "private" }); | |
| 80 | if (!made.ok) throw new Error(String(made.error)); | |
| 81 | const run = createRun(app.db, { repoId: made.value.id, kind: "agent", reason: "assigned" }); | |
| 82 | app.db.query("UPDATE run SET subscription_id = ?1 WHERE id = ?2").run(sub.id, run.id); | |
| 83 | expect((await mcp(adminToken(), { id: sub.id })).error).toBe(false); | |
| 84 | expect(app.db.query("SELECT subscription_id FROM run WHERE id = ?1").get(run.id)).toEqual({ subscription_id: sub.id }); | |
| 85 | }); | |
| 86 | ||
| 87 | test("an unknown id is refused", async () => { | |
Nit, not blocking: add one case for |
||
| 88 | const r = await mcp(adminToken(), { id: "nope" }); | |
| 89 | expect(r.error).toBe(true); | |
| 90 | expect(r.text).toContain("No such subscription."); | |
| 91 | }); | |
outdated
bob is refused by |
||
| 92 | ||
| 93 | test("a non-admin never sees the tool: the call is an unknown tool, and the subscription stays", async () => { | |
| 94 | const sub = pool("kept"); | |
| 95 | const bob = createToken(app.db, getUserByName(app.db, "bob")!.id, "t", "admin").token; | |
| 96 | const r = await mcp(bob, { id: sub.id }); | |
| 97 | expect(r.error).toBe(true); | |
| 98 | expect(getSubscription(app.db, sub.id)).toBeDefined(); | |
| 99 | }); | |
| 100 | ||
| 101 | test("an admin's everyday rw token is refused: it needs an admin token, and the subscription stays", async () => { | |
| 102 | const sub = pool("rw-kept"); | |
| 103 | const rw = createToken(app.db, getUserByName(app.db, "alice")!.id, "t", "rw").token; | |
| 104 | const r = await mcp(rw, { id: sub.id }); | |
| 105 | expect(r.error).toBe(true); | |
| 106 | expect(r.text).toContain("no tool named subscription_delete"); | |
| 107 | expect(getSubscription(app.db, sub.id)).toBeDefined(); | |
| 108 | }); |
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_delete: { id: "u1" }, | |
| 27 | 28 | subscription_add: { name: "Sneaky", token: "sk-ant-oat01-aaaaaaaaaaaaaaaaaaaa" }, |
| 28 | 29 | subscription_wake: { id: "u1" }, |
| 29 | 30 | prompts: { repo: "alice/game", action: "remove", id: "p1" }, |
Nit: cap the id as
subscription_budgetdoes (id.length > 64→ invalid). It is bound, so this is hygiene, not a hole.