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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4752-mcp-hooks-list-add-and-remove-a-reposito → main 8 commits fork 3 threads · 3 unresolved
3 files
changed +179
−1
docs/MCP.md +1
| @@ -104,6 +104,7 @@ for a tunnel and a forge that sleeps. | ||
| 104 | 104 | | `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 | | |
| 105 | 105 | | `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 | |
| 106 | 106 | | `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 | | |
| 107 | 108 | | `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 | |
| 108 | 109 | | `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 | |
| 109 | 110 | | `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 { | ||
| 205 | 205 | directionNote, |
| 206 | 206 | OUTSIDERS_NOTE, |
| 207 | 207 | } 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"; | |
| 209 | 210 | import { LABEL_WORDS } from "../actions/label_words.ts"; |
| 210 | 211 | import { |
| 211 | 212 | appView, |
| @@ -366,6 +367,20 @@ const str = (v: unknown): string => (typeof v === "string" ? v.trim() : ""); | ||
| 366 | 367 | |
| 367 | 368 | const REPO_ARG = { type: "string" } as const; |
| 368 | 369 | |
| 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 | ||
| 369 | 384 | /** A repository the caller may read, by `owner/name`; a wrong one is answered with that form. */ |
| 370 | 385 | function repoArg(ctx: Ctx, user: User, args: Args): Repo | Answer { |
| 371 | 386 | const [owner, name, ...rest] = str(args["repo"]).split("/"); |
| @@ -3946,6 +3961,58 @@ const TOOLS: readonly Tool[] = [ | ||
| 3946 | 3961 | : bad("not_found", "no such label, or you are not this repository's admin"); |
| 3947 | 3962 | }, |
| 3948 | 3963 | }, |
| 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) | |
| 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: |
||
| 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 | }, | |
| 3949 | 4016 | { |
| 3950 | 4017 | name: "subscription_budget", |
| 3951 | 4018 | 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 |
||
| 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 | }); |
new above
offline
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.