Public: anyone can read this; the crew can change it.
MCP `new_app`: make an app from a sentence and get back its URL, its first issue and who is on it #3677
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3671-mcp-new-app-make-an-app-from-a-sentence → main 2 commits fork 4 threads · 4 unresolved
docs/MCP.md +1
| @@ -28,6 +28,7 @@ for a tunnel and a forge that sleeps. | ||
| 28 | 28 | | tool | does | writes | |
| 29 | 29 | | -------------- | ------------------------------------------------------------------------------------------------- | ------ | |
| 30 | 30 | | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | | |
| 31 | | `new_app` | `{ name, description?, owner?, visibility?, agent?, pick? }`: make an app from a sentence; answers `{ repo, url, issue, agent }`; refused as `POST /api/v1/apps` is (unverified, app_limit, no_such_agent, name_taken) | yes | | |
| 31 | 32 | | `repo` | `{ repo }`: one repository's description, visibility, autonomy and `audience { level, sentence }`, the same as `GET /api/v1/repos/:owner/:name` | | |
| 32 | 33 | | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes | |
| 33 | 34 | | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes | |
src/http/mcp.ts +60 −1
| @@ -133,7 +133,10 @@ import { | ||
| 133 | 133 | directionNote, |
| 134 | 134 | OUTSIDERS_NOTE, |
| 135 | 135 | } from "../crew/direct.ts"; |
| 136 | import { setArchived, setCrewOutsiders, setDecisionsBy } from "../actions/repos.ts"; | |
| 136 | import { setArchived, setCrewOutsiders, setDecisionsBy, repositoryLimitMessage } from "../actions/repos.ts"; | |
| 137 | import { newApp } from "../actions/apps.ts"; | |
| 138 | import { appLimitWords } from "../actions/app_limit.ts"; | |
| 139 | import { storageLimitMessage } from "../core/storage.ts"; | |
| 137 | 140 | import { mapJson, marksFor } from "../web/pages/map.ts"; |
| 138 | 141 | import { applyFleetIds, previewFleetIds, undoFleetOp } from "../actions/fleet.ts"; |
| 139 | 142 | import { applyLimb, isLimbAction, previewLimb } from "../actions/limbs.ts"; |
| @@ -428,6 +431,62 @@ const TOOLS: readonly Tool[] = [ | ||
| 428 | 431 | ); |
| 429 | 432 | }, |
| 430 | 433 | }, |
| 434 | { | |
| 435 | name: "new_app", | |
| 436 | description: | |
| 437 | "Make an app from a sentence: a repository with a working app in it, production started, and — with `description` — the first issue, on the agent you name (`agent`) or the one the forge picks (`pick`). Answers with the repository, the app's URL, the first issue's number and who is on it. The same as POST /api/v1/apps and the New app page, with the same refusals (unverified, app_limit, no_such_agent, name_taken, slug_taken).", | |
| 438 | inputSchema: { | |
| 439 | type: "object", | |
| 440 | properties: { | |
| 441 | name: { type: "string", maxLength: 100, description: "The app's name." }, | |
| 442 | description: { type: "string", maxLength: 4000, description: "What it should become; opens the first issue." }, | |
| 443 | owner: { type: "string", maxLength: 39, description: "You, or an organisation you can write to." }, | |
| 444 | visibility: { type: "string", enum: ["public", "private"], description: "Private unless you say public." }, | |
| 445 | agent: { type: "string", maxLength: 39, description: "The agent to put on the crew and hand the first issue to." }, | |
| 446 | pick: { type: "boolean", description: "Let the forge pick the agent from the description, when `agent` is not given." }, | |
| 447 | }, | |
| 448 | required: ["name"], | |
| 449 | }, | |
| 450 | writes: true, | |
| 451 | async run(ctx, user, args) { | |
| 452 | const text = (k: string) => (typeof args[k] === "string" ? (args[k] as string) : undefined); | |
| 453 | const visibility = args["visibility"]; | |
| 454 | const owner = text("owner"); | |
| 455 | const description = text("description"); | |
| 456 | const agent = text("agent"); | |
| 457 | const made = await newApp(ctx.app, user, { | |
| 458 | name: str(args["name"]), | |
| 459 | ...(owner === undefined ? {} : { owner }), | |
| 460 | ...(description === undefined ? {} : { description }), | |
| 461 | ...(visibility === "public" || visibility === "private" ? { visibility } : {}), | |
| 462 | ...(agent === undefined ? {} : { agent }), | |
| 463 | ...(args["pick"] === true ? { pickAgent: true } : {}), | |
| 464 | }); | |
| 465 | if (!made.ok) { | |
| 466 | switch (made.error) { | |
| 467 | case "unverified": | |
| 468 | return bad("unverified", "verify your email first"); | |
| 469 | case "app_limit": { | |
| 470 | const who = getUserByName(ctx.app.db, owner || user.username); | |
| 471 | return bad("app_limit", who ? appLimitWords(ctx.app, who.id) : "App limit reached."); | |
| 472 | } | |
| 473 | case "repository_limit": | |
| 474 | return bad("repository_limit", repositoryLimitMessage(ctx.app, user)); | |
| 475 | case "storage_limit": | |
| 476 | return bad("storage_limit", storageLimitMessage(ctx.app, user.id)); | |
| 477 | default: | |
| 478 | return bad(made.error, made.error); | |
Nit: the fallthrough sends the bare code twice ( |
||
| 479 | } | |
| 480 | } | |
| 481 | const { repo, app: record, issue } = made.value; | |
| 482 | return good({ | |
| 483 | repo: `${repo.ownerName}/${repo.name}`, | |
| 484 | url: appsRuntime(ctx.app)?.urlFor(record, "production") ?? null, | |
| 485 | issue: issue === undefined ? null : issue.number, | |
| 486 | agent: issue?.assigneeName ?? null, | |
| 487 | }); | |
| 488 | }, | |
| 489 | }, | |
| 431 | 490 | { |
| 432 | 491 | name: "repo", |
| 433 | 492 | description: |
test/mcp.test.ts +60
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * not do here. |
| 6 | 6 | */ |
| 7 | 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | import { chmodSync, writeFileSync } from "node:fs"; | |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| 10 | 11 | import { join } from "node:path"; |
| @@ -166,6 +167,7 @@ describe("the forge over MCP", () => { | ||
| 166 | 167 | expect(names).toEqual([ |
| 167 | 168 | "sizes", |
| 168 | 169 | "apps", |
| 170 | "new_app", | |
| 169 | 171 | "repo", |
| 170 | 172 | "here", |
| 171 | 173 | "tell", |
| @@ -1070,4 +1072,62 @@ describe("the forge over MCP", () => { | ||
| 1070 | 1072 | expect(cleared.value).toEqual({ username: "ceilee", dailyUsd: null, perRunUsd: null }); |
| 1071 | 1073 | expect(getAccountCeiling(app.db, id)).toBeUndefined(); |
| 1072 | 1074 | }); |
| 1075 | ||
| 1076 | test("new_app makes an app from a sentence and apps reads it back", async () => { | |
| 1077 | const bot = await createAgent(app, alice, { username: "newappbot", brief: "builds" }); | |
| 1078 | if (!bot.ok) throw new Error(String(bot.error)); | |
| 1079 | const made = (await tool(rw, "new_app", { | |
| 1080 | name: "sentence", | |
| 1081 | description: "a page that says hello", | |
| 1082 | agent: "newappbot", | |
| 1083 | })) as any; | |
| 1084 | expect(made.value.repo).toBe("alice/sentence"); | |
| 1085 | expect(made.value.issue).toBe(1); | |
| 1086 | expect(made.value.agent).toBe("newappbot"); | |
| 1087 | const seen = (await tool(rw, "apps")) as any; | |
| 1088 | const row = seen.value.find((r: any) => r.app === "alice/sentence"); | |
outdated
When the test forge has no app runtime, this compares null with null. If the runtime is present, also assert |
||
| 1089 | expect(row).toBeDefined(); | |
| 1090 | expect(row.url).toBe(made.value.url); | |
| 1091 | // The API's refusals, in its words. | |
| 1092 | expect((await tool(rw, "new_app", { name: "sentence" })).error).toContain("name_taken"); | |
| 1093 | expect((await tool(rw, "new_app", { name: "other", agent: "nobody-here" })).error).toContain("no_such_agent"); | |
| 1094 | // Without the rw scope nothing is made. | |
| 1095 | const denied = await tool(ro, "new_app", { name: "readonly" }); | |
| 1096 | expect(denied.error).toContain("forbidden"); | |
| 1097 | expect((await tool(rw, "apps") as any).value.some((r: any) => r.app === "alice/readonly")).toBe(false); | |
| 1098 | }, 30_000); | |
| 1099 | ||
| 1100 | test("new_app with pick lets the planner name the agent", async () => { | |
| 1101 | const picked = await createAgent(app, alice, { username: "pickbot", brief: "builds" }); | |
| 1102 | if (!picked.ok) throw new Error(String(picked.error)); | |
| 1103 | const dir = await mkdtemp(join(tmpdir(), "forge-mcp-pick-")); | |
| 1104 | const fake = join(dir, "fake-claude"); | |
| 1105 | writeFileSync( | |
| 1106 | fake, | |
| 1107 | `#!/bin/sh | |
| 1108 | if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi | |
| 1109 | printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"checklist\\":[\\"One\\"],\\"agent\\":\\"pickbot\\",\\"confidence\\":0.8}"}' | |
| 1110 | `, | |
| 1111 | ); | |
| 1112 | chmodSync(fake, 0o755); | |
| 1113 | const real = process.env["FORGE_CLAUDE"]; | |
| 1114 | process.env["FORGE_CLAUDE"] = fake; | |
| 1115 | try { | |
| 1116 | const made = (await tool(rw, "new_app", { | |
| 1117 | name: "picked", | |
| 1118 | description: "a page that says hello", | |
| 1119 | pick: true, | |
| 1120 | })) as any; | |
| 1121 | expect(made.error).toBeUndefined(); | |
| 1122 | expect(made.value.agent).toBe("pickbot"); | |
| 1123 | expect(typeof made.value.issue).toBe("number"); | |
| 1124 | const row = ((await tool(rw, "apps")) as any).value.find((r: any) => r.app === "alice/picked"); | |
| 1125 | expect(row.url).toBe(made.value.url); | |
| 1126 | if (row.url !== null) expect(typeof made.value.url).toBe("string"); | |
Nit: this assertion is conditional, so in a test forge without an app runtime it checks nothing. The |
||
| 1127 | } finally { | |
| 1128 | if (real === undefined) delete process.env["FORGE_CLAUDE"]; | |
| 1129 | else process.env["FORGE_CLAUDE"] = real; | |
| 1130 | await rm(dir, { recursive: true, force: true }); | |
| 1131 | } | |
| 1132 | }, 30_000); | |
| 1073 | 1133 | }); |
No test covers this mapping. Add a
new_apptest withpick: trueand a fakeFORGE_CLAUDE, the waytest/apps-new.test.tsdoes, and assert the picked agent comes back inagent.