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

MCP `hooks`: list, add and remove a repository's webhooks, with the same rules as the API #5706

Merged opened this

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

Merged

scout/forge:scout/4752-mcp-hooks-list-add-and-remove-a-reposito → main 8 commits fork 3 threads · 3 unresolved

  • Merged. scout/4752-mcp-hooks-list-add-and-remove-a-reposito was merged into main as 1efb817.

Review

Sign in to review this change.
3 files changed +179 −1
docs/MCP.md +1
@@ -104,6 +104,7 @@ for a tunnel and a forge that sleeps.
104104 | `usage` | `{ repo, days? }`: what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue, and each account's 5-hour and weekly standing; a writer's | |
105105 | `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 |
106106 | `repo_labels` | `{ repo, action?, name?, color?, id? }`: list (`list`, the default; a read-only token may), create (`create`, `name`, `color` six hex digits) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `DELETE …/labels/:id`; create and remove need an admin token and a repository admin | yes |
107 | `hooks` | `{ repo, action, url, secret, kinds, id }`: `list`, `add` (`secret`, 16–200 characters, is required) or `remove` the repository's webhooks, as `…/hooks`; an admin-scope token on a repository you administer, and a secret is never shown | yes |
107108 | `subscription_budget` | `{ id, usd }`: set an account's whole-life budget in dollars (up to the `subscription.budget_max_usd` limit), or clear it with `usd: null`, as the Subscriptions page does; a locked budget can only be lowered; a forge admin with an admin token | yes |
108109 | `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 |
109110 | `confirm_wait` | `{ app, issue, blocker }`: a person says `issue` may go ahead although `blocker` closed as dropped or split; the API's confirm action, so writers only and never the crew | yes |
src/http/mcp.ts +68 −1
@@ -205,7 +205,8 @@ import {
205205 directionNote,
206206 OUTSIDERS_NOTE,
207207 } from "../crew/direct.ts";
208 import { createLabel, deleteLabel, setArchived, setCrewOutsiders, updateRepoSettings, setDecisionsBy, repositoryLimitMessage } from "../actions/repos.ts";
208 import { createLabel, deleteLabel, createWebhook, deleteWebhook, setArchived, setCrewOutsiders, updateRepoSettings, setDecisionsBy, repositoryLimitMessage } from "../actions/repos.ts";
209 import { listWebhooks } from "../core/webhooks.ts";
209210 import { LABEL_WORDS } from "../actions/label_words.ts";
210211 import {
211212 appView,
@@ -366,6 +367,20 @@ const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
366367
367368 const REPO_ARG = { type: "string" } as const;
368369
370 const HOOK_LIMITS = { url: 2048, kinds: 200, secret: 200, id: 64 } as const;
371
372 /** The refusal for a `hooks` argument that is not text or is over its declared length. */
373 function hookTooLong(args: Args): Answer | undefined {
374 for (const [key, max] of Object.entries(HOOK_LIMITS)) {
375 const v = args[key];
376 if (v !== undefined && (typeof v !== "string" || v.length > max))
377 return bad("invalid", `${key} is text of at most ${max} characters`);
378 }
379 return undefined;
380 }
381
382 const notAdmin = (): Answer => bad("not_found", "no such app or repository, or you do not administer it");
383
369384 /** A repository the caller may read, by `owner/name`; a wrong one is answered with that form. */
370385 function repoArg(ctx: Ctx, user: User, args: Args): Repo | Answer {
371386 const [owner, name, ...rest] = str(args["repo"]).split("/");
@@ -3946,6 +3961,58 @@ const TOOLS: readonly Tool[] = [
39463961 : bad("not_found", "no such label, or you are not this repository's admin");
39473962 },
39483963 },
3964 {
3965 name: "hooks",
3966 description: "List, add (`url`, `secret`, `kinds`) or remove (`id`) a repository's webhooks; admins only, a secret is never shown.",
3967 inputSchema: {
3968 type: "object",
3969 properties: {
3970 repo: REPO_ARG,
3971 action: { type: "string", enum: ["list", "add", "remove"] },
3972 url: { type: "string", maxLength: 2048 },
3973 kinds: { type: "string", maxLength: 200 },
3974 secret: { type: "string", maxLength: 200 },
3975 id: { type: "string", maxLength: 64 },
3976 },
3977 required: ["repo", "action"],
3978 },
3979 writes: true,
3980 needsAdminScope: true,
3981 run(ctx, user, args) {
3982 const repo = repoArg(ctx, user, args);
3983 if (isAnswer(repo)) return repo;
3984 const action = args["action"];
3985 if (action === "list") {
3986 if (!canAdmin(ctx.app.db, repo, user)) return notAdmin();
3987 return good(listWebhooks(ctx.app.db, repo.id));
3988 }
3989 if (action === "add") {
3990 const long = hookTooLong(args);
3991 if (long !== undefined) return long;
3992 const secret = args["secret"];
3993 if (typeof secret !== "string" || secret.length < 16)

Low: a secret of 16 spaces, or any whitespace-only string, passes this check and becomes the HMAC key. Trimming the value before checking its length (secret.trim().length < 16) closes that.

3994 return bad("invalid", "secret is required: 16 to 200 characters, the key deliveries are signed with");
3995 const created = createWebhook(ctx.app, user, repo, {
3996 url: str(args["url"]),
3997 kinds: str(args["kinds"]) || "*",
3998 secret,
3999 });
4000 if (!created.ok)
4001 return created.error === "invalid_url"
4002 ? bad("invalid_url", "a webhook url is https://…")
4003 : notAdmin();
4004 // Unlike the API's one-time reveal, the secret is never echoed here.
4005 return good(created.value.webhook);
4006 }
4007 if (action === "remove") {
4008 const long = hookTooLong(args);
4009 if (long !== undefined) return long;

Low: remove relies entirely on deleteWebhook's admin gate, which is right. But test/mcp-hooks.test.ts has no case where a non-admin (bobAdmin) tries to remove alice's hook and gets not_found with the hook still listed. One line in the "a reader is refused" test would pin that down.

4010 const removed = deleteWebhook(ctx.app, user, repo, str(args["id"]));
4011 return removed.ok ? good({}) : bad("not_found", "no such webhook here");
4012 }
4013 return bad("invalid", "action is list, add or remove");
4014 },
4015 },
39494016 {
39504017 name: "subscription_budget",
39514018 description: "Admin: set an account's whole-life budget in dollars, or null to clear.",
test/mcp-hooks.test.ts added +110
@@ -0,0 +1,110 @@
1 /** The `hooks` tool is `…/hooks` over MCP: the same actions, the same gate. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { promote } from "./helpers/members.ts";
12
13 describe("MCP hooks", () => {
14 let dataDir: string;
15 let app: App;
16 let servers: Servers;
17 let aliceAdmin: string;
18 let aliceRw: string;
19 let bobAdmin: string;
20 let id = 1;
21
22 const rpc = async (token: string, method: string, params: unknown) => {
23 const res = await fetch(`${servers.url}/mcp`, {
24 method: "POST",
25 headers: {
26 "content-type": "application/json",
27 accept: "application/json, text/event-stream",
28 authorization: `Bearer ${token}`,
29 },
30 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method, params }),
31 });
32 return (await res.json()) as any;
33 };
34 const tool = async (token: string, args: unknown) => {
35 const body = await rpc(token, "tools/call", { name: "hooks", arguments: args });
36 const text: string = body.result.content[0].text;
37 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
38 };
39
40 beforeAll(async () => {
41 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-hooks-"));
42 app = createApp({ dataDir, port: 0 });
43 servers = await serve(app);
44 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
45 if (!a.ok) throw new Error(a.error);
46 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
47 if (!b.ok) throw new Error(b.error);
48 promote(app.db, b.value);
49 aliceAdmin = createToken(app.db, a.value.id, "a", "admin").token;
50 aliceRw = createToken(app.db, a.value.id, "rw", "rw").token;
51 bobAdmin = createToken(app.db, b.value.id, "b", "admin").token;
52 const made = await createRepo(app, a.value, { name: "game", visibility: "public" });
53 if (!made.ok) throw new Error(String(made.error));
54 }, 30_000);
55
56 afterAll(async () => {
57 servers.stop();
58 app.close();
59 await rm(dataDir, { recursive: true, force: true });
60 });
61
62 test("an admin adds, lists and removes a hook, and its secret is never echoed", async () => {
63 const added = await tool(aliceAdmin, { repo: "alice/game", action: "add", url: "https://example.com/h", secret: "s3cret-value-long-enough" });
64 expect(added.value.url).toBe("https://example.com/h");
65 expect(added.value.kinds).toBe("*");
66 expect(JSON.stringify(added)).not.toContain("s3cret-value-long-enough");
67 const listed = await tool(aliceAdmin, { repo: "alice/game", action: "list" });
68 expect(listed.value.map((h: any) => h.id)).toEqual([added.value.id]);
69 expect(JSON.stringify(listed)).not.toContain("s3cret-value-long-enough");
70 expect((await tool(aliceAdmin, { repo: "alice/game", action: "remove", id: added.value.id })).value).toEqual({});
71 expect((await tool(aliceAdmin, { repo: "alice/game", action: "list" })).value).toEqual([]);
72 expect((await tool(aliceAdmin, { repo: "alice/game", action: "remove", id: added.value.id })).error).toContain("not_found");
73 });
74
75 test("a reader is refused and nothing is added", async () => {
76 for (const args of [

Low: also have bobAdmin try remove on an existing hook, and assert not_found and that the hook is still listed, so the delete gate has its own test.

77 { repo: "alice/game", action: "add", url: "https://example.com/x", secret: "0123456789abcdef" },
78 { repo: "alice/game", action: "list" },
79 ])
80 expect((await tool(bobAdmin, args)).error).toContain("not_found");
81 expect((await tool(aliceAdmin, { repo: "alice/game", action: "list" })).value).toEqual([]);
82 });
83
84 test("an invalid URL is refused with the API's error", async () => {
85 for (const url of ["http://example.com/h", "nonsense", ""])
86 expect((await tool(aliceAdmin, { repo: "alice/game", action: "add", url, secret: "0123456789abcdef" })).error).toContain("invalid_url");
87 expect((await tool(aliceAdmin, { repo: "alice/game", action: "nope" })).error).toContain("action is list");
88 });
89
90 test("lengths are enforced, and a secret of 16 or more is required", async () => {
91 const base = { repo: "alice/game", action: "add", secret: "0123456789abcdef" };
92 const long = await tool(aliceAdmin, { ...base, url: "https://example.com/" + "a".repeat(2048) });
93 expect(long.error).toContain("url is text of at most 2048");
94 const kinds = await tool(aliceAdmin, { ...base, url: "https://example.com/h", kinds: "k".repeat(201) });
95 expect(kinds.error).toContain("kinds is text of at most 200");
96 expect((await tool(aliceAdmin, { ...base, url: "https://example.com/h", secret: "x".repeat(201) })).error).toContain("secret is text");
97 expect((await tool(aliceAdmin, { repo: "alice/game", action: "remove", id: "i".repeat(65) })).error).toContain("id is text");
98 for (const secret of [undefined, "short"])
99 expect((await tool(aliceAdmin, { repo: "alice/game", action: "add", url: "https://example.com/h", secret })).error).toContain("secret is required");
100 expect((await tool(aliceAdmin, { repo: "alice/game", action: "list" })).value).toEqual([]);
101 });
102
103 test("it is listed, and callable, only with an admin-scope token", async () => {
104 const names = async (t: string) => (await rpc(t, "tools/list", {})).result.tools.map((x: any) => x.name);
105 expect(await names(aliceAdmin)).toContain("hooks");
106 expect(await names(aliceRw)).not.toContain("hooks");
107 const called = await rpc(aliceRw, "tools/call", { name: "hooks", arguments: { repo: "alice/game", action: "list" } });
108 expect(called.error.message).toContain("no tool named hooks");
109 });
110 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs