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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10159-mcp-economics-reads-and-sets-the-profit → main 4 commits fork 3 threads · 3 unresolved
7 files
changed +189
−2
docs/features/economics.md +5
| @@ -29,4 +29,9 @@ runtime's constants (`SLEEP_AFTER_MS`, `IDLE_PULL_DAYS` in | ||
| 29 | 29 | `src/apps/runtime.ts`), so a forge that never moves the slider behaves as |
| 30 | 30 | before. |
| 31 | 31 | |
| 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 | ||
| 32 | 37 | 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"; | ||
| 13 | 13 | import { accountAdmin } from "./admin.ts"; |
| 14 | 14 | |
| 15 | 15 | /** The wire shape: milliseconds and shares as numbers, `null` for never. */ |
| 16 | function wire(e: EconomicsView) { | |
| 16 | export function wire(e: EconomicsView) { | |
| 17 | 17 | return { |
| 18 | 18 | position: e.position, |
| 19 | 19 | 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)); | |
| 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 |
||
| 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 |
||
| 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"; | ||
| 49 | 49 | export { tool as decisions_by } from "./decisions_by.ts"; |
| 50 | 50 | export { tool as detach } from "./detach.ts"; |
| 51 | 51 | export { tool as drills } from "./drills.ts"; |
| 52 | export { tool as economics } from "./economics.ts"; | |
| 52 | 53 | export { tool as edit } from "./edit.ts"; |
| 53 | 54 | export { tool as egress_grants } from "./egress_grants.ts"; |
| 54 | 55 | 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", () => { | ||
| 122 | 122 | const NOT_ASKED = new Set([ |
| 123 | 123 | "access", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide", "atlas", |
| 124 | 124 | "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", | |
| 126 | 126 | "follow_mode", "fork", "front_page", "hold", "issue_teams", "keep_plan", "labels", "lift", "limb", "maintenance", "make_it_yours", "map", "merge", "new_app", |
| 127 | 127 | "next_up", "week", "notify", "open_pull", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports", |
| 128 | 128 | "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", () => { | ||
| 290 | 290 | "sync", |
| 291 | 291 | "sever", |
| 292 | 292 | "flag_set", |
| 293 | "economics", | |
| 293 | 294 | "follow_mode", |
| 294 | 295 | "fork", |
| 295 | 296 | "forks", |
This duplicates
wirefrom src/http/api/economics.ts. Share one so the two doors keep one shape.