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 #3653

Merged opened this

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

Merged

scout/forge:scout/3434-mcp-egress-grants-list-grant-and-revoke → main 4 commits fork 5 threads · 5 unresolved

  • Merged. scout/3434-mcp-egress-grants-list-grant-and-revoke was merged into main as 9167603.

Review

Sign in to review this change.
4 files changed +130 −22
docs/MCP.md +1
@@ -74,6 +74,7 @@ for a tunnel and a forge that sleeps.
7474 | `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 | |
7575 | `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` | |
7676 | `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 |
7778 | `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 | |
7879 | `search` | `{ q, kind?, repo? }`: issues, pulls, repositories and code you can read, the same results as /search; `repo` keeps to one | |
7980 | `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 {
234234 readAppMembers,
235235 setAppMembersThrough,
236236 } from "../actions/app_members.ts";
237 import {
238 egressView,
239 grantAppEgress,
240 revokeAppEgress,
241 } from "../actions/egress_grants.ts";
237242 import {
238243 explore,
239244 exploreLine,
@@ -250,7 +255,7 @@ import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts";
250255 import { deleteOriginApp, listOriginApps, registerOriginApp } from "../actions/origins.ts";
251256 import { connectMessage } from "./api.ts";
252257 import { resourceMetadataUrl } from "./oauth.ts";
253 import { body, boundedBody, json } from "./respond.ts";
258 import { body, boundedBody, FORBIDDEN_MESSAGE, json } from "./respond.ts";
254259 import { sizesView } from "../spawn/sizes.ts";
255260 import type { Router } from "./router.ts";
256261 import { mayReadRuns, runForReader, runLog } from "../actions/runs.ts";
@@ -518,10 +523,10 @@ const TOOLS: readonly Tool[] = [
518523 type: "object",
519524 properties: {
520525 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." },
522527 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." },
525530 pick: { type: "boolean", description: "Pick the agent from the text." },
526531 },
527532 required: ["name"],
@@ -1300,7 +1305,7 @@ const TOOLS: readonly Tool[] = [
13001305 },
13011306 {
13021307 name: "secrets",
1303 description: "Admin: list|set|remove sealed secrets; values are write-only.",
1308 description: "Admin: list|set|remove sealed secrets.",
13041309 inputSchema: {
13051310 type: "object",
13061311 properties: {
@@ -1371,7 +1376,7 @@ const TOOLS: readonly Tool[] = [
13711376 },
13721377 {
13731378 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.",
13751380 inputSchema: {
13761381 type: "object",
13771382 properties: {
@@ -1656,7 +1661,7 @@ const TOOLS: readonly Tool[] = [
16561661 },
16571662 {
16581663 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`.",
16601665 inputSchema: {
16611666 type: "object",
16621667 properties: {
@@ -1732,7 +1737,7 @@ const TOOLS: readonly Tool[] = [
17321737 },
17331738 {
17341739 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.",
17361741 inputSchema: {
17371742 type: "object",
17381743 properties: { username: { type: "string" }, suspended: { type: "boolean" } },
@@ -1763,8 +1768,8 @@ const TOOLS: readonly Tool[] = [
17631768 type: "object",
17641769 properties: {
17651770 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." },
17681773 dryRun: { type: "boolean", description: "false to apply" },
17691774 },
17701775 required: ["username", "dailyUsd", "perRunUsd"],
@@ -1799,7 +1804,7 @@ const TOOLS: readonly Tool[] = [
17991804 },
18001805 {
18011806 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).",
18031808 inputSchema: {
18041809 type: "object",
18051810 properties: { username: { type: "string" } },
@@ -2093,7 +2098,7 @@ const TOOLS: readonly Tool[] = [
20932098 },
20942099 {
20952100 name: "events",
2096 description: "A repository's events, paged.",
2101 description: "Repository events.",
20972102 inputSchema: {
20982103 type: "object",
20992104 properties: {
@@ -2265,7 +2270,7 @@ const TOOLS: readonly Tool[] = [
22652270 type: "object",
22662271 properties: {
22672272 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." },
22692274 limit: { type: "integer", minimum: 1, maximum: 100 },
22702275 },
22712276 required: ["repo"],
@@ -2435,7 +2440,7 @@ const TOOLS: readonly Tool[] = [
24352440 },
24362441 {
24372442 name: "agent",
2438 description: "Admin: create|update|remove a crew agent.",
2443 description: "Admin: create|update|remove an agent.",
24392444 inputSchema: {
24402445 type: "object",
24412446 properties: {
@@ -2827,7 +2832,7 @@ const TOOLS: readonly Tool[] = [
28272832 },
28282833 {
28292834 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.",
28312836 inputSchema: {
28322837 type: "object",
28332838 properties: { limit: { type: "integer", minimum: 1, maximum: 50 } },
@@ -2887,6 +2892,51 @@ const TOOLS: readonly Tool[] = [
28872892 return answer(setAppMembersThrough(ctx.app, user, repo, { members, dryRun }));
28882893 },
28892894 },
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" },

Low: destination has no maxLength, unlike the sibling tools' string arguments (ref 255, name 100). parseDestination rejects bad input anyway, but maxLength: 260 would say so in the schema and keep a client from sending a huge string. That is optional if it costs size-test headroom.

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 },
28902940 {
28912941 name: "explore",
28922942 description:
@@ -3131,7 +3181,7 @@ const TOOLS: readonly Tool[] = [
31313181 hidden: { type: "array", items: { type: "string" } },
31323182 weights: {
31333183 type: "object",
3134 description: "0–10: forks, ships, use, fresh.",
3184 description: "0–10 each.",
31353185 properties: {
31363186 forks: { type: "number", minimum: 0, maximum: 10 },
31373187 ships: { type: "number", minimum: 0, maximum: 10 },
@@ -3770,7 +3820,7 @@ const TOOLS: readonly Tool[] = [
37703820 },
37713821 {
37723822 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.",
37743824 inputSchema: {
37753825 type: "object",
37763826 properties: {
@@ -4103,7 +4153,7 @@ const TOOLS: readonly Tool[] = [
41034153 inputSchema: {
41044154 type: "object",
41054155 properties: {
4106 since: { type: "string", maxLength: 40, description: "ISO time; max 14 days." },
4156 since: { type: "string", maxLength: 40, description: "ISO time." },
41074157 ua: { type: "string", maxLength: 100, description: "User agent part." },
41084158 path: { type: "string", maxLength: 200, description: "Path prefix." },
41094159 },
@@ -4486,7 +4536,7 @@ const TOOLS: readonly Tool[] = [
44864536 },
44874537 {
44884538 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.",
44904540 inputSchema: { type: "object", properties: {} },
44914541 writes: true,
44924542 async run(ctx, user) {
@@ -4516,7 +4566,7 @@ const TOOLS: readonly Tool[] = [
45164566 panel: { type: "integer", minimum: 0, maximum: 23, description: "One panel, from 0." },
45174567 from: { type: "string", maxLength: 64 },
45184568 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." },
45204570 },
45214571 },
45224572 writes: false,
src/http/respond.ts +2 −2
@@ -59,8 +59,8 @@ export const tooManyAttempts = (retryAfter: number) =>
5959 429,
6060 { "retry-after": String(retryAfter) },
6161 );
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);
6464 // A path matching no route at all — distinct from a missing resource, so a
6565 // client can tell a typo'd URL from a resource that genuinely isn't there.
6666 // 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", () => {
234234 "asked",
235235 "assigned",
236236 "app_members",
237 "egress_grants",
237238 "explore",
238239 "search",
239240 "feed",
@@ -1004,6 +1005,62 @@ describe("the forge over MCP", () => {
10041005 expect(limitChanges(app.db)[0]).toMatchObject({ key });
10051006 });
10061007
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");

Low: toContain("forbidden") passes whichever gate refuses the agent. To pin the rule this test means (a site-admin agent is refused by changer, not earlier), assert the exact text: expect(botGrant.error).toBe(forbidden: ${FORBIDDEN_MESSAGE}), as the API comparison above does.

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
10071064 test("board_pins: list, pin, move, unpin for an admin person; an agent and a non-admin are refused", async () => {
10081065 const board = { title: "Pinnable", range: "24h", refresh: "off", panels: [
10091066 { title: "One", chart: "number", query: { source: "runs", aggregate: "count" }, w: 1, h: 1 },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs