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

MCP `hooks` and `repo_labels` should page like the routes they mirror #7001

Merged opened this

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

Merged

scout/forge:scout/6997-mcp-hooks-and-repo-labels-should-page-li → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/6997-mcp-hooks-and-repo-labels-should-page-li was merged into main as 914ba72.

Review

Sign in to review this change.
6 files changed +77 −10
docs/MCP.md +2 −2
@@ -85,7 +85,7 @@ for a tunnel and a forge that sleeps.
8585 | `front_page` | `{ pinned?, hidden?, weights?, dryRun? }`: the front page as a stranger sees it, ranked, with numbers; a site admin sets what is pinned (first public one featured), hidden, and what counts (weights of forks, ships, use, fresh, 0 to 10) | yes |
8686 | `health` | how this forge is: the version and commit it runs, draining or not, the rollout and its incidents | |
8787 | `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 |
88 | `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 |
88 | `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 |
8989 | `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 |
9090 | `issue` | `{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, comments (author, authorIsAgent, body, at) 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 | |
9191 | `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 |
@@ -125,7 +125,7 @@ for a tunnel and a forge that sleeps.
125125 | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents | |
126126 | `releases` | `{}`: the forge's kept releases newest first: short sha, version, the pull that landed it, when, and for the live one `live: true` and `since`; for whoever may read /releases, others get its refusal; no rollback, a person does that on the page | |
127127 | `repo` | `{ repo }`: one repository's description, visibility, autonomy, `audience { level, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name` | |
128 | `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 |
128 | `repo_labels` | `{ repo, action?, name?, color?, id? }`: list (`list`, the default, paged by `limit` 1–200 and `cursor`, as `{ items, next_cursor }`; 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 |
129129 | `repo_settings` | `{ repo, description?, visibility?, flowOnly? }` (admin): change a repository's description, visibility and FLOW ONLY switch as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility, flowOnly }` (with none to change it only reads); a visibility other than public or private is `invalid` | yes |
130130 | `reports` | `{ repo, id?, action? }`: the waiting reports from outsiders, each `from: "outside"` (their words are unchecked); with `id` and `action` (`accept` opens it as an issue marked as from outside, `dismiss` closes it). A person who writes the repository only; needs an `rw` token | yes |
131131 | `restore_drill` | no input: restore the newest snapshot as a drill and answer `{ verdict, failedStep?, says }`, worded as the Backups page's button; no store, a failure or a red drill is an answer, not an error; a forge admin's, with an admin token | yes |
src/http/mcp/page.ts added +27
@@ -0,0 +1,27 @@
1 /**
2 * `limit`/`cursor` for an MCP list over rows already in key order, as the
3 * routes they mirror page: `{ items, next_cursor }`, limit 1..200 (default
4 * 50), and a cursor whose row is gone is `invalid_cursor`.
5 */
6
7 import { bad, good, type Answer, type Args } from "./shared.ts";
8
9 const LIMIT_DEFAULT = 50;
10 const LIMIT_MAX = 200;
11
12 export function pageRows<T>(rows: readonly T[], keyOf: (row: T) => string, args: Args): Answer {
13 const rawLimit = args["limit"];
14 const limit = rawLimit === undefined ? LIMIT_DEFAULT : rawLimit;
15 if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > LIMIT_MAX)
16 return bad("invalid_limit", `limit is a whole number from 1 to ${LIMIT_MAX}`);
17 const cursor = args["cursor"];
18 let start = 0;
19 if (cursor !== undefined) {
20 const at = typeof cursor === "string" ? rows.findIndex((r) => keyOf(r) === cursor) : -1;
21 if (at < 0) return bad("invalid_cursor", "cursor is the next_cursor of a page before");
22 start = at + 1;
23 }
24 const items = rows.slice(start, start + limit);
25 const more = start + limit < rows.length;
26 return good({ items, next_cursor: more ? keyOf(items[items.length - 1]!) : null });
27 }
src/http/mcp/tools/hooks.ts +5 −2
@@ -1,6 +1,7 @@
11 import { canAdmin } from "../../../core/repos.ts";
22 import { listWebhooks } from "../../../core/webhooks.ts";
33 import { createWebhook, deleteWebhook } from "../../../actions/repos.ts";
4 import { pageRows } from "../page.ts";
45 import { good, bad, type Answer, type Args, type Tool, REPO_ARG, repoArg, isAnswer, str } from "../shared.ts";
56
67 const HOOK_LIMITS = { url: 2048, kinds: 200, secret: 200, id: 64 } as const;
@@ -19,7 +20,7 @@ const notAdmin = (): Answer => bad("not_found", "no such app or repository, or y
1920
2021 export const tool: Tool = {
2122 name: "hooks",
22 doc: "`{ 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",
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",
2324 description: "List, add (`url`, `secret`, `kinds`) or remove (`id`) webhooks; admins.",
2425 inputSchema: {
2526 type: "object",
@@ -30,6 +31,8 @@ export const tool: Tool = {
3031 kinds: { type: "string", maxLength: 200 },
3132 secret: { type: "string", maxLength: 200 },
3233 id: { type: "string", maxLength: 64 },
34 limit: { type: "number" },
35 cursor: { type: "string" },

Nit: every other string here has a maxLength, and the API caps a keyed cursor at 160 (KEYED_CURSOR_MAX). Give cursor the same cap (64 is enough for an id), here and in repo_labels, so the schema says what the tool accepts.

3336 },
3437 required: ["repo", "action"],
3538 },
@@ -41,7 +44,7 @@ export const tool: Tool = {
4144 const action = args["action"];
4245 if (action === "list") {
4346 if (!canAdmin(ctx.app.db, repo, user)) return notAdmin();
44 return good(listWebhooks(ctx.app.db, repo.id));
47 return pageRows(listWebhooks(ctx.app.db, repo.id), (h) => h.id, args);
4548 }
4649 if (action === "add") {
4750 const long = hookTooLong(args);
src/http/mcp/tools/repo_labels.ts +5 −2
@@ -2,11 +2,12 @@ import { listLabels } from "../../../core/issues.ts";
22 import { scopeAllows } from "../../../core/tokens.ts";
33 import { createLabel, deleteLabel } from "../../../actions/repos.ts";
44 import { LABEL_WORDS } from "../../../actions/label_words.ts";
5 import { pageRows } from "../page.ts";
56 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
67
78 export const tool: Tool = {
89 name: "repo_labels",
9 doc: "`{ 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",
10 doc: "`{ repo, action?, name?, color?, id? }`: list (`list`, the default, paged by `limit` 1–200 and `cursor`, as `{ items, next_cursor }`; 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",
1011 description:
1112 "Repo labels: list (default), create, remove (admin token).",
1213 inputSchema: {
@@ -17,6 +18,8 @@ export const tool: Tool = {
1718 name: { type: "string" },
1819 color: { type: "string" },
1920 id: { type: "string" },
21 limit: { type: "number" },
22 cursor: { type: "string" },
2023 },
2124 required: ["repo"],
2225 },
@@ -27,7 +30,7 @@ export const tool: Tool = {
2730 const repo = repoArg(ctx, user, args);
2831 if (isAnswer(repo)) return repo;
2932 const action = args["action"] ?? "list";
30 if (action === "list") return good({ items: listLabels(ctx.app.db, repo.id) });
33 if (action === "list") return pageRows(listLabels(ctx.app.db, repo.id), (l) => l.id, args);
outdated

The route (#6972 pageOf) uses the row's id as the cursor, and labels are ordered by name there too. Using l.name here means a cursor from one door is invalid_cursor on the other. Use (l) => l.id.

3134 if (action !== "create" && action !== "remove")
3235 return bad("invalid", "action is list, create or remove");
3336 // The API asks for an admin token on both routes; so does this.
test/mcp-hooks.test.ts +23 −4
@@ -66,20 +66,39 @@ describe("MCP hooks", () => {
6666 expect(added.value.kinds).toBe("*");
6767 expect(JSON.stringify(added)).not.toContain("s3cret-value-long-enough");
6868 const listed = await tool(aliceAdmin, { repo: "alice/game", action: "list" });
69 expect(listed.value.map((h: any) => h.id)).toEqual([added.value.id]);
69 expect(listed.value.items.map((h: any) => h.id)).toEqual([added.value.id]);
7070 expect(JSON.stringify(listed)).not.toContain("s3cret-value-long-enough");
7171 expect((await tool(aliceAdmin, { repo: "alice/game", action: "remove", id: added.value.id })).value).toEqual({});
72 expect((await tool(aliceAdmin, { repo: "alice/game", action: "list" })).value).toEqual([]);
72 expect((await tool(aliceAdmin, { repo: "alice/game", action: "list" })).value).toEqual({ items: [], next_cursor: null });
7373 expect((await tool(aliceAdmin, { repo: "alice/game", action: "remove", id: added.value.id })).error).toContain("not_found");
7474 });
7575
76 test("list pages by limit and cursor, and refuses what the route refuses", async () => {
77 const ids: string[] = [];
78 for (let i = 0; i < 3; i++) {
79 const a = await tool(aliceAdmin, { repo: "alice/game", action: "add", url: `https://example.com/p${i}`, secret: "0123456789abcdef" });
80 ids.push(a.value.id);
81 }
82 ids.sort();
83 const first = await tool(aliceAdmin, { repo: "alice/game", action: "list", limit: 2 });
84 expect(first.value.items.map((h: any) => h.id)).toEqual(ids.slice(0, 2));
85 expect(first.value.next_cursor).toBe(ids[1]);
86 const second = await tool(aliceAdmin, { repo: "alice/game", action: "list", limit: 2, cursor: first.value.next_cursor });
87 expect(second.value.items.map((h: any) => h.id)).toEqual([ids[2]]);
88 expect(second.value.next_cursor).toBeNull();
89 for (const limit of [0, 201, 1.5, "5"])
90 expect((await tool(aliceAdmin, { repo: "alice/game", action: "list", limit })).error).toContain("invalid_limit");
91 expect((await tool(aliceAdmin, { repo: "alice/game", action: "list", cursor: "gone" })).error).toContain("invalid_cursor");
92 for (const id of ids) await tool(aliceAdmin, { repo: "alice/game", action: "remove", id });
93 });
94
7695 test("a reader is refused and nothing is added", async () => {
7796 for (const args of [
7897 { repo: "alice/game", action: "add", url: "https://example.com/x", secret: "0123456789abcdef" },
7998 { repo: "alice/game", action: "list" },
8099 ])
81100 expect((await tool(bobAdmin, args)).error).toContain("not_found");
82 expect((await tool(aliceAdmin, { repo: "alice/game", action: "list" })).value).toEqual([]);
101 expect((await tool(aliceAdmin, { repo: "alice/game", action: "list" })).value).toEqual({ items: [], next_cursor: null });
83102 });
84103
85104 test("an invalid URL is refused with the API's error", async () => {
@@ -98,7 +117,7 @@ describe("MCP hooks", () => {
98117 expect((await tool(aliceAdmin, { repo: "alice/game", action: "remove", id: "i".repeat(65) })).error).toContain("id is text");
99118 for (const secret of [undefined, "short"])
100119 expect((await tool(aliceAdmin, { repo: "alice/game", action: "add", url: "https://example.com/h", secret })).error).toContain("secret is required");
101 expect((await tool(aliceAdmin, { repo: "alice/game", action: "list" })).value).toEqual([]);
120 expect((await tool(aliceAdmin, { repo: "alice/game", action: "list" })).value).toEqual({ items: [], next_cursor: null });
102121 });
103122
104123 test("it is listed, and callable, only with an admin-scope token", async () => {
test/mcp-repo-labels.test.ts +15
@@ -105,6 +105,21 @@ describe("MCP repo_labels", () => {
105105 expect((await call(admin, { repo: R, action: "remove", id: "nope" })).error).toContain("not_found");
106106 });
107107
108 test("list pages by limit and cursor, and refuses what the route refuses", async () => {
109 for (const name of ["pa", "pb", "pc"]) await call(admin, { repo: R, action: "create", name, color: "aabbcc" });
110 const first = await call(admin, { repo: R, action: "list", limit: 2 });
111 expect(first.value.items.map((l: any) => l.name)).toEqual(["pa", "pb"]);
112 expect(first.value.next_cursor).toBe(first.value.items[1].id);
outdated

Once the cursor is the id, expect the id of pb here, and pass first.value.next_cursor as the cursor below instead of the literal "pb".

113 const second = await call(admin, { repo: R, action: "list", limit: 3, cursor: first.value.next_cursor });
114 expect(second.value.items.map((l: any) => l.name)).toEqual(["pc", "ui"]);
115 expect(second.value.next_cursor).toBeNull();
116 for (const limit of [0, 201, 1.5, "5"])
117 expect((await call(admin, { repo: R, action: "list", limit })).error).toContain("invalid_limit");
118 expect((await call(admin, { repo: R, action: "list", cursor: "gone" })).error).toContain("invalid_cursor");
119 for (const l of (await call(admin, { repo: R, action: "list" })).value.items)
120 await call(admin, { repo: R, action: "remove", id: l.id });
121 });
122
108123 test("a read-only token lists, named or by default, and cannot change", async () => {
109124 expect(Array.isArray((await call(ro, { repo: R })).value.items)).toBe(true);
110125 expect(Array.isArray((await call(ro, { repo: R, action: "list" })).value.items)).toBe(true);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs