Public: anyone can read this; the crew can change it.
MCP `hooks` takes action `deliveries` with an `id` and answers that webhook's last deliveries, as the hooks page shows them #7883
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7871-mcp-hooks-takes-action-deliveries-with-a → main 2 commits fork
3 files
changed +114
−5
docs/MCP.md +1 −1
| @@ -96,7 +96,7 @@ for a tunnel and a forge that sleeps. | ||
| 96 | 96 | | `health` | how this forge is: the version and commit it runs, draining or not, the rollout and its incidents | | |
| 97 | 97 | | `here` | `{ url }`: on a forge page, what you may do there as who you are, each action with its route and tool; on the link of one of this forge's app hosts, what its `/__forge/` door shows: `{ app, repo, environment, pull: { number, title } or null, working: [names], links: { home, repo, report } }`; a foreign host, or a private app you may not enter, is refused like a missing page | | |
| 98 | 98 | | `hold` | `{ app, pull | path, why?, until?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; or with `path` (no `pull`) hold new work on a file so the picker starts no build whose issue names it, or list the holds and who set each; an admin of the app; plans unless `dryRun: false` | yes | |
| 99 | | `hooks` | `{ repo, action, url, secret, kinds, id }`: `list` (pages: `limit` 1–200, `cursor`; `{ items, next_cursor }`), `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 | | |
| 99 | | `hooks` | `{ repo, action, url, secret, kinds, id }`: `list` (pages: `limit` 1–200, `cursor`; `{ items, next_cursor }`), `add` (`secret`, 16–200 characters, is required) or `remove` the repository's webhooks, as `…/hooks`; `deliveries` with `id` answers that hook's last 20, newest first (`{ items: [{ id, state, attempts, lastStatus, lastError, kind }] }`); an admin-scope token on a repository you administer, and a secret is never shown | yes | | |
| 100 | 100 | | `invites` | `{ action, id? }`: `list` the outstanding invites (state and expiry, never a link) or `revoke` one by `id`; a person admin with an admin token, stricter than the API's list, which an `ro` token may read | yes | |
| 101 | 101 | | `issue` | `{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, the newest 50 comments (author, authorIsAgent, body, at), `commentsTotal` and `commentsNext` (older ones: `GET …/comments?cursor=`), and blockedBy/blocks, as `GET …/issues/:number`; a sealed one you cannot see is not found; draws the `ui://forge/issue` card: title, state, assignee, labels and what it waits for, never the body or comments | | |
| 102 | 102 | | `issue_teams` | `{ repo, number, teams? }`: the crew teams an issue names; with `teams` (at most two slugs, `[]` clears) names them, and each reviews the closing pull | yes | |
src/http/mcp/tools/hooks.ts +12 −4
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { canAdmin } from "../../../core/repos.ts"; |
| 2 | import { listWebhooks } from "../../../core/webhooks.ts"; | |
| 2 | import { getWebhook, listWebhooks, recentDeliveries } from "../../../core/webhooks.ts"; | |
| 3 | 3 | import { createWebhook, deleteWebhook } from "../../../actions/repos.ts"; |
| 4 | 4 | import { pageRows } from "../page.ts"; |
| 5 | 5 | import { good, bad, type Answer, type Args, type Tool, REPO_ARG, repoArg, isAnswer, str } from "../shared.ts"; |
| @@ -20,13 +20,13 @@ const notAdmin = (): Answer => bad("not_found", "no such app or repository, or y | ||
| 20 | 20 | |
| 21 | 21 | export const tool: Tool = { |
| 22 | 22 | name: "hooks", |
| 23 | doc: "`{ repo, action, url, secret, kinds, id }`: `list` (pages: `limit` 1–200, `cursor`; `{ items, next_cursor }`), `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", | |
| 23 | doc: "`{ repo, action, url, secret, kinds, id }`: `list` (pages: `limit` 1–200, `cursor`; `{ items, next_cursor }`), `add` (`secret`, 16–200 characters, is required) or `remove` the repository's webhooks, as `…/hooks`; `deliveries` with `id` answers that hook's last 20, newest first (`{ items: [{ id, state, attempts, lastStatus, lastError, kind }] }`); an admin-scope token on a repository you administer, and a secret is never shown", | |
| 24 | 24 | description: "List, add (`url`, `secret`, `kinds`) or remove (`id`) webhooks; admins.", |
| 25 | 25 | inputSchema: { |
| 26 | 26 | type: "object", |
| 27 | 27 | properties: { |
| 28 | 28 | repo: REPO_ARG, |
| 29 | action: { type: "string", enum: ["list", "add", "remove"] }, | |
| 29 | action: { type: "string", enum: ["list", "add", "remove", "deliveries"] }, | |
| 30 | 30 | url: { type: "string", maxLength: 2048 }, |
| 31 | 31 | kinds: { type: "string", maxLength: 200 }, |
| 32 | 32 | secret: { type: "string", maxLength: 200 }, |
| @@ -46,6 +46,14 @@ export const tool: Tool = { | ||
| 46 | 46 | if (!canAdmin(ctx.app.db, repo, user)) return notAdmin(); |
| 47 | 47 | return pageRows(listWebhooks(ctx.app.db, repo.id), (h) => h.id, args); |
| 48 | 48 | } |
| 49 | if (action === "deliveries") { | |
| 50 | const long = hookTooLong(args); | |
| 51 | if (long !== undefined) return long; | |
| 52 | if (!canAdmin(ctx.app.db, repo, user)) return notAdmin(); | |
| 53 | const hook = getWebhook(ctx.app.db, repo.id, str(args["id"])); | |
| 54 | if (hook === undefined) return bad("not_found", "no such webhook here"); | |
| 55 | return good({ items: recentDeliveries(ctx.app.db, hook.id, 20) }); | |
| 56 | } | |
| 49 | 57 | if (action === "add") { |
| 50 | 58 | const long = hookTooLong(args); |
| 51 | 59 | if (long !== undefined) return long; |
| @@ -70,6 +78,6 @@ export const tool: Tool = { | ||
| 70 | 78 | const removed = deleteWebhook(ctx.app, user, repo, str(args["id"])); |
| 71 | 79 | return removed.ok ? good({}) : bad("not_found", "no such webhook here"); |
| 72 | 80 | } |
| 73 | return bad("invalid", "action is list, add or remove"); | |
| 81 | return bad("invalid", "action is list, add, remove or deliveries"); | |
| 74 | 82 | }, |
| 75 | 83 | }; |
test/mcp-hook-deliveries.test.ts added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | /** `hooks` action `deliveries`: a hook's last deliveries, behind the same gate as `list`. */ | |
| 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 hook deliveries", () => { | |
| 14 | let dataDir: string; | |
| 15 | let app: App; | |
| 16 | let servers: Servers; | |
| 17 | let aliceAdmin: string; | |
| 18 | let bobAdmin: string; | |
| 19 | let hookId: string; | |
| 20 | let otherHookId: string; | |
| 21 | let id = 1; | |
| 22 | ||
| 23 | const tool = async (token: string, args: unknown) => { | |
| 24 | const res = await fetch(`${servers.url}/mcp`, { | |
| 25 | method: "POST", | |
| 26 | headers: { | |
| 27 | "content-type": "application/json", | |
| 28 | accept: "application/json, text/event-stream", | |
| 29 | authorization: `Bearer ${token}`, | |
| 30 | }, | |
| 31 | body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "hooks", arguments: args } }), | |
| 32 | }); | |
| 33 | const body = (await res.json()) as any; | |
| 34 | const text: string = body.result.content[0].text; | |
| 35 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 36 | }; | |
| 37 | ||
| 38 | const deliver = (hook: string, n: number, state: string, status: number | null, error: string | null) => { | |
| 39 | app.db.run("INSERT INTO event (id, kind, payload) VALUES (?1, 'push', '{\"secretpayload\":1}')", [`ev${n}`]); | |
| 40 | app.db.run( | |
| 41 | "INSERT INTO webhook_delivery (id, webhook_id, event_id, state, attempts, last_status, last_error) VALUES (?1, ?2, ?3, ?4, 2, ?5, ?6)", | |
| 42 | [`d${String(n).padStart(3, "0")}`, hook, `ev${n}`, state, status, error], | |
| 43 | ); | |
| 44 | }; | |
| 45 | ||
| 46 | beforeAll(async () => { | |
| 47 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-hookdel-")); | |
| 48 | app = createApp({ dataDir, port: 0 }); | |
| 49 | servers = await serve(app); | |
| 50 | const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 51 | if (!a.ok) throw new Error(a.error); | |
| 52 | const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" }); | |
| 53 | if (!b.ok) throw new Error(b.error); | |
| 54 | promote(app.db, b.value); | |
| 55 | aliceAdmin = createToken(app.db, a.value.id, "a", "admin").token; | |
| 56 | bobAdmin = createToken(app.db, b.value.id, "b", "admin").token; | |
| 57 | for (const [owner, name] of [[a.value, "game"], [b.value, "other"]] as const) { | |
| 58 | const made = await createRepo(app, owner, { name, visibility: "public" }); | |
| 59 | if (!made.ok) throw new Error(String(made.error)); | |
| 60 | } | |
| 61 | const h = await tool(aliceAdmin, { repo: "alice/game", action: "add", url: "https://example.com/h", secret: "s3cret-value-long-enough" }); | |
| 62 | hookId = h.value.id; | |
| 63 | app.db.run("DELETE FROM webhook_delivery"); | |
| 64 | const o = await tool(bobAdmin, { repo: "bob/other", action: "add", url: "https://example.com/o", secret: "s3cret-value-long-enough" }); | |
| 65 | otherHookId = o.value.id; | |
| 66 | }, 30_000); | |
| 67 | ||
| 68 | afterAll(async () => { | |
| 69 | servers.stop(); | |
| 70 | app.close(); | |
| 71 | await rm(dataDir, { recursive: true, force: true }); | |
| 72 | }); | |
| 73 | ||
| 74 | test("an ok and a failed delivery come back newest first with status and reason", async () => { | |
| 75 | deliver(hookId, 1, "ok", 200, null); | |
| 76 | deliver(hookId, 2, "failed", null, "refused: private address"); | |
| 77 | const got = await tool(aliceAdmin, { repo: "alice/game", action: "deliveries", id: hookId }); | |
| 78 | expect(got.value.items).toEqual([ | |
| 79 | { id: "d002", state: "failed", attempts: 2, lastStatus: null, lastError: "refused: private address", kind: "push" }, | |
| 80 | { id: "d001", state: "ok", attempts: 2, lastStatus: 200, lastError: null, kind: "push" }, | |
| 81 | ]); | |
| 82 | const text = JSON.stringify(got); | |
| 83 | expect(text).not.toContain("s3cret-value-long-enough"); | |
| 84 | expect(text).not.toContain("secretpayload"); | |
| 85 | expect(text).not.toContain("signature"); | |
| 86 | }); | |
| 87 | ||
| 88 | test("at most 20 are answered", async () => { | |
| 89 | for (let n = 3; n < 30; n++) deliver(hookId, n, "ok", 200, null); | |
| 90 | const got = await tool(aliceAdmin, { repo: "alice/game", action: "deliveries", id: hookId }); | |
| 91 | expect(got.value.items.length).toBe(20); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a non-admin and another repository's hook are refused", async () => { | |
| 95 | deliver(otherHookId, 100, "ok", 200, null); | |
| 96 | expect((await tool(bobAdmin, { repo: "alice/game", action: "deliveries", id: hookId })).error).toContain("not_found"); | |
| 97 | expect((await tool(aliceAdmin, { repo: "alice/game", action: "deliveries", id: otherHookId })).error).toContain("not_found"); | |
| 98 | expect((await tool(aliceAdmin, { repo: "alice/game", action: "deliveries", id: "nope" })).error).toContain("not_found"); | |
| 99 | expect((await tool(aliceAdmin, { repo: "alice/game", action: "deliveries", id: "i".repeat(65) })).error).toContain("id is text"); | |
| 100 | }); | |
| 101 | }); |
new above
offline