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

MCP `prompts`: list, add, run and remove an app's prompt sources, with the same rules as the API #4776

Merged opened this

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

Merged

scout/forge:scout/4766-mcp-prompts-list-add-run-and-remove-an-a → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/4766-mcp-prompts-list-add-run-and-remove-an-a was merged into main as 027c135.

Review

Sign in to review this change.
3 files changed +226 −2
docs/MCP.md +1
@@ -74,6 +74,7 @@ for a tunnel and a forge that sleeps.
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 |
7676 | `plan` | `{ app, issues }`: open up to 12 linked issues, all or none (see below) | yes |
77 | `prompts` | `{ repo, action, kind?, token?, at?, id? }`: list, add, run or remove an app's prompt sources (Instagram), as the `…/prompts` API does; the token goes in once and is never shown; listed only for an admin-scope token, and only the repository's admin may use it | yes |
7778
7879 Answers are JSON in a text block; most carry a `says` sentence short enough
7980 to relay as it is.
src/http/mcp.ts +65 −2
@@ -61,6 +61,12 @@ import { submitReview } from "../actions/pulls.ts";
6161 import { originError } from "../web/pages/thread.ts";
6262 import { holdPull, releasePull } from "../actions/holds.ts";
6363 import { openPlan, parsePlan, PLAN_MAX, proposePlan } from "../actions/plan.ts";
64 import {
65 addPromptSource,
66 listPromptSources,
67 removePromptSource,
68 runPromptSource,
69 } from "../actions/prompts.ts";
6470 import { cancelRepoRun, listRepoRuns } from "../actions/runs.ts";
6571 import { familyView } from "../spawn/family.ts";
6672 import { clientRows } from "../spawn/clients.ts";
@@ -262,6 +268,8 @@ interface Tool {
262268 readonly writes: boolean;
263269 /** Listed, and callable, for a forge admin only. */
264270 readonly adminOnly?: true;
271 /** Listed, and callable, only with a token of this scope. */
272 readonly needsAdminScope?: true;
265273 /** With this argument absent the tool only reads, whatever the token. */
266274 readonly readsWithout?: string;
267275 /** A write that only plans (`dryRun` not false) is a read: `ro` may ask. */
@@ -269,6 +277,11 @@ interface Tool {
269277 run(ctx: Ctx, user: User, args: Args): Promise<Answer> | Answer;
270278 }
271279
280 /** Whether this caller is shown the tool and may call it: one rule for the manifest and dispatch. */
281 const visible = (tool: Tool, ctx: Ctx, user: User): boolean =>
282 (tool.adminOnly !== true || user.isAdmin) &&
283 (tool.needsAdminScope !== true || scopeAllows(ctx.auth.scope ?? "ro", "admin"));
284
272285 const CHIP_ARGS = {
273286 hide_kinds: {
274287 type: "array",
@@ -3273,6 +3286,56 @@ const TOOLS: readonly Tool[] = [
32733286 });
32743287 },
32753288 },
3289 {
3290 name: "prompts",
3291 description:
3292 "An app's prompt sources (Instagram): list, add (`kind`, `token`, `at`), run or remove (`id`). Repository admin token.",
3293 inputSchema: {
3294 type: "object",
3295 properties: {
3296 repo: REPO_ARG,
3297 action: { type: "string", enum: ["list", "add", "run", "remove"] },
3298 kind: { type: "string", description: "add: instagram" },
3299 token: { type: "string", description: "add: sealed, never shown again" },
3300 at: { type: "string", description: "add: HH:MM UTC" },
3301 id: { type: "string", description: "run, remove" },
outdated

Unreachable: tools/call already refuses this tool for a token below admin scope (the needsAdminScope check). Keep one gate, ideally a shared visible(tool) helper used by both tools/list and tools/call.

3302 },
3303 required: ["repo", "action"],
3304 },
3305 writes: true,
3306 needsAdminScope: true,
3307 async run(ctx, user, args) {
3308 const repo = repoArg(ctx, user, args);
3309 if (isAnswer(repo)) return repo;
3310 const refuse = (error: string) =>
3311 error === "not_found"
3312 ? bad("not_found", "no such app or repository (owner/name)")
3313 : bad(error, error.replaceAll("_", " "));
3314 const action = args["action"];
3315 if (action === "list") {
3316 const listed = listPromptSources(ctx.app, user, repo);
3317 return listed.ok ? good({ items: listed.value }) : refuse(listed.error);
3318 }
3319 if (action === "add") {
3320 const added = addPromptSource(ctx.app, user, repo, {
3321 kind: str(args["kind"]),
3322 token: str(args["token"]),
3323 at: str(args["at"]),
3324 });
3325 return added.ok ? good(added.value) : refuse(added.error);
3326 }
3327 const id = str(args["id"]);
3328 if (action === "run") {
3329 const ran = await runPromptSource(ctx.app, user, repo, id);
3330 return ran.ok ? good(ran.value) : refuse(ran.error);
3331 }
3332 if (action === "remove") {
3333 const removed = removePromptSource(ctx.app, user, repo, id);
3334 return removed.ok ? good({ removed: true }) : refuse(removed.error);
3335 }
3336 return bad("bad_action", "`action` is list, add, run or remove");
3337 },
3338 },
32763339 {
32773340 name: "access",
32783341 description:
@@ -3787,7 +3850,7 @@ function rpcOne(
37873850 case "tools/list":
outdated

This filter repeats the condition tools/call checks below. Extract it into one predicate so the manifest and dispatch stay the same rule; it also brings this line back to a readable width.

37883851 return reply({
37893852 tools: [
3790 ...TOOLS.filter((t) => t.adminOnly !== true || user.isAdmin).map((t) => ({
3853 ...TOOLS.filter((t) => visible(t, ctx, user)).map((t) => ({
37913854 name: t.name,
37923855 description: t.description,
37933856 inputSchema: lean(t.inputSchema),
@@ -3821,7 +3884,7 @@ function rpcOne(
38213884 }
38223885 case "tools/call": {
38233886 const tool = TOOLS.find((t) => t.name === params["name"]);
3824 if (tool === undefined || (tool.adminOnly === true && !user.isAdmin))
3887 if (tool === undefined || !visible(tool, ctx, user))
38253888 return refuse(-32602, `no tool named ${String(params["name"])}`);
38263889 const args =
38273890 typeof params["arguments"] === "object" && params["arguments"] !== null
test/mcp-prompts.test.ts added +160
@@ -0,0 +1,160 @@
1 /**
2 * The `prompts` tool is the `…/prompts` API over MCP: the same actions, so
3 * the same rules. The token goes in once and no answer carries it back; the
4 * source is faked, so nothing here reaches the network.
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 { createRepo } from "../src/actions/repos.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { createOrg, createTeam, addTeamMember } from "../src/core/orgs.ts";
13 import { canAdmin, canWrite } from "../src/core/repos.ts";
14 import { createToken } from "../src/core/tokens.ts";
15 import { createUser } from "../src/core/users.ts";
16 import type { InstagramClient } from "../src/crew/instagram.ts";
17 import { startPromptRoutine, type PromptRoutineHandle } from "../src/crew/prompts.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19 import { promote } from "./helpers/members.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21
22 quietLogs();
23
24 const TOKEN = "IGAAQzx7k9p2m4-secret-token-for-the-mcp-test-QQ";
25
26 const fake: InstagramClient = {
27 me: async () => ({ id: "1", username: "gamehandle" }),
28 latestReel: async () => undefined,
29 comments: async () => [],
30 refresh: async () => ({ token: "IGAAQfresh-QQ" }),
31 };
32
33 describe("MCP prompts", () => {
34 let dataDir: string;
35 let app: App;
36 let servers: Servers;
37 let routine: PromptRoutineHandle;
38 let admin = "";
39 let rw = "";
40 let bob = "";
41 let stranger = "";
42 let id = 1;
43
44 const rpc = async (token: string, method: string, params: unknown) => {
45 const res = await fetch(`${servers.url}/mcp`, {
46 method: "POST",
47 headers: {
48 "content-type": "application/json",
49 accept: "application/json, text/event-stream",
50 authorization: `Bearer ${token}`,
51 },
52 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method, params }),
53 });
54 return (await res.json()) as any;
55 };
56 const tool = async (token: string, args: unknown) => {
57 const body = await rpc(token, "tools/call", { name: "prompts", arguments: args });
58 const text: string = body.result.content[0].text;
59 return { text, error: body.result.isError === true, value: body.result.isError ? undefined : JSON.parse(text) };
60 };
61
62 beforeAll(async () => {
63 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-prompts-"));
64 app = createApp({ dataDir, port: 0 });
65 servers = await serve(app);
66 routine = startPromptRoutine(app, { client: fake, everyMs: 3_600_000 });
67 const a = await createUser(app.db, { origin: "seed", trusted: true,
68 username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
69 const b = await createUser(app.db, { origin: "seed", trusted: true,
70 username: "bob", email: "[email protected]", password: "a-good-passphrase" });
71 if (!a.ok || !b.ok) throw new Error("users");
72 promote(app.db, b.value);
73 admin = createToken(app.db, a.value.id, "admin", "admin").token;
74 rw = createToken(app.db, a.value.id, "rw", "rw").token;
75 bob = createToken(app.db, b.value.id, "admin", "admin").token;
76 const c = await createUser(app.db, { origin: "seed", trusted: true,
77 username: "carol", email: "[email protected]", password: "a-good-passphrase" });
78 if (!c.ok) throw new Error("users");
79 promote(app.db, c.value);
80 stranger = createToken(app.db, c.value.id, "admin", "admin").token;
81 const made = await createRepo(app, a.value, { name: "game", visibility: "public" });
82 if (!made.ok) throw new Error(String(made.error));
83 // Bob writes to studio/game through a write team, and administers nothing.
84 const org = createOrg(app.db, "studio", a.value.id);
85 if (!org.ok) throw new Error(org.error);
86 const team = createTeam(app.db, org.value.id, "devs", "write");
87 if (!team.ok) throw new Error(team.error);
88 addTeamMember(app.db, team.value, b.value.id);
89 const studio = await createRepo(app, org.value, { name: "game", visibility: "public" });
90 if (!studio.ok) throw new Error(String(studio.error));
91 expect(canWrite(app.db, studio.value, b.value)).toBe(true);
92 expect(canAdmin(app.db, studio.value, b.value)).toBe(false);
93 }, 30_000);
94
95 afterAll(async () => {
96 await routine.stop();
97 servers.stop();
98 app.close();
99 await rm(dataDir, { recursive: true, force: true });
100 });
101
102 test("an admin adds, lists, runs and removes a source, and no answer holds the token", async () => {
103 const seen: string[] = [];
104 const added = await tool(admin, { repo: "alice/game", action: "add", kind: "instagram", token: TOKEN, at: "15:00" });
105 seen.push(added.text);
106 expect(added.error).toBe(false);
107 const sourceId = added.value.id as string;
108 expect(added.value.kind).toBe("instagram");
109 const listed = await tool(admin, { repo: "alice/game", action: "list" });
110 seen.push(listed.text);
111 expect(listed.value.items.map((s: any) => s.id)).toEqual([sourceId]);
112 const ran = await tool(admin, { repo: "alice/game", action: "run", id: sourceId });
113 seen.push(ran.text);
114 expect(ran.error).toBe(false);
115 expect(ran.value.account).toBe("gamehandle");
116 const again = await tool(admin, { repo: "alice/game", action: "add", kind: "instagram", token: TOKEN, at: "15:00" });
outdated

bob has no role on alice/game; promote only makes him a forge member. This test would also pass if the action gated on write instead of admin. Grant bob write access to the repository, then assert that each action (add, list, run, remove) is refused for him. That is the case #4766 asked for.

117 seen.push(again.text);
118 expect(again.error).toBe(true);
119 const removed = await tool(admin, { repo: "alice/game", action: "remove", id: sourceId });
120 seen.push(removed.text);
121 expect(removed.error).toBe(false);
122 expect((await tool(admin, { repo: "alice/game", action: "list" })).value.items).toEqual([]);
123 for (const text of seen) expect(text).not.toContain(TOKEN);
124 });
125
126 test("an unknown kind is refused with the API's error, and the token is not echoed", async () => {
127 const r = await tool(admin, { repo: "alice/game", action: "add", kind: "tiktok", token: TOKEN, at: "15:00" });
128 expect(r.error).toBe(true);
129 expect(r.text).toContain("invalid_kind");
130 expect(r.text).not.toContain(TOKEN);
outdated

Assert the refusal plainly: expect(direct.error.code).toBe(-32602). The tool(rw, …).catch call above only throws and can go.

131 expect((await tool(admin, { repo: "alice/game", action: "nope" })).error).toBe(true);
132 });
133
134 test("a writer who is not admin is refused every action, and a stranger too", async () => {
135 const sourceId = (await tool(admin, { repo: "studio/game", action: "add", kind: "instagram", token: TOKEN, at: "15:00" })).value.id;
136 const attempts = [
137 { action: "add", kind: "instagram", token: TOKEN, at: "15:00" },
138 { action: "list" },
139 { action: "run", id: sourceId },
140 { action: "remove", id: sourceId },
141 ];
142 for (const who of [bob, stranger])
143 for (const a of attempts) {
144 const r = await tool(who, { repo: "studio/game", ...a });
145 expect(r.error).toBe(true);
146 expect(r.text).toStartWith("not_found");
147 expect(r.text).not.toContain(TOKEN);
148 }
149 // Nothing was touched: the admin still sees the source.
150 expect((await tool(admin, { repo: "studio/game", action: "list" })).value.items).toHaveLength(1);
151 });
152
153 test("only an admin-scope token sees the tool or may call it", async () => {
154 const names = async (t: string) => (await rpc(t, "tools/list", {})).result.tools.map((x: any) => x.name);
155 expect(await names(admin)).toContain("prompts");
156 expect(await names(rw)).not.toContain("prompts");
157 const direct = await rpc(rw, "tools/call", { name: "prompts", arguments: { repo: "alice/game", action: "list" } });
158 expect(direct.error.code).toBe(-32602);
159 });
160 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs