Public: anyone can read this; the crew can change it.

MCP `subscription_budget`: set or clear a subscription's whole-life budget, with the same rules as the Subscriptions page #5454

Merged opened this

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

Merged

scout/forge:scout/5431-mcp-subscription-budget-set-or-clear-a-s → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/5431-mcp-subscription-budget-set-or-clear-a-s was merged into main as 2998c98.

Review

Sign in to review this change.
3 files changed +159 −1
docs/MCP.md +1
@@ -93,6 +93,7 @@ for a tunnel and a forge that sleeps.
9393 | `usage` | `{ repo, days? }`: what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue, and each account's 5-hour and weekly standing; a writer's | |
9494 | `labels` | `{ repo, number, labels }`: set an issue's or a pull's labels to exactly these (`[]` clears); only labels the repository has, by a writer | yes |
9595 | `repo_labels` | `{ repo, action?, name?, color?, id? }`: list (`list`, the default; a read-only token may), create (`create`, `name`, `color` six hex digits) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `DELETE …/labels/:id`; create and remove need an admin token and a repository admin | yes |
96 | `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 |
9697 | `wait_for` | `{ app, issue, blockedBy, stop? }`: the issue waits for another, or stops with `stop: true`; the page's rules, and the answer carries the issue's URL | yes |
9798 | `confirm_wait` | `{ app, issue, blocker }`: a person says `issue` may go ahead although `blocker` closed as dropped or split; the API's confirm action, so writers only and never the crew | yes |
9899 | `hold` | `{ app, pull, why?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; plans unless `dryRun: false` | yes |
src/http/mcp.ts +38 −1
@@ -49,7 +49,8 @@ import { CAPPED_WORDS } from "../actions/mail.ts";
4949 import { changeSignup, readSignup } from "../actions/signup.ts";
5050 import { SIGNUP_MODES } from "../settings/signup.ts";
5151 import { QUOTA_KEYS } from "../settings/quotas.ts";
52 import { LIMIT_KEYS } from "../settings/limits.ts";
52 import { LIMIT_KEYS, limitValue } from "../settings/limits.ts";
53 import { budgetsOf, setBudget } from "../core/subscriptions.ts";
5354 import { checkUpstreamNow, FOLLOW_REFUSALS, followJson, setFollowUpstream } from "../actions/upstream_follow.ts";
5455 import { getFollow } from "../core/upstream_follow.ts";
5556 import { convergeWords, FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeForgeCode, takeForgeWords, takeOrTell } from "../actions/follow.ts";
@@ -3757,6 +3758,42 @@ const TOOLS: readonly Tool[] = [
37573758 : bad("not_found", "no such label, or you are not this repository's admin");
37583759 },
37593760 },
3761 {
3762 name: "subscription_budget",
3763 description: "Admin: set an account's whole-life budget in dollars, or null to clear.",
3764 inputSchema: {
3765 type: "object",
3766 properties: { id: { type: "string" }, usd: { type: ["number", "null"] } },
3767 required: ["id", "usd"],
3768 },
3769 writes: true,
3770 adminOnly: true,
3771 needsAdminScope: true,

Minor, not blocking: needsAdminScope has no test of its own here. A case where alice calls with a write-scope token and is refused would keep this gate from being dropped without anyone noticing.

3772 run(ctx, _user, args) {
3773 const id = args["id"];
3774 const usd = args["usd"];
3775 if (typeof id !== "string" || id === "" || id.length > 64)
3776 return bad("invalid", "id is the account's id");
3777 if (usd !== null && (typeof usd !== "number" || !Number.isFinite(usd)))
3778 return bad("invalid", "usd is a number of dollars, or null to clear");
3779 const held = budgetsOf(ctx.app.db).get(id);
3780 if (!held) return bad("not_found", "no such account");
3781 if (held.locked && (usd === null || !(usd < (held.budgetUsd ?? 0))))
3782 return bad(
3783 "locked",
3784 "This budget was set by whoever made this forge: it can be lowered, never raised or cleared.",
3785 );
3786 const max = limitValue(ctx.app.db, "subscription.budget_max_usd");
3787 if (!setBudget(ctx.app.db, id, usd))
3788 return bad("invalid", `A budget is dollars from 1 to ${max}, or null for none.`);
3789 const budgetUsd = usd === null ? null : Math.round(usd * 100) / 100;
3790 return good({
3791 id,
3792 budgetUsd,
3793 says: budgetUsd === null ? "No budget: this account spends until you stop it." : `Budget set: $${budgetUsd} in all.`,
3794 });
3795 },
3796 },
37603797 {
37613798 name: "wait_for",
37623799 description: "Make an issue wait for another; `stop: true` ends it.",
test/mcp-subscription-budget.test.ts added +120
@@ -0,0 +1,120 @@
1 /**
2 * The `subscription_budget` tool is POST /settings/subscriptions/:id/budget
3 * over MCP: the same `setBudget`, 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 { addSubscription, budgetsOf, lockBudget } from "../src/core/subscriptions.ts";
11 import { createToken } from "../src/core/tokens.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15
16 quietLogs();
17
18 describe("MCP subscription_budget", () => {
19 let dataDir = "";
20 let app: App;
21 let servers: Servers;
22 let admin = "";
23 let bob = "";
24 let open = "";
25 let locked = "";
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_budget", 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-budget-"));
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: "open", token: "sk-ant-api03-open-key-for-the-mcp-budget" });
69 const l = addSubscription(app.db, dataDir, { name: "locked", token: "sk-ant-api03-locked-key-for-the-mcp-budget" });
70 if (!s.ok || !l.ok) throw new Error("subs");
71 open = s.value.id;
72 locked = l.value.id;
73 lockBudget(app.db, locked, 8);
74 }, 30_000);
75
76 afterAll(async () => {
77 servers.stop();
78 app.close();
79 await rm(dataDir, { recursive: true, force: true });
80 });
81
82 test("an admin sets a budget and the page shows it; clearing works when unlocked", async () => {
83 const set = await call(admin, { id: open, usd: 12.5 });
84 expect(set.error).toBe(false);
85 expect(JSON.parse(set.text)).toMatchObject({ id: open, budgetUsd: 12.5 });
86 const signin = await fetch(`${servers.url}/signin`, {
87 method: "POST",
88 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
89 redirect: "manual",
90 });
91 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
92 const page = await fetch(`${servers.url}/settings/subscriptions`, { headers: { cookie } });
93 expect(await page.text()).toContain('value="12.5"');
94 const cleared = await call(admin, { id: open, usd: null });
95 expect(cleared.error).toBe(false);
96 expect(JSON.parse(cleared.text).budgetUsd).toBeNull();
97 expect(budgetsOf(app.db).get(open)?.budgetUsd).toBeNull();
98 });
99
100 test("a value above the limit, a bad value, or an unknown id is refused", async () => {
101 const over = await call(admin, { id: open, usd: 1e9 });
102 expect(over.error).toBe(true);
103 expect(over.text).toContain("invalid");
104 expect((await call(admin, { id: open, usd: "5" })).error).toBe(true);
105 expect((await call(admin, { id: "nope", usd: 5 })).text).toContain("not_found");
106 expect(budgetsOf(app.db).get(open)?.budgetUsd).toBeNull();
107 });
108
109 test("a locked budget is lowered, never raised or cleared", async () => {
110 expect((await call(admin, { id: locked, usd: 20 })).text).toContain("locked");
111 expect((await call(admin, { id: locked, usd: null })).text).toContain("locked");
112 expect((await call(admin, { id: locked, usd: 5 })).error).toBe(false);
113 expect(budgetsOf(app.db).get(locked)?.budgetUsd).toBe(5);
114 });
115
116 test("a non-admin is refused", async () => {
117 expect((await call(bob, { id: open, usd: 5 })).error).toBe(true);
118 expect(budgetsOf(app.db).get(open)?.budgetUsd).toBeNull();
119 });
120 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs