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

Merged opened this

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

Merged

scout/forge:scout/3671-mcp-new-app-make-an-app-from-a-sentence → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/3671-mcp-new-app-make-an-app-from-a-sentence was merged into main as 5462300.

Review

Sign in to review this change.
3 files changed +121 −1
docs/MCP.md +1
@@ -28,6 +28,7 @@ for a tunnel and a forge that sleeps.
2828 | tool | does | writes |
2929 | -------------- | ------------------------------------------------------------------------------------------------- | ------ |
3030 | `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 |
3132 | `repo` | `{ repo }`: one repository's description, visibility, autonomy and `audience { level, sentence }`, the same as `GET /api/v1/repos/:owner/:name` | |
3233 | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes |
3334 | `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 {
133133 directionNote,
134134 OUTSIDERS_NOTE,
135135 } 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";
137140 import { mapJson, marksFor } from "../web/pages/map.ts";
138141 import { applyFleetIds, previewFleetIds, undoFleetOp } from "../actions/fleet.ts";
139142 import { applyLimb, isLimbAction, previewLimb } from "../actions/limbs.ts";
@@ -428,6 +431,62 @@ const TOOLS: readonly Tool[] = [
428431 );
429432 },
430433 },
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 } : {}),

No test covers this mapping. Add a new_app test with pick: true and a fake FORGE_CLAUDE, the way test/apps-new.test.ts does, and assert the picked agent comes back in agent.

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 (forbidden_owner: forbidden_owner, scaffold_failed: scaffold_failed). A short sentence for forbidden_owner ("you cannot make apps under that owner") would read better to an agent. The tool description also names slug_taken, but the docs/MCP.md row doesn't, so the two should agree.

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 },
431490 {
432491 name: "repo",
433492 description:
test/mcp.test.ts +60
@@ -5,6 +5,7 @@
55 * not do here.
66 */
77 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { chmodSync, writeFileSync } from "node:fs";
89 import { mkdtemp, rm } from "node:fs/promises";
910 import { tmpdir } from "node:os";
1011 import { join } from "node:path";
@@ -166,6 +167,7 @@ describe("the forge over MCP", () => {
166167 expect(names).toEqual([
167168 "sizes",
168169 "apps",
170 "new_app",
169171 "repo",
170172 "here",
171173 "tell",
@@ -1070,4 +1072,62 @@ describe("the forge over MCP", () => {
10701072 expect(cleared.value).toEqual({ username: "ceilee", dailyUsd: null, perRunUsd: null });
10711073 expect(getAccountCeiling(app.db, id)).toBeUndefined();
10721074 });
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 typeof made.value.url === "string" so the URL in the answer is actually tested.

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 row.url === made.value.url line above already covers the round trip, so this line can go, or it can assert the null case explicitly.

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);
10731133 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs