Public: anyone can read this; the crew can change it.
MCP `egress_grants`: list, grant and revoke an app's private destinations, with the same rules as the page #5659
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3434-mcp-egress-grants-list-grant-and-revoke → main 4 commits fork 1 thread · 1 unresolved
4 files
changed +130
−22
docs/MCP.md +1
| @@ -74,6 +74,7 @@ for a tunnel and a forge that sleeps. | ||
| 74 | 74 | | `asked` | `{ repo?, show?, order? }`: what people asked the crew for, by default nothing merged yet and oldest first: who and when, the pulls that close it, the crew's last activity, waiting/building/shipped/closed, stale when not shipped in three days and flagged when stale with nothing built; repositories you can write | | |
| 75 | 75 | | `assigned` | `{ limit? }`: the open issues and pulls assigned to you in every repository you can still read (repository, number, title, kind, state), as `GET /api/v1/user/assigned` | | |
| 76 | 76 | | `app_members` | `{ repo, members?, dryRun? }`: the people a private app names, who may enter it and its previews and nothing else; a writer (never an agent) changes the list with an admin-scope token | yes | |
| 77 | | `egress_grants` | `{ repo, op?, destination? }`: the private `host:port` destinations an app's forge.json asks for and which are granted; `op` grant or revoke by a site admin (never an agent), and every op needs an admin-scope token; the same as the settings page and `/api/v1/repos/:owner/:name/egress-grants` | yes | | |
| 77 | 78 | | `explore` | `{ query? }`: the apps and people you can see, ranked by forks, ships, use and freshness, with their numbers; `kind:` `sort:` and words in one line | | |
| 78 | 79 | | `search` | `{ q, kind?, repo? }`: issues, pulls, repositories and code you can read, the same results as /search; `repo` keeps to one | | |
| 79 | 80 | | `feed` | `{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed` | | |
src/http/mcp.ts +70 −20
| @@ -234,6 +234,11 @@ import { | ||
| 234 | 234 | readAppMembers, |
| 235 | 235 | setAppMembersThrough, |
| 236 | 236 | } from "../actions/app_members.ts"; |
| 237 | import { | |
| 238 | egressView, | |
| 239 | grantAppEgress, | |
| 240 | revokeAppEgress, | |
| 241 | } from "../actions/egress_grants.ts"; | |
| 237 | 242 | import { |
| 238 | 243 | explore, |
| 239 | 244 | exploreLine, |
| @@ -250,7 +255,7 @@ import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts"; | ||
| 250 | 255 | import { deleteOriginApp, listOriginApps, registerOriginApp } from "../actions/origins.ts"; |
| 251 | 256 | import { connectMessage } from "./api.ts"; |
| 252 | 257 | import { resourceMetadataUrl } from "./oauth.ts"; |
| 253 | import { body, boundedBody, json } from "./respond.ts"; | |
| 258 | import { body, boundedBody, FORBIDDEN_MESSAGE, json } from "./respond.ts"; | |
| 254 | 259 | import { sizesView } from "../spawn/sizes.ts"; |
| 255 | 260 | import type { Router } from "./router.ts"; |
| 256 | 261 | import { mayReadRuns, runForReader, runLog } from "../actions/runs.ts"; |
| @@ -518,10 +523,10 @@ const TOOLS: readonly Tool[] = [ | ||
| 518 | 523 | type: "object", |
| 519 | 524 | properties: { |
| 520 | 525 | name: { type: "string", maxLength: 100 }, |
| 521 | description: { type: "string", maxLength: 4000, description: "What it should become: the first issue." }, | |
| 526 | description: { type: "string", maxLength: 4000, description: "The first issue." }, | |
| 522 | 527 | owner: { type: "string", maxLength: 39 }, |
| 523 | visibility: { type: "string", enum: ["public", "private"], description: "Private unless public." }, | |
| 524 | agent: { type: "string", maxLength: 39, description: "Agent for the crew and first issue." }, | |
| 528 | visibility: { type: "string", enum: ["public", "private"], description: "Default private." }, | |
| 529 | agent: { type: "string", maxLength: 39, description: "Agent for the first issue." }, | |
| 525 | 530 | pick: { type: "boolean", description: "Pick the agent from the text." }, |
| 526 | 531 | }, |
| 527 | 532 | required: ["name"], |
| @@ -1300,7 +1305,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1300 | 1305 | }, |
| 1301 | 1306 | { |
| 1302 | 1307 | name: "secrets", |
| 1303 | description: "Admin: list|set|remove sealed secrets; values are write-only.", | |
| 1308 | description: "Admin: list|set|remove sealed secrets.", | |
| 1304 | 1309 | inputSchema: { |
| 1305 | 1310 | type: "object", |
| 1306 | 1311 | properties: { |
| @@ -1371,7 +1376,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1371 | 1376 | }, |
| 1372 | 1377 | { |
| 1373 | 1378 | name: "org_teams", |
| 1374 | description: "An organization owner's teams: create one, add a member, or remove a team.", | |
| 1379 | description: "An organization's teams: create, add a member, remove.", | |
| 1375 | 1380 | inputSchema: { |
| 1376 | 1381 | type: "object", |
| 1377 | 1382 | properties: { |
| @@ -1656,7 +1661,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1656 | 1661 | }, |
| 1657 | 1662 | { |
| 1658 | 1663 | name: "invites", |
| 1659 | description: "List open invites, or revoke one by `id`. Person admins only.", | |
| 1664 | description: "List open invites, or revoke one by `id`.", | |
| 1660 | 1665 | inputSchema: { |
| 1661 | 1666 | type: "object", |
| 1662 | 1667 | properties: { |
| @@ -1732,7 +1737,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1732 | 1737 | }, |
| 1733 | 1738 | { |
| 1734 | 1739 | name: "account_suspend", |
| 1735 | description: "Suspend a person's account, or unsuspend it with `suspended: false`.", | |
| 1740 | description: "Suspend an account, or `suspended: false` to lift it.", | |
| 1736 | 1741 | inputSchema: { |
| 1737 | 1742 | type: "object", |
| 1738 | 1743 | properties: { username: { type: "string" }, suspended: { type: "boolean" } }, |
| @@ -1763,8 +1768,8 @@ const TOOLS: readonly Tool[] = [ | ||
| 1763 | 1768 | type: "object", |
| 1764 | 1769 | properties: { |
| 1765 | 1770 | username: { type: "string" }, |
| 1766 | dailyUsd: { type: ["number", "null"], description: "0.01–10000; null clears (with perRunUsd null)." }, | |
| 1767 | perRunUsd: { type: ["number", "null"], description: "0.01–10000; null clears (with dailyUsd null)." }, | |
| 1771 | dailyUsd: { type: ["number", "null"], description: "0.01–10000; null clears." }, | |
| 1772 | perRunUsd: { type: ["number", "null"], description: "0.01–10000; null clears." }, | |
| 1768 | 1773 | dryRun: { type: "boolean", description: "false to apply" }, |
| 1769 | 1774 | }, |
| 1770 | 1775 | required: ["username", "dailyUsd", "perRunUsd"], |
| @@ -1799,7 +1804,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1799 | 1804 | }, |
| 1800 | 1805 | { |
| 1801 | 1806 | name: "account_verify", |
| 1802 | description: "Mark a person's email verified, as an admin who knows them.", | |
| 1807 | description: "Mark a person's email verified (admin).", | |
| 1803 | 1808 | inputSchema: { |
| 1804 | 1809 | type: "object", |
| 1805 | 1810 | properties: { username: { type: "string" } }, |
| @@ -2093,7 +2098,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2093 | 2098 | }, |
| 2094 | 2099 | { |
| 2095 | 2100 | name: "events", |
| 2096 | description: "A repository's events, paged.", | |
| 2101 | description: "Repository events.", | |
| 2097 | 2102 | inputSchema: { |
| 2098 | 2103 | type: "object", |
| 2099 | 2104 | properties: { |
| @@ -2265,7 +2270,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2265 | 2270 | type: "object", |
| 2266 | 2271 | properties: { |
| 2267 | 2272 | repo: REPO_ARG, |
| 2268 | ref: { type: "string", maxLength: 255, description: "Branch, tag or commit; default branch." }, | |
| 2273 | ref: { type: "string", maxLength: 255, description: "Branch, tag or commit." }, | |
| 2269 | 2274 | limit: { type: "integer", minimum: 1, maximum: 100 }, |
| 2270 | 2275 | }, |
| 2271 | 2276 | required: ["repo"], |
| @@ -2435,7 +2440,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2435 | 2440 | }, |
| 2436 | 2441 | { |
| 2437 | 2442 | name: "agent", |
| 2438 | description: "Admin: create|update|remove a crew agent.", | |
| 2443 | description: "Admin: create|update|remove an agent.", | |
| 2439 | 2444 | inputSchema: { |
| 2440 | 2445 | type: "object", |
| 2441 | 2446 | properties: { |
| @@ -2827,7 +2832,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2827 | 2832 | }, |
| 2828 | 2833 | { |
| 2829 | 2834 | name: "assigned", |
| 2830 | description: "The open issues and pulls assigned to you in every repository you can read.", | |
| 2835 | description: "Open issues and pulls assigned to you.", | |
| 2831 | 2836 | inputSchema: { |
| 2832 | 2837 | type: "object", |
| 2833 | 2838 | properties: { limit: { type: "integer", minimum: 1, maximum: 50 } }, |
| @@ -2887,6 +2892,51 @@ const TOOLS: readonly Tool[] = [ | ||
| 2887 | 2892 | return answer(setAppMembersThrough(ctx.app, user, repo, { members, dryRun })); |
| 2888 | 2893 | }, |
| 2889 | 2894 | }, |
| 2895 | { | |
| 2896 | name: "egress_grants", | |
| 2897 | description: | |
| 2898 | "An app's private destinations and their grants: list, grant or revoke (site admin). See docs/MCP.md.", | |
| 2899 | inputSchema: { | |
| 2900 | type: "object", | |
| 2901 | properties: { | |
| 2902 | repo: REPO_ARG, | |
| 2903 | op: { type: "string", enum: ["list", "grant", "revoke"] }, | |
| 2904 | destination: { type: "string" }, | |
| 2905 | }, | |
| 2906 | required: ["repo"], | |
| 2907 | }, | |
| 2908 | writes: true, | |
| 2909 | readsWithout: "op", | |
| 2910 | async run(ctx, user, args) { | |
| 2911 | const repo = repoArg(ctx, user, args); | |
| 2912 | if (isAnswer(repo)) return repo; | |
| 2913 | const op = args["op"] === undefined ? "list" : str(args["op"]); | |
| 2914 | if (op !== "list" && op !== "grant" && op !== "revoke") | |
| 2915 | return bad("invalid", "op is list, grant or revoke"); | |
| 2916 | // The API's rule: reading or changing grants takes an admin-scope token. | |
| 2917 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) | |
| 2918 | return bad("forbidden", "egress grants need an admin-scope token"); | |
| 2919 | const refused = (error: string): Answer => { | |
| 2920 | if (error === "not_found") | |
| 2921 | return bad("not_found", "no such app or repository (owner/name)"); | |
| 2922 | if (error === "forbidden") return bad("forbidden", FORBIDDEN_MESSAGE); | |
| 2923 | return bad(error, error); | |
| 2924 | }; | |
| 2925 | if (op === "list") { | |
| 2926 | const view = await egressView(ctx.app, user, repo); | |
| 2927 | return view.ok | |
| 2928 | ? good({ items: view.value.rows, changes: view.value.changes }) | |
| 2929 | : refused(view.error); | |
| 2930 | } | |
| 2931 | const destination = str(args["destination"]); | |
| 2932 | if (op === "grant") { | |
| 2933 | const granted = await grantAppEgress(ctx.app, user, repo, destination); | |
| 2934 | return granted.ok ? good(granted.value) : refused(granted.error); | |
| 2935 | } | |
| 2936 | const revoked = revokeAppEgress(ctx.app, user, repo, destination); | |
| 2937 | return revoked.ok ? good({ revoked: revoked.value }) : refused(revoked.error); | |
| 2938 | }, | |
| 2939 | }, | |
| 2890 | 2940 | { |
| 2891 | 2941 | name: "explore", |
| 2892 | 2942 | description: |
| @@ -3131,7 +3181,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 3131 | 3181 | hidden: { type: "array", items: { type: "string" } }, |
| 3132 | 3182 | weights: { |
| 3133 | 3183 | type: "object", |
| 3134 | description: "0–10: forks, ships, use, fresh.", | |
| 3184 | description: "0–10 each.", | |
| 3135 | 3185 | properties: { |
| 3136 | 3186 | forks: { type: "number", minimum: 0, maximum: 10 }, |
| 3137 | 3187 | ships: { type: "number", minimum: 0, maximum: 10 }, |
| @@ -3770,7 +3820,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 3770 | 3820 | }, |
| 3771 | 3821 | { |
| 3772 | 3822 | name: "wait_for", |
| 3773 | description: "Make an issue wait for another; `stop: true` ends it.", | |
| 3823 | description: "Make an issue wait for another; `stop` ends it.", | |
| 3774 | 3824 | inputSchema: { |
| 3775 | 3825 | type: "object", |
| 3776 | 3826 | properties: { |
| @@ -4103,7 +4153,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 4103 | 4153 | inputSchema: { |
| 4104 | 4154 | type: "object", |
| 4105 | 4155 | properties: { |
| 4106 | since: { type: "string", maxLength: 40, description: "ISO time; max 14 days." }, | |
| 4156 | since: { type: "string", maxLength: 40, description: "ISO time." }, | |
| 4107 | 4157 | ua: { type: "string", maxLength: 100, description: "User agent part." }, |
| 4108 | 4158 | path: { type: "string", maxLength: 200, description: "Path prefix." }, |
| 4109 | 4159 | }, |
| @@ -4486,7 +4536,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 4486 | 4536 | }, |
| 4487 | 4537 | { |
| 4488 | 4538 | name: "email_verify", |
| 4489 | description: "Send the caller a new email-verification link; same limits as the API.", | |
| 4539 | description: "Send yourself a new verification link.", | |
| 4490 | 4540 | inputSchema: { type: "object", properties: {} }, |
| 4491 | 4541 | writes: true, |
| 4492 | 4542 | async run(ctx, user) { |
| @@ -4516,7 +4566,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 4516 | 4566 | panel: { type: "integer", minimum: 0, maximum: 23, description: "One panel, from 0." }, |
| 4517 | 4567 | from: { type: "string", maxLength: 64 }, |
| 4518 | 4568 | to: { type: "string", maxLength: 64 }, |
| 4519 | step: { type: "string", maxLength: 16, description: "With panel: seconds per bucket, or a count of s/m/h/d." }, | |
| 4569 | step: { type: "string", maxLength: 16, description: "With panel: seconds per bucket, or 5m." }, | |
| 4520 | 4570 | }, |
| 4521 | 4571 | }, |
| 4522 | 4572 | writes: false, |
src/http/respond.ts +2 −2
| @@ -59,8 +59,8 @@ export const tooManyAttempts = (retryAfter: number) => | ||
| 59 | 59 | 429, |
| 60 | 60 | { "retry-after": String(retryAfter) }, |
| 61 | 61 | ); |
| 62 | export const forbidden = () => | |
| 63 | apiError("forbidden", 403, "your credentials lack the scope for this action"); | |
| 62 | export const FORBIDDEN_MESSAGE = "your credentials lack the scope for this action"; | |
| 63 | export const forbidden = () => apiError("forbidden", 403, FORBIDDEN_MESSAGE); | |
| 64 | 64 | // A path matching no route at all — distinct from a missing resource, so a |
| 65 | 65 | // client can tell a typo'd URL from a resource that genuinely isn't there. |
| 66 | 66 | // The message survives into the HTML 404 too (`src/web/chrome.ts`), as a |
test/mcp.test.ts +57
| @@ -234,6 +234,7 @@ describe("the forge over MCP", () => { | ||
| 234 | 234 | "asked", |
| 235 | 235 | "assigned", |
| 236 | 236 | "app_members", |
| 237 | "egress_grants", | |
| 237 | 238 | "explore", |
| 238 | 239 | "search", |
| 239 | 240 | "feed", |
| @@ -1004,6 +1005,62 @@ describe("the forge over MCP", () => { | ||
| 1004 | 1005 | expect(limitChanges(app.db)[0]).toMatchObject({ key }); |
| 1005 | 1006 | }); |
| 1006 | 1007 | |
| 1008 | test("egress_grants: list, grant and revoke for a site admin; a non-admin is refused as the API refuses", async () => { | |
| 1009 | const dest = "100.89.95.68:8101"; | |
| 1010 | const committed = await commitToBranch(app, alice, game, { | |
| 1011 | message: "Ask for the arm", | |
| 1012 | files: [{ path: "forge.json", content: JSON.stringify({ app: { run: "bun run src/server.ts" }, egress: [dest] }) }], | |
| 1013 | }); | |
| 1014 | if (!committed.ok) throw new Error(JSON.stringify(committed.error)); | |
| 1015 | const admin = createToken(app.db, alice.id, "admin", "admin").token; | |
| 1016 | const bobAdmin = createToken(app.db, bob.id, "bobadmin", "admin").token; | |
| 1017 | const repo = "alice/game"; | |
| 1018 | ||
| 1019 | const listed = await tool(admin, "egress_grants", { repo }); | |
| 1020 | expect(listed.value.items).toEqual([ | |
| 1021 | { destination: dest, asked: true, granted: false, grantedBy: null, grantedAt: null }, | |
| 1022 | ]); | |
| 1023 | ||
| 1024 | // Bob is no admin of the repository: told nothing of it. | |
| 1025 | expect((await tool(bobAdmin, "egress_grants", { repo })).error).toContain("not_found"); | |
| 1026 | // A token below admin scope may not read or change. | |
| 1027 | expect((await tool(ro, "egress_grants", { repo })).error).toContain("forbidden"); | |
| 1028 | expect((await tool(rw, "egress_grants", { repo, op: "grant", destination: dest })).error).toContain("forbidden"); | |
| 1029 | ||
| 1030 | const granted = await tool(admin, "egress_grants", { repo, op: "grant", destination: dest }); | |
| 1031 | expect(granted.value).toMatchObject({ destination: dest, granted: true, grantedBy: "alice" }); | |
| 1032 | expect((await tool(admin, "egress_grants", { repo, op: "grant", destination: "10.0.0.1:1" })).error).toContain("not_asked"); | |
| 1033 | ||
| 1034 | // The same words the API gives a repository admin who is no site admin. | |
| 1035 | const arm = await createRepo(app, bob, { name: "arm", visibility: "public", description: "" }); | |
| 1036 | if (!arm.ok) throw new Error(String(arm.error)); | |
| 1037 | await commitToBranch(app, bob, arm.value, { | |
| 1038 | message: "Ask for the arm", | |
| 1039 | files: [{ path: "forge.json", content: JSON.stringify({ app: { run: "bun run src/server.ts" }, egress: [dest] }) }], | |
| 1040 | }); | |
| 1041 | const refused = await tool(bobAdmin, "egress_grants", { repo: "bob/arm", op: "grant", destination: dest }); | |
| 1042 | const api = await fetch(`${base}/api/v1/repos/bob/arm/egress-grants`, { | |
| 1043 | method: "POST", | |
| 1044 | headers: { authorization: `Bearer ${bobAdmin}`, "content-type": "application/json" }, | |
| 1045 | body: JSON.stringify({ destination: dest }), | |
| 1046 | }); | |
| 1047 | expect(api.status).toBe(403); | |
| 1048 | expect(refused.error).toBe(`forbidden: ${((await api.json()) as any).message}`); | |
| 1049 | ||
| 1050 | // An agent, even one marked site admin, may not change a grant. | |
| 1051 | const bot = await createAgent(app, alice, { username: "egressbot", brief: "x" }); | |
| 1052 | if (!bot.ok) throw new Error(String(bot.error)); | |
| 1053 | app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(bot.value.user.id); | |
| 1054 | const botToken = createToken(app.db, bot.value.user.id, "bot", "admin").token; | |
| 1055 | const botGrant = await tool(botToken, "egress_grants", { repo: "bob/arm", op: "grant", destination: dest }); | |
| 1056 | expect(botGrant.error).toContain("forbidden"); | |
| 1057 | ||
| 1058 | const revoked = await tool(admin, "egress_grants", { repo, op: "revoke", destination: dest }); | |
| 1059 | expect(revoked.value).toEqual({ revoked: dest }); | |
| 1060 | expect((await tool(admin, "egress_grants", { repo, op: "revoke", destination: dest })).error).toContain("no_such_grant"); | |
| 1061 | expect((await tool(admin, "egress_grants", { repo })).value.items[0].granted).toBe(false); | |
| 1062 | }); | |
| 1063 | ||
| 1007 | 1064 | test("board_pins: list, pin, move, unpin for an admin person; an agent and a non-admin are refused", async () => { |
| 1008 | 1065 | const board = { title: "Pinnable", range: "24h", refresh: "off", panels: [ |
| 1009 | 1066 | { title: "One", chart: "number", query: { source: "runs", aggregate: "count" }, w: 1, h: 1 }, |
new above
offline
Optional: the tool already has a
needsAdminScope: trueflag, and 4 other tools use it. It hides the tool fromro/rwtokens in tools/list and refuses the call at dispatch. With it, this in-tool check isn't needed, tokens that will always be refused don't see the tool, and most callers' tools/list gets smaller.