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

MCP `new_app` makes an app from a sentence alone: the name is derived as the web and terminal do #8437

Merged opened this

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

Merged

scout/forge:scout/8426-mcp-new-app-makes-an-app-from-a-sentence → main 4 commits fork

  • Merged. scout/8426-mcp-new-app-makes-an-app-from-a-sentence was merged into main as 1db7070.

Review

Sign in to review this change.
3 files changed +57 −33
src/actions/apps.ts +5 −5
@@ -461,8 +461,8 @@ export const NEW_APP_ERRORS: Record<string, string> = {
461461 no_such_agent: NO_SUCH_AGENT,
462462 };
463463
464 export interface NewAppFromSentenceInput extends Omit<NewAppInput, "name" | "description"> {
465 /** The sentence the app is named from; it is also the first issue's description. */
464 export interface NewAppFromSentenceInput extends Omit<NewAppInput, "name"> {
465 /** The sentence the app is named from; it is also the first issue's description unless `description` is given. */
466466 sentence: string;
467467 }
468468
@@ -478,16 +478,16 @@ export type NewAppFromSentence =
478478 export async function newAppFromSentence(
479479 app: App,
480480 actor: User | undefined,
481 { sentence, ...rest }: NewAppFromSentenceInput,
481 { sentence, description, ...rest }: NewAppFromSentenceInput,
482482 onPhase?: (name: string, ms: number) => void,
483483 ): Promise<NewAppFromSentence> {
484484 const base = deriveAppName(sentence);
485485 if (base === undefined) return { ok: false, code: "no_name" };
486486 let name = base;
487 let made = await newApp(app, actor, { ...rest, description: sentence, name }, onPhase);
487 let made = await newApp(app, actor, { ...rest, description: description ?? sentence, name }, onPhase);
488488 for (let n = 2; !made.ok && made.error === "name_taken" && n <= MAX_NAME_ATTEMPTS; n++) {
489489 name = `${base}-${n}`;
490 made = await newApp(app, actor, { ...rest, description: sentence, name }, onPhase);
490 made = await newApp(app, actor, { ...rest, description: description ?? sentence, name }, onPhase);
491491 }
492492 return made.ok ? { ok: true, name, ...made.value } : { ok: false, code: made.error };
493493 }
src/http/mcp/tools/new_app.ts +25 −20
@@ -2,22 +2,31 @@ import { appsRuntime } from "../../../apps/runtime.ts";
22 import { listActiveRuns } from "../../../core/runs.ts";
33 import { getUserByName } from "../../../core/users.ts";
44 import { repositoryLimitMessage } from "../../../actions/repos.ts";
5 import { NO_NAME_ERROR, newApp, newAppFromSentence, type NewAppInput, type NewAppResult } from "../../../actions/apps.ts";
5 import { NO_NAME_ERROR, newApp, newAppFromSentence, type NewAppInput } from "../../../actions/apps.ts";
66 import { appLimitWords } from "../../../actions/app_limit.ts";
77 import { storageLimitMessage } from "../../../core/storage.ts";
88 import { VERIFY_FIRST } from "../../../actions/verify_words.ts";
9 import { err, ok } from "../../../kernel/result.ts";
10 import type { User } from "../../../core/users.ts";
11 import type { App } from "../../../app.ts";
912 import { good, bad, type Tool, str } from "../shared.ts";
1013
14 /** `newAppFromSentence`, answered in `newApp`'s shape so one switch maps both paths' refusals. */
15 async function fromSentence(app: App, user: User, sentence: string, input: Omit<NewAppInput, "name">) {
16 const derived = await newAppFromSentence(app, user, { ...input, sentence });
17 return derived.ok ? ok(derived) : err(derived.code);
18 }
19
1120 export const tool: Tool = {
1221 name: "new_app",
13 doc: "`{ name?, sentence?, description?, owner?, visibility?, agent?, pick? }`: make an app from a sentence; `name` wins when both are given, and `sentence` alone derives the name (`no_name` when nothing is left to name from); answers `{ repo, url, issue, agent, run }` (`run` is the first run's state, or `none`) and draws the `ui://forge/app` card: name, production URL, first issue, agent, first run; refused as `POST /api/v1/apps` is (unverified, app_limit, no_such_agent, name_taken)",
22 doc: "`{ name?, sentence?, description?, owner?, visibility?, agent?, pick? }`: make an app from a sentence: with `sentence` and no `name` the name is derived from it (`-2`, `-3` when taken), as the web and terminal do; `name` and `sentence` together keep the name; the first issue is `description`, else `sentence`; answers `{ repo, url, issue, agent, run }` (`run` is the first run's state, or `none`) and draws the `ui://forge/app` card: name, production URL, first issue, agent, first run; refused as `POST /api/v1/apps` is (unverified, app_limit, no_such_agent, name_taken)",
1423 description:
1524 "Make an app from a sentence: a repo, production, and with `description` a first issue.",
1625 inputSchema: {
1726 type: "object",
1827 properties: {
1928 name: { type: "string", maxLength: 100 },
20 sentence: { type: "string", maxLength: 4000, description: "Names the app when `name` is absent." },
29 sentence: { type: "string", maxLength: 4000 },
2130 description: { type: "string", maxLength: 4000, description: "The first issue." },
2231 owner: { type: "string", maxLength: 39 },
2332 visibility: { type: "string", enum: ["public", "private"] },
@@ -32,26 +41,24 @@ export const tool: Tool = {
3241 const owner = text("owner");
3342 const description = text("description");
3443 const agent = text("agent");
35 const options: Omit<NewAppInput, "name" | "description"> = {
44 const sentence = text("sentence");
45 const rest: Omit<NewAppInput, "name" | "description"> = {
3646 ...(owner === undefined ? {} : { owner }),
3747 ...(visibility === "public" || visibility === "private" ? { visibility } : {}),
3848 ...(agent === undefined ? {} : { agent }),
3949 ...(args["pick"] === true ? { pickAgent: true } : {}),
4050 };
41 const sentence = text("sentence");
42 let made: { ok: true; value: NewAppResult } | { ok: false; error: string };
43 if (str(args["name"]) === "" && sentence !== undefined) {
44 const r = await newAppFromSentence(ctx.app, user, { ...options, sentence: str(sentence) });
45 made = r.ok ? { ok: true, value: r } : { ok: false, error: r.code };
46 } else {
47 made = await newApp(ctx.app, user, {
48 name: str(args["name"]),
49 ...(description === undefined ? {} : { description }),
50 ...options,
51 });
52 }
51 const given = str(args["name"]).trim();
52 const first = description ?? sentence;
53 const input = { ...rest, ...(first === undefined ? {} : { description: first }) };
54 const made =
55 given !== ""
56 ? await newApp(ctx.app, user, { ...input, name: given })
57 : await fromSentence(ctx.app, user, sentence ?? "", input);
5358 if (!made.ok) {
5459 switch (made.error) {
60 case "no_name":
61 return bad("no_name", NO_NAME_ERROR);
5562 case "unverified":
5663 return bad("unverified", VERIFY_FIRST);
5764 case "app_limit": {
@@ -62,20 +69,18 @@ export const tool: Tool = {
6269 return bad("repository_limit", repositoryLimitMessage(ctx.app, user));
6370 case "storage_limit":
6471 return bad("storage_limit", storageLimitMessage(ctx.app, user.id));
65 case "no_name":
66 return bad("no_name", NO_NAME_ERROR);
6772 default:
6873 return bad(made.error, made.error);
6974 }
7075 }
7176 const { repo, app: record, issue } = made.value;
72 const first = listActiveRuns(ctx.app.db, [repo.id])[0];
77 const run = listActiveRuns(ctx.app.db, [repo.id])[0];
7378 return good({
7479 repo: `${repo.ownerName}/${repo.name}`,
7580 url: appsRuntime(ctx.app)?.urlFor(record, "production") ?? null,
7681 issue: issue === undefined ? null : issue.number,
7782 agent: issue?.assigneeName ?? null,
78 run: first?.state ?? "none",
83 run: run?.state ?? "none",
7984 });
8085 },
8186 };
test/mcp.test.ts +27 −8
@@ -20,7 +20,7 @@ import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
2020 import { createApp as insertApp } from "../src/core/apps.ts";
2121 import { addComment, createIssue, listIssues } from "../src/core/issues.ts";
2222 import { addDependency } from "../src/core/dependencies.ts";
23 import { getRepoById, updateRepoSettings, type Repo } from "../src/core/repos.ts";
23 import { getRepo, getRepoById, updateRepoSettings, type Repo } from "../src/core/repos.ts";
2424 import { createRun, listRuns, setRunBase } from "../src/core/runs.ts";
2525 import { getAccountCeiling } from "../src/core/account_ceiling.ts";
2626 import { createToken } from "../src/core/tokens.ts";
@@ -1658,14 +1658,33 @@ describe("the forge over MCP", () => {
16581658 expect((await tool(rw, "apps") as any).value.some((r: any) => r.app === "alice/readonly")).toBe(false);
16591659 }, 30_000);
16601660
1661 test("new_app with a sentence and no name derives the name; name wins when both are given", async () => {
1662 const first = (await tool(rw, "new_app", { sentence: "Keep notes" })) as any;
1663 expect(first.error).toBeUndefined();
1664 const again = (await tool(rw, "new_app", { sentence: "Keep notes" })) as any;
1665 expect(again.value.repo).toBe(`${first.value.repo}-2`);
1666 const named = (await tool(rw, "new_app", { name: "wins", sentence: "Something else" })) as any;
1667 expect(named.value.repo).toBe("alice/wins");
1661 test("new_app names an app from a sentence alone, and -2 when taken", async () => {
1662 const one = (await tool(rw, "new_app", { sentence: "a garden planner for my allotment" })) as any;
1663 expect(one.value.repo).toMatch(/^alice\/[a-z0-9-]+$/);
1664 expect(one.value.issue).toBe(1);
1665 const two = (await tool(rw, "new_app", { sentence: "a garden planner for my allotment" })) as any;
1666 expect(two.value.repo).toBe(`${one.value.repo}-2`);
1667 const named = (await tool(rw, "new_app", { name: "keepname", sentence: "a garden planner" })) as any;
1668 expect(named.value.repo).toBe("alice/keepname");
1669 expect(named.value.issue).toBe(1);
1670 // The derived path forwards visibility and agent, and `description` is the first issue over `sentence`.
1671 const bot = await createAgent(app, alice, { username: "sentbot", brief: "builds" });
1672 if (!bot.ok) throw new Error(String(bot.error));
1673 const fwd = (await tool(rw, "new_app", {
1674 sentence: "a recipe box for the family",
1675 description: "Start with a list of recipes.",
1676 visibility: "private",
1677 agent: "sentbot",
1678 })) as any;
1679 expect(fwd.value.agent).toBe("sentbot");
1680 const [slug] = fwd.value.repo.split("/").slice(1);
1681 const made = getRepo(app.db, "alice", slug)!;
1682 expect(made.visibility).toBe("private");
1683 expect(listIssues(app.db, made.id, { state: "open" })[0]!.body).toContain("Start with a list of recipes.");
1684 // A refusal maps as on the named path.
1685 expect((await tool(rw, "new_app", { sentence: "a tide table", agent: "nobody-here" })).error).toContain("no_such_agent");
16681686 expect((await tool(rw, "new_app", { sentence: "!!!" })).error).toContain("no_name");
1687 expect((await tool(rw, "new_app", {})).error).toContain("no_name");
16691688 }, 30_000);
16701689
16711690 test("new_app with pick lets the planner name the agent", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs