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

MCP `economics` reads and sets the profit slider, as `GET`/`PUT /api/v1/forge/economics` do #10175

Merged opened this

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

Merged

torque/forge:torque/10159-mcp-economics-reads-and-sets-the-profit → main 4 commits fork 3 threads · 3 unresolved

  • Merged. torque/10159-mcp-economics-reads-and-sets-the-profit was merged into main as c08a3fc.

Review

Sign in to review this change.
7 files changed +189 −2
docs/features/economics.md +5
@@ -29,4 +29,9 @@ runtime's constants (`SLEEP_AFTER_MS`, `IDLE_PULL_DAYS` in
2929 `src/apps/runtime.ts`), so a forge that never moves the slider behaves as
3030 before.
3131
32 - **MCP**: the `economics` tool answers the same as `GET`; with `position`
33 and/or `marginPct` an admin token plans the change (`dryRun` is true unless
34 `false`) and then sets it, through the same action and validation.
35 Anyone else is told `not_found`, as the API does.
36
3237 An agent can never move the slider, whatever it was granted.
src/http/api/economics.ts +1 −1
@@ -13,7 +13,7 @@ import type { Router } from "../router.ts";
1313 import { accountAdmin } from "./admin.ts";
1414
1515 /** The wire shape: milliseconds and shares as numbers, `null` for never. */
16 function wire(e: EconomicsView) {
16 export function wire(e: EconomicsView) {
1717 return {
1818 position: e.position,
1919 marginPct: e.marginPct,
src/http/mcp/tools/economics.ts added +54
@@ -0,0 +1,54 @@
1 import { ECONOMICS_WORDS, changeEconomics, readEconomics } from "../../../actions/economics.ts";
2 import { parseMarginPct, parsePosition } from "../../../core/slider.ts";
3 import { wire } from "../../api/economics.ts";
4 import { scopeAllows } from "../../../core/tokens.ts";
5 import { good, bad, type Tool } from "../shared.ts";
6
7 export const tool: Tool = {
8 name: "economics",
9 doc: "`{ position?, marginPct?, dryRun? }`: with nothing, the profit slider's `position` (0 to 100), `marginPct` (0 to 500), the `policy` and its `words`, as `GET /api/v1/forge/economics` answers; with either number, set it (an admin token; a forge admin who is a person, never an agent), planned first (`dryRun` is true unless `false`); anyone else is told `not_found`",
10 description: "Read the profit slider and its policy, or set it (admin).",
11 inputSchema: {
12 type: "object",
13 properties: {
14 position: { type: "integer" },
15 marginPct: { type: "integer" },
16 dryRun: { type: "boolean" },
17 },
18 },
19 writes: true,
20 dryByDefault: true,
21 readsWithout: ["position", "marginPct"],
22 run(ctx, user, args) {
23 const { position, marginPct } = args;
24 const refuse = (error: "not_found" | "bad_position" | "bad_margin") =>
25 error === "not_found" ? bad("not_found", "Not found.") : bad("invalid", ECONOMICS_WORDS[error]);
26 const read = readEconomics(ctx.app, user);
27 if (!read.ok) return refuse(read.error);
28 if (position === undefined && marginPct === undefined) return good(wire(read.value));
outdated

This duplicates wire from src/http/api/economics.ts. Share one so the two doors keep one shape.

29 if (args["dryRun"] !== false) {
30 const before = read.value;
31 const invalid = checkOnly(position, marginPct);
32 if (invalid) return refuse(invalid);
33 const to = {
34 position: position === undefined ? before.position : position,
35 marginPct: marginPct === undefined ? before.marginPct : marginPct,
36 };
37 return good({
38 from: { position: before.position, marginPct: before.marginPct },
39 to,
outdated

Rename bad1 (for example invalid), and move it above the to construction.

40 says: "This is the plan; nothing changed. Send dryRun: false to set it.",
41 });
42 }
43 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "setting the slider needs an admin token");
44 const changed = changeEconomics(ctx.app, user, { position, marginPct });
45 return changed.ok ? good(wire(changed.value)) : refuse(changed.error);
outdated

This branch is untested: dispatch lets rw tokens through, so only a test with an rw token held by an admin proves it. Add that test.

46 },
47 };
48
49 /** The write's own validation, without the write. */
50 function checkOnly(position: unknown, marginPct: unknown): "bad_position" | "bad_margin" | undefined {
51 if (position !== undefined && parsePosition(position) === undefined) return "bad_position";
52 if (marginPct !== undefined && parseMarginPct(marginPct) === undefined) return "bad_margin";
53 return undefined;
54 }
src/http/mcp/tools/index.ts +1
@@ -49,6 +49,7 @@ export { tool as crew } from "./crew.ts";
4949 export { tool as decisions_by } from "./decisions_by.ts";
5050 export { tool as detach } from "./detach.ts";
5151 export { tool as drills } from "./drills.ts";
52 export { tool as economics } from "./economics.ts";
5253 export { tool as edit } from "./edit.ts";
5354 export { tool as egress_grants } from "./egress_grants.ts";
5455 export { tool as email_verify } from "./email_verify.ts";
test/conversation-confirm.test.ts +1 −1
@@ -122,7 +122,7 @@ describe("the one list of what asks first", () => {
122122 const NOT_ASKED = new Set([
123123 "access", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide", "atlas",
124124 "assign", "attach_look", "bet", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait",
125 "crew", "decisions_by", "detach", "edit", "email_verify", "feed_chips", "feed_dismiss", "flag_set", "flag_winner",
125 "crew", "decisions_by", "detach", "economics", "edit", "email_verify", "feed_chips", "feed_dismiss", "flag_set", "flag_winner",
126126 "follow_mode", "fork", "front_page", "hold", "issue_teams", "keep_plan", "labels", "lift", "limb", "maintenance", "make_it_yours", "map", "merge", "new_app",
127127 "next_up", "week", "notify", "open_pull", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports",
128128 "restore_drill", "review", "say", "signup", "snapshot_now", "spend", "steer", "sync", "take_back", "take_updates",
test/mcp-economics.test.ts added +126
@@ -0,0 +1,126 @@
1 /** MCP `economics`: the slider from a harness, under the API's rules. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createAgent } from "../src/actions/crew.ts";
8 import { getEconomics } from "../src/settings/economics.ts";
9 import { createToken } from "../src/core/tokens.ts";
10 import { createUser } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13
14 quietLogs();
15
16 describe("MCP economics", () => {
17 let dataDir: string;
18 let app: App;
19 let servers: Servers;
20 let admin = "";
21 let adminRo = "";
22 let bob = "";
23 let adminRw = "";
24 let agent = "";
25 let id = 1;
26
27 const call = async (token: string, args: 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: "tools/call", params: { name: "economics", arguments: args } }),
36 });
37 const body = (await res.json()) as any;
38 return { text: body.result.content[0].text as string, error: body.result.isError === true };
39 };
40
41 beforeAll(async () => {
42 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-econ-"));
43 app = createApp({ dataDir, port: 0 });
44 servers = await serve(app);
45 const mk = async (username: string, adminIfFirst: boolean) => {
46 const u = await createUser(app.db, {
47 origin: "seed", username, email: `${username}@forge.local`,
48 password: "a-good-passphrase", trusted: true, adminIfFirst,
49 });
50 if (!u.ok) throw new Error(u.error);
51 return u.value;
52 };
53 const a = await mk("alice", true);
54 const b = await mk("bob", false);
55 admin = createToken(app.db, a.id, "admin", "admin").token;
56 adminRo = createToken(app.db, a.id, "ro", "ro").token;
57 adminRw = createToken(app.db, a.id, "rw", "rw").token;
58 const made = await createAgent(app, a, { username: "crew-a", budgetUsd: 1 });
59 if (!made.ok) throw new Error(String(made.error));
60 app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(made.value.user.id);
61 agent = createToken(app.db, made.value.user.id, "agent", "admin").token;
62 bob = createToken(app.db, b.id, "admin", "admin").token;
63 });
64 afterAll(async () => {
65 await servers.stop();
66 await rm(dataDir, { recursive: true, force: true });
67 });
68
69 test("with nothing it answers the position and the policy in words", async () => {
70 const r = await call(adminRo, {});
71 expect(r.error).toBe(false);
72 const j = JSON.parse(r.text);
73 expect(j.position).toBe(getEconomics(app.db).position);
74 expect(typeof j.words).toBe("string");
75 expect(j.policy).toBeDefined();
76 });
77
78 test("a position is planned first and changes nothing", async () => {
79 const before = getEconomics(app.db).position;
80 const r = await call(admin, { position: 70 });
81 expect(r.error).toBe(false);
82 expect(JSON.parse(r.text).to.position).toBe(70);
83 expect(getEconomics(app.db).position).toBe(before);
84 const badPlan = await call(admin, { position: 101 });
85 expect(badPlan.error).toBe(true);
86 expect(badPlan.text).toContain("position is a whole number from 0 to 100");
87 });
88
89 test("dryRun false sets it, and the read agrees", async () => {
90 const r = await call(admin, { position: 70, marginPct: 15, dryRun: false });
91 expect(r.error).toBe(false);
92 expect(JSON.parse(r.text).position).toBe(70);
93 expect(getEconomics(app.db)).toMatchObject({ position: 70, marginPct: 15 });
94 expect(JSON.parse((await call(admin, {})).text).position).toBe(70);
95 });
96
97 test("a read-only token cannot set it", async () => {
98 const r = await call(adminRo, { position: 10, dryRun: false });
99 expect(r.error).toBe(true);
100 expect(getEconomics(app.db).position).toBe(70);
101 });
102
103 test("a non-admin is told not found, to read or to set", async () => {
104 expect((await call(bob, {})).error).toBe(true);
105 const r = await call(bob, { position: 5, dryRun: false });
106 expect(r.error).toBe(true);
107 expect(getEconomics(app.db).position).toBe(70);
108 });
109
110 test("an admin agent token is told not found, to read or to set", async () => {
111 const read = await call(agent, {});
112 expect(read.error).toBe(true);
113 expect(read.text).toContain("not_found");
114 const set = await call(agent, { position: 5, dryRun: false });
115 expect(set.error).toBe(true);
116 expect(set.text).toContain("not_found");
117 expect(getEconomics(app.db).position).toBe(70);
118 });
119
120 test("an rw token held by an admin is forbidden to set it", async () => {
121 const r = await call(adminRw, { position: 5, dryRun: false });
122 expect(r.error).toBe(true);
123 expect(r.text).toContain("forbidden");
124 expect(getEconomics(app.db).position).toBe(70);
125 });
126 });
test/mcp.test.ts +1
@@ -290,6 +290,7 @@ describe("the forge over MCP", () => {
290290 "sync",
291291 "sever",
292292 "flag_set",
293 "economics",
293294 "follow_mode",
294295 "fork",
295296 "forks",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs