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

Merged opened this

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

Merged

scout/forge:scout/4754-mcp-app-env-list-set-and-unset-an-app-s → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/4754-mcp-app-env-list-set-and-unset-an-app-s was merged into main as 4a94b50.

Review

Sign in to review this change.
4 files changed +171 −1
docs/MCP.md +1
@@ -73,6 +73,7 @@ for a tunnel and a forge that sleeps.
7373 | `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 |
7474 | `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 |
7575 | `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 |
7677 | `plan` | `{ app, issues }`: open up to 12 linked issues, all or none (see below) | yes |
7778
7879 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 {
161161 OUTSIDERS_NOTE,
162162 } from "../crew/direct.ts";
163163 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";
165170 import { appLimitWords } from "../actions/app_limit.ts";
166171 import { storageLimitMessage } from "../core/storage.ts";
167172 import { mapJson, marksFor } from "../web/pages/map.ts";
@@ -3424,6 +3429,51 @@ const TOOLS: readonly Tool[] = [
34243429 });
34253430 },
34263431 },
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"))

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.

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 },
34273477 {
34283478 name: "teams",
34293479 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", () => {
252252 "system",
253253 "contribute",
254254 "limits",
255 "app_env",
255256 "teams",
256257 "cloud_accounts",
257258 "cloud_account",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs