Public: anyone can read this; the crew can change it.
MCP `subscription_headroom`: set how much of each window the crew leaves for people, as the Subscriptions page does #7405
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/5439-mcp-subscription-headroom-set-how-much-o → main 1 commit fork 1 thread · 1 unresolved
5 files
changed +147
−0
docs/MCP.md +1
| @@ -166,6 +166,7 @@ for a tunnel and a forge that sleeps. | ||
| 166 | 166 | | `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 | |
| 167 | 167 | | `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 | |
| 168 | 168 | | `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 | |
| 169 | | `subscription_headroom` | `{ id, percent }`: how much of each 5-hour and weekly window the crew leaves for people on one account, a whole percent, as the Subscriptions page does; answers `{ id, percent, says }`; a forge admin with an admin token | yes | | |
| 169 | 170 | | `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 | |
| 170 | 171 | | `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 | | |
| 171 | 172 | | `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 | |
src/http/mcp/tools/index.ts +1
| @@ -135,6 +135,7 @@ export { tool as stop } from "./stop.ts"; | ||
| 135 | 135 | export { tool as subscription_add } from "./subscription_add.ts"; |
| 136 | 136 | export { tool as subscription_budget } from "./subscription_budget.ts"; |
| 137 | 137 | export { tool as subscription_delete } from "./subscription_delete.ts"; |
| 138 | export { tool as subscription_headroom } from "./subscription_headroom.ts"; | |
| 138 | 139 | export { tool as subscription_wake } from "./subscription_wake.ts"; |
| 139 | 140 | export { tool as subscriptions } from "./subscriptions.ts"; |
| 140 | 141 | export { tool as sync } from "./sync.ts"; |
src/http/mcp/tools/subscription_headroom.ts added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | import { headroomPct, setHeadroom } from "../../../core/standing.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_headroom", | |
| 7 | doc: "`{ id, percent }`: how much of each 5-hour and weekly window the crew leaves for people on one account, a whole percent, as the Subscriptions page does; answers `{ id, percent, says }`; a forge admin with an admin token", | |
| 8 | description: "Admin: set the % of each window the crew leaves for people.", | |
| 9 | inputSchema: { | |
| 10 | type: "object", | |
| 11 | properties: { id: { type: "string" }, percent: { type: "integer" } }, | |
| 12 | required: ["id", "percent"], | |
| 13 | }, | |
| 14 | writes: true, | |
| 15 | adminOnly: true, | |
| 16 | needsAdminScope: true, | |
| 17 | run(ctx, _user, args) { | |
| 18 | const id = args["id"]; | |
| 19 | const percent = args["percent"]; | |
| 20 | if (typeof id !== "string" || id === "" || id.length > 64) | |
| 21 | return bad("invalid", "id is the subscription's id"); | |
| 22 | // The page's own check: the form's text through headroomPct. | |
| 23 | const pct = | |
| 24 | typeof percent === "number" || typeof percent === "string" ? headroomPct(String(percent)) : undefined; | |
| 25 | if (pct === undefined) return bad("invalid", SUBSCRIPTION_WORDS.headroom_range); | |
| 26 | if (!setHeadroom(ctx.app.db, id, pct)) return bad("not_found", SUBSCRIPTION_WORDS.not_found); | |
| 27 | return good({ id, percent: pct, says: `The crew now leaves ${pct}% of each window for people.` }); | |
| 28 | }, | |
| 29 | }; |
test/mcp-subscription-headroom.test.ts added +115
| @@ -0,0 +1,115 @@ | ||
| 1 | /** | |
| 2 | * The `subscription_headroom` tool is POST /settings/subscriptions/:id/headroom | |
| 3 | * over MCP: the same `headroomPct` and `setHeadroom`, so the same refusals. | |
| 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 { getHeadroom, HEADROOM_MAX } from "../src/core/standing.ts"; | |
| 11 | import { addSubscription } from "../src/core/subscriptions.ts"; | |
| 12 | import { createToken } from "../src/core/tokens.ts"; | |
| 13 | import { createUser } 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 | describe("MCP subscription_headroom", () => { | |
| 20 | let dataDir = ""; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | let admin = ""; | |
| 24 | let bob = ""; | |
| 25 | let sub = ""; | |
| 26 | let id = 1; | |
| 27 | ||
| 28 | const call = async (token: string, args: unknown) => { | |
| 29 | const res = await fetch(`${servers.url}/mcp`, { | |
| 30 | method: "POST", | |
| 31 | headers: { | |
| 32 | "content-type": "application/json", | |
| 33 | accept: "application/json, text/event-stream", | |
| 34 | authorization: `Bearer ${token}`, | |
| 35 | }, | |
| 36 | body: JSON.stringify({ | |
| 37 | jsonrpc: "2.0", | |
| 38 | id: id++, | |
| 39 | method: "tools/call", | |
| 40 | params: { name: "subscription_headroom", arguments: args }, | |
| 41 | }), | |
| 42 | }); | |
| 43 | const body = (await res.json()) as any; | |
| 44 | // A tool the caller may not see is a JSON-RPC error, not a result. | |
| 45 | if (!body.result) return { text: JSON.stringify(body.error), error: true }; | |
| 46 | const text: string = body.result.content[0].text; | |
| 47 | return { text, error: body.result.isError === true }; | |
| 48 | }; | |
| 49 | ||
| 50 | beforeAll(async () => { | |
| 51 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-headroom-")); | |
| 52 | app = createApp({ dataDir, port: 0 }); | |
| 53 | servers = await serve(app); | |
| 54 | const mk = (username: string) => | |
| 55 | createUser(app.db, { | |
| 56 | origin: "seed", | |
| 57 | trusted: true, | |
| 58 | username, | |
| 59 | email: `${username}@forge.local`, | |
| 60 | password: "a-good-passphrase", | |
| 61 | adminIfFirst: username === "alice", | |
| 62 | }); | |
| 63 | const a = await mk("alice"); | |
| 64 | const b = await mk("bob"); | |
| 65 | if (!a.ok || !b.ok) throw new Error("users"); | |
| 66 | admin = createToken(app.db, a.value.id, "admin", "admin").token; | |
| 67 | bob = createToken(app.db, b.value.id, "admin", "admin").token; | |
| 68 | const s = addSubscription(app.db, dataDir, { name: "pool", token: "sk-ant-api03-key-for-the-mcp-headroom" }); | |
| 69 | if (!s.ok) throw new Error("sub"); | |
| 70 | sub = s.value.id; | |
| 71 | }, 30_000); | |
| 72 | ||
| 73 | afterAll(async () => { | |
| 74 | servers.stop(); | |
| 75 | app.close(); | |
| 76 | await rm(dataDir, { recursive: true, force: true }); | |
| 77 | }); | |
| 78 | ||
| 79 | test("an admin sets 30 and the page shows 30", async () => { | |
| 80 | const set = await call(admin, { id: sub, percent: 30 }); | |
| 81 | expect(set.error).toBe(false); | |
| 82 | expect(JSON.parse(set.text)).toEqual({ | |
| 83 | id: sub, | |
| 84 | percent: 30, | |
| 85 | says: "The crew now leaves 30% of each window for people.", | |
| 86 | }); | |
| 87 | expect(getHeadroom(app.db, sub)).toBe(30); | |
| 88 | const signin = await fetch(`${servers.url}/signin`, { | |
| 89 | method: "POST", | |
| 90 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 91 | redirect: "manual", | |
| 92 | }); | |
| 93 | const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 94 | const page = await (await fetch(`${servers.url}/settings/subscriptions`, { headers: { cookie } })).text(); | |
| 95 | expect(page).toMatch(new RegExp(`id="h-${sub}"[^>]*value="30"`)); | |
| 96 | }); | |
| 97 | ||
| 98 | test("101 is refused in the page's words", async () => { | |
| 99 | const over = await call(admin, { id: sub, percent: 101 }); | |
| 100 | expect(over.error).toBe(true); | |
| 101 | expect(over.text).toContain(`Headroom is a whole percent from 0 to ${HEADROOM_MAX}.`); | |
| 102 | expect(getHeadroom(app.db, sub)).toBe(30); | |
| 103 | }); | |
| 104 | ||
| 105 | test("an unknown id is refused", async () => { | |
| 106 | const none = await call(admin, { id: "nope", percent: 10 }); | |
| 107 | expect(none.error).toBe(true); | |
| 108 | expect(none.text).toContain("No such subscription."); | |
| 109 | }); | |
| 110 | ||
| 111 | test("a non-admin is refused", async () => { | |
| 112 | expect((await call(bob, { id: sub, percent: 5 })).error).toBe(true); | |
| 113 | expect(getHeadroom(app.db, sub)).toBe(30); | |
| 114 | }); | |
| 115 | }); |
test/mcp-write-scope.test.ts +1
| @@ -29,6 +29,7 @@ const ADMIN_ARGS: Record<string, Record<string, unknown>> = { | ||
| 29 | 29 | subscription_delete: { id: "u1" }, |
| 30 | 30 | subscription_add: { name: "Sneaky", token: "sk-ant-oat01-aaaaaaaaaaaaaaaaaaaa" }, |
| 31 | 31 | subscription_wake: { id: "u1" }, |
| 32 | subscription_headroom: { id: "u1", percent: 30 }, | |
| 32 | 33 | prompts: { repo: "alice/game", action: "remove", id: "p1" }, |
| 33 | 34 | telemetry_grant: { agent: "robo", on: true }, |
| 34 | 35 | hooks: { repo: "alice/game", action: "add", url: "https://example.com/h", secret: "0123456789abcdef0123" }, |
new above
offline
Nit: this accepts a string
percentlike "30", but the schema says integer. It's harmless because headroomPct still validates it, and it matches the page's text path. Just noting it.