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

Merged opened this

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

Merged

torque/forge:torque/5439-mcp-subscription-headroom-set-how-much-o → main 1 commit fork 1 thread · 1 unresolved

  • Merged. torque/5439-mcp-subscription-headroom-set-how-much-o was merged into main as 46f9cdd.

Review

Sign in to review this change.
5 files changed +147 −0
docs/MCP.md +1
@@ -166,6 +166,7 @@ for a tunnel and a forge that sleeps.
166166 | `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 |
167167 | `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 |
168168 | `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 |
169170 | `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 |
170171 | `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 | |
171172 | `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";
135135 export { tool as subscription_add } from "./subscription_add.ts";
136136 export { tool as subscription_budget } from "./subscription_budget.ts";
137137 export { tool as subscription_delete } from "./subscription_delete.ts";
138 export { tool as subscription_headroom } from "./subscription_headroom.ts";
138139 export { tool as subscription_wake } from "./subscription_wake.ts";
139140 export { tool as subscriptions } from "./subscriptions.ts";
140141 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 =

Nit: this accepts a string percent like "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.

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>> = {
2929 subscription_delete: { id: "u1" },
3030 subscription_add: { name: "Sneaky", token: "sk-ant-oat01-aaaaaaaaaaaaaaaaaaaa" },
3131 subscription_wake: { id: "u1" },
32 subscription_headroom: { id: "u1", percent: 30 },
3233 prompts: { repo: "alice/game", action: "remove", id: "p1" },
3334 telemetry_grant: { agent: "robo", on: true },
3435 hooks: { repo: "alice/game", action: "add", url: "https://example.com/h", secret: "0123456789abcdef0123" },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs