Public: anyone can read this; the crew can change it.
MCP `app_env`: list, set and unset an app's environment variables, with the same rules as the API #4773
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4754-mcp-app-env-list-set-and-unset-an-app-s → main 1 commit fork 1 thread · 1 unresolved
4 files
changed +171
−1
docs/MCP.md +1
| @@ -73,6 +73,7 @@ for a tunnel and a forge that sleeps. | ||
| 73 | 73 | | `labels` | `{ repo, number, labels }`: set an issue's or a pull's labels to exactly these (`[]` clears); only labels the repository has, by a writer | yes | |
| 74 | 74 | | `wait_for` | `{ app, issue, blockedBy, stop? }`: the issue waits for another, or stops with `stop: true`; the page's rules, and the answer carries the issue's URL | yes | |
| 75 | 75 | | `hold` | `{ app, pull, why?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; plans unless `dryRun: false` | yes | |
| 76 | | `app_env` | `{ repo, action, name?, value?, scope? }`: `list` an app's variable names and scopes, `set` one, or `unset` it, in `all` (default), `production` or `preview`; the API's rules and errors, an admin of the repository with an admin-scope token; a value is never read back | yes | | |
| 76 | 77 | | `plan` | `{ app, issues }`: open up to 12 linked issues, all or none (see below) | yes | |
| 77 | 78 | |
| 78 | 79 | Answers are JSON in a text block; most carry a `says` sentence short enough |
src/http/mcp.ts +51 −1
| @@ -161,7 +161,12 @@ import { | ||
| 161 | 161 | OUTSIDERS_NOTE, |
| 162 | 162 | } from "../crew/direct.ts"; |
| 163 | 163 | import { setArchived, setCrewOutsiders, setDecisionsBy, repositoryLimitMessage } from "../actions/repos.ts"; |
| 164 | import { newApp } from "../actions/apps.ts"; | |
| 164 | import { | |
| 165 | deleteAppEnvVar, | |
| 166 | listAppEnv, | |
| 167 | newApp, | |
| 168 | setAppEnvVar, | |
| 169 | } from "../actions/apps.ts"; | |
| 165 | 170 | import { appLimitWords } from "../actions/app_limit.ts"; |
| 166 | 171 | import { storageLimitMessage } from "../core/storage.ts"; |
| 167 | 172 | import { mapJson, marksFor } from "../web/pages/map.ts"; |
| @@ -3424,6 +3429,51 @@ const TOOLS: readonly Tool[] = [ | ||
| 3424 | 3429 | }); |
| 3425 | 3430 | }, |
| 3426 | 3431 | }, |
| 3432 | { | |
| 3433 | name: "app_env", | |
| 3434 | description: | |
| 3435 | "An app's environment variables: list names, set or unset one by scope; values are write-only. Admin.", | |
| 3436 | inputSchema: { | |
| 3437 | type: "object", | |
| 3438 | properties: { | |
| 3439 | repo: REPO_ARG, | |
| 3440 | action: { type: "string", enum: ["list", "set", "unset"] }, | |
| 3441 | name: { type: "string" }, | |
| 3442 | value: { type: "string" }, | |
| 3443 | scope: { type: "string", enum: ["all", "production", "preview"] }, | |
| 3444 | }, | |
| 3445 | required: ["repo", "action"], | |
| 3446 | }, | |
| 3447 | writes: true, | |
| 3448 | async run(ctx, user, args) { | |
| 3449 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) | |
| 3450 | return bad("forbidden", "an app's environment needs an admin-scope token"); | |
| 3451 | const repo = repoArg(ctx, user, args); | |
| 3452 | if (isAnswer(repo)) return repo; | |
| 3453 | const action = args["action"]; | |
| 3454 | const scope = typeof args["scope"] === "string" ? args["scope"] : "all"; | |
| 3455 | const name = typeof args["name"] === "string" ? args["name"] : ""; | |
| 3456 | // The API's errors, by code: never the value. | |
| 3457 | const refused = (error: string): Answer => | |
| 3458 | error === "not_found" | |
| 3459 | ? bad("not_found", "no such app or repository (owner/name)") | |
| 3460 | : bad(error, error); | |
| 3461 | if (action === "list") { | |
| 3462 | const listed = listAppEnv(ctx.app, user, repo); | |
| 3463 | return listed.ok ? good({ items: listed.value, next_cursor: null }) : refused(listed.error); | |
| 3464 | } | |
| 3465 | if (action === "set") { | |
| 3466 | const value = typeof args["value"] === "string" ? args["value"] : ""; | |
| 3467 | const set = await setAppEnvVar(ctx.app, user, repo, name, value, scope); | |
| 3468 | return set.ok ? good(set.value) : refused(set.error); | |
| 3469 | } | |
| 3470 | if (action === "unset") { | |
| 3471 | const removed = await deleteAppEnvVar(ctx.app, user, repo, name, scope); | |
| 3472 | return removed.ok ? good({}) : refused(removed.error); | |
| 3473 | } | |
| 3474 | return bad("invalid", "`action` is list, set or unset"); | |
| 3475 | }, | |
| 3476 | }, | |
| 3427 | 3477 | { |
| 3428 | 3478 | name: "teams", |
| 3429 | 3479 | description: |
test/mcp-app-env.test.ts added +118
| @@ -0,0 +1,118 @@ | ||
| 1 | /** | |
| 2 | * The `app_env` tool is the API's /app/env routes over MCP: the same | |
| 3 | * actions, so the same gate and the same errors, and never a value read back. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { createApp as createAppRecord } from "../src/core/apps.ts"; | |
| 12 | import { createToken } from "../src/core/tokens.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { promote } from "./helpers/members.ts"; | |
| 16 | ||
| 17 | describe("MCP app_env", () => { | |
| 18 | let dataDir: string; | |
| 19 | let app: App; | |
| 20 | let servers: Servers; | |
| 21 | let aliceAdmin: string; | |
| 22 | let aliceRw: string; | |
| 23 | let bobAdmin: string; | |
| 24 | let id = 1; | |
| 25 | const SECRET = "s3cret-value-xyz"; | |
| 26 | ||
| 27 | const tool = 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({ | |
| 36 | jsonrpc: "2.0", | |
| 37 | id: id++, | |
| 38 | method: "tools/call", | |
| 39 | params: { name: "app_env", arguments: args }, | |
| 40 | }), | |
| 41 | }); | |
| 42 | const body: any = await res.json(); | |
| 43 | const text: string = body.result.content[0].text; | |
| 44 | return body.result.isError === true | |
| 45 | ? { error: text } | |
| 46 | : { value: JSON.parse(text), text }; | |
| 47 | }; | |
| 48 | ||
| 49 | beforeAll(async () => { | |
| 50 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-env-")); | |
| 51 | app = createApp({ dataDir, port: 0 }); | |
| 52 | servers = await serve(app); | |
| 53 | const a = await createUser(app.db, { origin: "seed", trusted: true, | |
| 54 | username: "alice", | |
| 55 | email: "[email protected]", | |
| 56 | password: "a-good-passphrase", | |
| 57 | adminIfFirst: true, | |
| 58 | }); | |
| 59 | if (!a.ok) throw new Error(a.error); | |
| 60 | const b = await createUser(app.db, { origin: "seed", trusted: true, | |
| 61 | username: "bob", | |
| 62 | email: "[email protected]", | |
| 63 | password: "a-good-passphrase", | |
| 64 | }); | |
| 65 | if (!b.ok) throw new Error(b.error); | |
| 66 | promote(app.db, b.value); | |
| 67 | aliceAdmin = createToken(app.db, a.value.id, "admin", "admin").token; | |
| 68 | aliceRw = createToken(app.db, a.value.id, "rw", "rw").token; | |
| 69 | bobAdmin = createToken(app.db, b.value.id, "admin", "admin").token; | |
| 70 | const made = await createRepo(app, a.value, { name: "game", visibility: "public" }); | |
| 71 | if (!made.ok) throw new Error(String(made.error)); | |
| 72 | const rec = createAppRecord(app.db, { repoId: made.value.id, slug: "game", access: "public" }); | |
| 73 | if (!rec.ok) throw new Error(rec.error); | |
| 74 | }, 30_000); | |
| 75 | ||
| 76 | afterAll(async () => { | |
| 77 | servers.stop(); | |
| 78 | app.close(); | |
| 79 | await rm(dataDir, { recursive: true, force: true }); | |
| 80 | }); | |
| 81 | ||
| 82 | test("an admin sets a variable per scope, lists the names and unsets them", async () => { | |
| 83 | for (const scope of ["all", "production", "preview"]) { | |
| 84 | const set = await tool(aliceAdmin, { repo: "alice/game", action: "set", name: "GREETING", value: SECRET, scope }); | |
| 85 | expect(set.value.scope).toBe(scope); | |
| 86 | expect(set.text).not.toContain(SECRET); | |
| 87 | } | |
| 88 | const listed = await tool(aliceAdmin, { repo: "alice/game", action: "list" }); | |
| 89 | expect(listed.text).not.toContain(SECRET); | |
| 90 | expect(listed.value.next_cursor).toBeNull(); | |
| 91 | expect(listed.value.items.map((i: any) => i.scope).sort()).toEqual(["all", "preview", "production"]); | |
| 92 | for (const scope of ["all", "production", "preview"]) | |
| 93 | expect((await tool(aliceAdmin, { repo: "alice/game", action: "unset", name: "GREETING", scope })).value).toEqual({}); | |
| 94 | expect((await tool(aliceAdmin, { repo: "alice/game", action: "list" })).value.items).toEqual([]); | |
| 95 | }); | |
| 96 | ||
| 97 | test("a writer who is not admin is refused, by token scope and by role", async () => { | |
| 98 | const rw = await tool(aliceRw, { repo: "alice/game", action: "list" }); | |
| 99 | expect(rw.error).toContain("admin-scope"); | |
| 100 | const bob = await tool(bobAdmin, { repo: "alice/game", action: "set", name: "A", value: SECRET }); | |
| 101 | expect(bob.error).toBeDefined(); | |
| 102 | expect(bob.error).not.toContain(SECRET); | |
| 103 | expect((await tool(aliceAdmin, { repo: "alice/game", action: "list" })).value.items).toEqual([]); | |
| 104 | }); | |
| 105 | ||
| 106 | test("a bad name, scope or action is refused with the API's error, never the value", async () => { | |
| 107 | const name = await tool(aliceAdmin, { repo: "alice/game", action: "set", name: "no good", value: SECRET }); | |
| 108 | expect(name.error).toContain("invalid_name"); | |
| 109 | expect(name.error).not.toContain(SECRET); | |
| 110 | const reserved = await tool(aliceAdmin, { repo: "alice/game", action: "set", name: "FORGE_X", value: SECRET }); | |
| 111 | expect(reserved.error).toContain("reserved_name"); | |
| 112 | const scope = await tool(aliceAdmin, { repo: "alice/game", action: "set", name: "A", value: SECRET, scope: "x" }); | |
| 113 | expect(scope.error).toContain("invalid_scope"); | |
| 114 | const gone = await tool(aliceAdmin, { repo: "alice/game", action: "unset", name: "NOPE" }); | |
| 115 | expect(gone.error).toContain("no_such_var"); | |
| 116 | expect((await tool(aliceAdmin, { repo: "alice/game", action: "burn" })).error).toContain("list, set or unset"); | |
| 117 | }); | |
| 118 | }); |
test/mcp.test.ts +1
| @@ -252,6 +252,7 @@ describe("the forge over MCP", () => { | ||
| 252 | 252 | "system", |
| 253 | 253 | "contribute", |
| 254 | 254 | "limits", |
| 255 | "app_env", | |
| 255 | 256 | "teams", |
| 256 | 257 | "cloud_accounts", |
| 257 | 258 | "cloud_account", |
new above
offline
Nit: the message is the code itself. A one-line sentence for each
AppEnvError(e.g.invalid_name→ "a name is letters, digits and _, starting with a letter") would help an agent fix its call. It isn't blocking, because the API answers the same way.