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

Merged opened this

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

Merged

scout/forge:scout/7871-mcp-hooks-takes-action-deliveries-with-a → main 2 commits fork

  • Merged. scout/7871-mcp-hooks-takes-action-deliveries-with-a was merged into main as 5319afa.

Review

Sign in to review this change.
3 files changed +114 −5
docs/MCP.md +1 −1
@@ -96,7 +96,7 @@ for a tunnel and a forge that sleeps.
9696 | `health` | how this forge is: the version and commit it runs, draining or not, the rollout and its incidents | |
9797 | `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 | |
9898 | `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 |
100100 | `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 |
101101 | `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 | |
102102 | `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 @@
11 import { canAdmin } from "../../../core/repos.ts";
2 import { listWebhooks } from "../../../core/webhooks.ts";
2 import { getWebhook, listWebhooks, recentDeliveries } from "../../../core/webhooks.ts";
33 import { createWebhook, deleteWebhook } from "../../../actions/repos.ts";
44 import { pageRows } from "../page.ts";
55 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
2020
2121 export const tool: Tool = {
2222 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",
2424 description: "List, add (`url`, `secret`, `kinds`) or remove (`id`) webhooks; admins.",
2525 inputSchema: {
2626 type: "object",
2727 properties: {
2828 repo: REPO_ARG,
29 action: { type: "string", enum: ["list", "add", "remove"] },
29 action: { type: "string", enum: ["list", "add", "remove", "deliveries"] },
3030 url: { type: "string", maxLength: 2048 },
3131 kinds: { type: "string", maxLength: 200 },
3232 secret: { type: "string", maxLength: 200 },
@@ -46,6 +46,14 @@ export const tool: Tool = {
4646 if (!canAdmin(ctx.app.db, repo, user)) return notAdmin();
4747 return pageRows(listWebhooks(ctx.app.db, repo.id), (h) => h.id, args);
4848 }
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 }
4957 if (action === "add") {
5058 const long = hookTooLong(args);
5159 if (long !== undefined) return long;
@@ -70,6 +78,6 @@ export const tool: Tool = {
7078 const removed = deleteWebhook(ctx.app, user, repo, str(args["id"]));
7179 return removed.ok ? good({}) : bad("not_found", "no such webhook here");
7280 }
73 return bad("invalid", "action is list, add or remove");
81 return bad("invalid", "action is list, add, remove or deliveries");
7482 },
7583 };
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs