Public: anyone can read this; the crew can change it.
MCP `app_deploy`: redeploy or stop an app's production or a preview, with the same rules as the API #5709
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4755-mcp-app-deploy-redeploy-or-stop-an-app-s → main 8 commits fork 1 thread · 1 unresolved
5 files
changed +165
−1
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 | | `app_deploy` | `{ repo, env, action }`: `redeploy` or `stop` an app's `production` or a pull's `pull-N` preview; a writer's, queued as `POST …/app/:env/redeploy` and `…/stop` are; answers `{ queued, run }` or `{ stopped }` | yes | | |
| 31 | 32 | | `new_app` | `{ name, description?, owner?, visibility?, agent?, pick? }`: make an app from a 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) | yes | |
| 32 | 33 | | `repo` | `{ repo }`: one repository's description, visibility, autonomy, `audience { level, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name` | | |
| 33 | 34 | | `repo_settings` | `{ repo, description?, visibility? }` (admin token): change a repository's description and visibility as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility }`; a visibility other than public or private is `invalid` | yes | |
src/http/mcp.ts +34
| @@ -129,6 +129,7 @@ import { | ||
| 129 | 129 | } from "../feed/feed.ts"; |
| 130 | 130 | import { share } from "../feed/share.ts"; |
| 131 | 131 | import { parseTargets } from "./feed.ts"; |
| 132 | import { redeployEnv, stopEnv } from "../actions/apps.ts"; | |
| 132 | 133 | import { appsRuntime, envsFor } from "../apps/runtime.ts"; |
| 133 | 134 | import { |
| 134 | 135 | CLOSE_REASONS, |
| @@ -511,6 +512,39 @@ const TOOLS: readonly Tool[] = [ | ||
| 511 | 512 | ); |
| 512 | 513 | }, |
| 513 | 514 | }, |
| 515 | { | |
| 516 | name: "app_deploy", | |
| 517 | description: | |
| 518 | "Redeploy or stop production or a pull-N preview.", | |
| 519 | inputSchema: { | |
| 520 | type: "object", | |
| 521 | properties: { | |
| 522 | repo: REPO_ARG, | |
| 523 | env: { type: "string" }, | |
| 524 | action: { type: "string", enum: ["redeploy", "stop"] }, | |
| 525 | }, | |
| 526 | required: ["repo", "env", "action"], | |
| 527 | }, | |
| 528 | writes: true, | |
| 529 | async run(ctx, user, args) { | |
| 530 | const repo = repoArg(ctx, user, args); | |
| 531 | if (isAnswer(repo)) return repo; | |
| 532 | const env = str(args["env"]); | |
| 533 | const action = str(args["action"]); | |
| 534 | if (action !== "redeploy" && action !== "stop") | |
| 535 | return bad("invalid_action", "action is redeploy or stop"); | |
| 536 | if (action === "stop") { | |
| 537 | const stopped = await stopEnv(ctx.app, user, repo, env); | |
| 538 | return stopped.ok | |
| 539 | ? good({ stopped: true }) | |
| 540 | : bad(stopped.error, `refused: ${stopped.error}`); | |
| 541 | } | |
| 542 | const queued = await redeployEnv(ctx.app, user, repo, env); | |
| 543 | return queued.ok | |
| 544 | ? good({ queued: true, run: queued.value?.id ?? null }) | |
| 545 | : bad(queued.error, `refused: ${queued.error}`); | |
| 546 | }, | |
| 547 | }, | |
| 514 | 548 | { |
| 515 | 549 | name: "new_app", |
| 516 | 550 | description: |
test/mcp-app-deploy.test.ts added +128
| @@ -0,0 +1,128 @@ | ||
| 1 | /** | |
| 2 | * The `app_deploy` tool is the API's redeploy and stop over MCP: the same | |
| 3 | * actions, so the same gate. A writer queues and stops, a reader is refused, | |
| 4 | * and an unknown environment is refused. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 12 | import { newApp } from "../src/actions/apps.ts"; | |
| 13 | import { openPull } from "../src/actions/pulls.ts"; | |
| 14 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 15 | import { getRepo } from "../src/core/repos.ts"; | |
| 16 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 19 | import { promote } from "./helpers/members.ts"; | |
| 20 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 21 | ||
| 22 | describe("MCP app_deploy", () => { | |
| 23 | let dataDir: string; | |
| 24 | let app: App; | |
| 25 | let servers: Servers; | |
| 26 | let runtime: AppsRuntime; | |
| 27 | let aliceRw: string; | |
| 28 | let bobRw: string; | |
| 29 | let pull = 0; | |
| 30 | let id = 1; | |
| 31 | ||
| 32 | const tool = async (token: string, args: unknown) => { | |
| 33 | const res = await fetch(`${servers.url}/mcp`, { | |
| 34 | method: "POST", | |
| 35 | headers: { | |
| 36 | "content-type": "application/json", | |
| 37 | accept: "application/json, text/event-stream", | |
| 38 | authorization: `Bearer ${token}`, | |
| 39 | }, | |
| 40 | body: JSON.stringify({ | |
| 41 | jsonrpc: "2.0", | |
| 42 | id: id++, | |
| 43 | method: "tools/call", | |
| 44 | params: { name: "app_deploy", arguments: args }, | |
| 45 | }), | |
| 46 | }); | |
| 47 | const body: any = await res.json(); | |
| 48 | const text: string = body.result.content[0].text; | |
| 49 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 50 | }; | |
| 51 | ||
| 52 | beforeAll(async () => { | |
| 53 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-app-deploy-")); | |
| 54 | app = createApp({ dataDir, port: 0 }); | |
| 55 | servers = await serve(app); | |
| 56 | runtime = new AppsRuntime(app, { | |
| 57 | domain: "apps.test", | |
| 58 | scheme: "http", | |
| 59 | publicPort: servers.port, | |
| 60 | forgeUrl: servers.url, | |
| 61 | }); | |
| 62 | await runtime.start(); | |
| 63 | await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" }); | |
| 64 | const b = await createUser(app.db, { | |
| 65 | origin: "seed", | |
| 66 | trusted: true, | |
| 67 | username: "bob", | |
| 68 | email: "[email protected]", | |
| 69 | password: "a-good-passphrase", | |
| 70 | }); | |
| 71 | if (!b.ok) throw new Error(b.error); | |
| 72 | promote(app.db, b.value); | |
| 73 | const alice = getUserByName(app.db, "alice")!; | |
| 74 | const made = await newApp(app, alice, { name: "hello", visibility: "public" } as never); | |
| 75 | if (!made.ok) throw new Error(String(made.error)); | |
| 76 | const repo = getRepo(app.db, "alice", "hello")!; | |
| 77 | const committed = await commitToBranch(app, alice, repo, { | |
| 78 | branch: "greeting", | |
| 79 | base: repo.defaultBranch, | |
| 80 | message: "Say hello", | |
| 81 | files: [{ path: "GREETING.md", content: "hi\n" }], | |
| 82 | }); | |
| 83 | if (!committed.ok) throw new Error(JSON.stringify(committed.error)); | |
| 84 | const opened = await openPull(app, alice, repo, { | |
| 85 | title: "Say hello", | |
| 86 | baseBranch: repo.defaultBranch, | |
| 87 | headBranch: "greeting", | |
| 88 | }); | |
| 89 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 90 | pull = opened.value.number; | |
| 91 | aliceRw = tokenFor(app, "alice", "rw"); | |
| 92 | bobRw = tokenFor(app, "bob", "rw"); | |
| 93 | }, 30_000); | |
| 94 | ||
| 95 | afterAll(async () => { | |
| 96 | await runtime.stop(); | |
| 97 | servers.stop(); | |
| 98 | app.close(); | |
| 99 | await rm(dataDir, { recursive: true, force: true }); | |
| 100 | }); | |
| 101 | ||
| 102 | test("a writer queues a redeploy, and stops production", async () => { | |
| 103 | const r = await tool(aliceRw, { repo: "alice/hello", env: "production", action: "redeploy" }); | |
| 104 | expect(r.value.queued).toBe(true); | |
| 105 | expect(typeof r.value.run).toBe("string"); | |
| 106 | const s = await tool(aliceRw, { repo: "alice/hello", env: "production", action: "stop" }); | |
| 107 | expect(s.value).toEqual({ stopped: true }); | |
| 108 | }, 30_000); | |
| 109 | ||
| 110 | test("a writer stops a pull's preview", async () => { | |
| 111 | const s = await tool(aliceRw, { repo: "alice/hello", env: `pull-${pull}`, action: "stop" }); | |
| 112 | expect(s.value).toEqual({ stopped: true }); | |
| 113 | }, 30_000); | |
| 114 | ||
| 115 | test("a reader is refused", async () => { | |
| 116 | const r = await tool(bobRw, { repo: "alice/hello", env: "production", action: "redeploy" }); | |
| 117 | expect(r.error).toContain("forbidden"); | |
| 118 | const s = await tool(bobRw, { repo: "alice/hello", env: "production", action: "stop" }); | |
| 119 | expect(s.error).toContain("forbidden"); | |
| 120 | }); | |
| 121 | ||
| 122 | test("an unknown env or action is refused", async () => { | |
| 123 | const e = await tool(aliceRw, { repo: "alice/hello", env: "staging", action: "redeploy" }); | |
| 124 | expect(e.error).toContain("no_such_env"); | |
| 125 | const a = await tool(aliceRw, { repo: "alice/hello", env: "production", action: "burn" }); | |
| 126 | expect(a.error).toContain("redeploy or stop"); | |
| 127 | }); | |
| 128 | }); |
test/mcp-size.test.ts +1 −1
| @@ -89,7 +89,7 @@ test("tools/list stays under 40 KB, with 3 KB of headroom (37,000 bytes)", async | ||
| 89 | 89 | for (const t of tools) walk(t.name, t.inputSchema, ""); |
| 90 | 90 | expect(long, `property descriptions over ${PROPERTY_MAX} bytes`).toEqual([]); |
| 91 | 91 | expect(bytes, "the argument descriptions were cut by 1,000 bytes").toBeLessThanOrEqual( |
| 92 | 36_400, | |
| 92 | 36_700, | |
| 93 | 93 | ); |
| 94 | 94 | // readOnlyHint defaults to false; spelling the default out on every write |
| 95 | 95 | // tool cost 2 KB, so only a read tool carries annotations. |
test/mcp.test.ts +1
| @@ -172,6 +172,7 @@ describe("the forge over MCP", () => { | ||
| 172 | 172 | expect(names).toEqual([ |
| 173 | 173 | "sizes", |
| 174 | 174 | "apps", |
| 175 | "app_deploy", | |
| 175 | 176 | "new_app", |
| 176 | 177 | "repo", |
| 177 | 178 | "here", |
new above
offline
Raising this ceiling is justified in the pull: main was 26 bytes under it, and the tool's skeleton alone is 272 bytes. The 37,000-byte headroom the test is named for still holds (36,646). The next MCP tool won't fit, though. The next pull that adds one should pay for its bytes by trimming text, not by lifting this number again.