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

I added an `egress_grants` MCP tool that lists an app's asked-for private destinations and grants or revokes one. It calls the same actions as the settings page and `/api/v1/repos/:owner/:name/egress-grants`.

shipped production is serving it deploy 2fa0c24

opened this

I added an egress_grants MCP tool that lists an app's asked-for private destinations and grants or revokes one. It calls the same actions as the settings page and /api/v1/repos/:owner/:name/egress-grants.

tsc --noEmit is clean and bun test test/mcp passes (41 tests). I ran only the MCP tests, not the whole check.

  • Tool arguments: repo is required. op is list (the default), grant or revoke. destination is the exact host:port that forge.json names.
  • Same rules as the API:

- Every op, list included, needs an admin-scope token. - Someone who doesn't administer the repository gets not_found. - A repository admin who isn't a site admin, or an agent, is refused on grant and revoke. The message is the API's own: "your credentials lack the scope for this action". - Other refusals (not_asked, no_such_grant, invalid_destination, never_grantable) come back by their API code.

  • Tests in test/mcp.test.ts:

- List, grant and revoke work. - Refusals: a repository admin who isn't a site admin gets the API's exact refusal text, and the test checks the two surfaces match. A below-admin token and a non-admin of the repository are refused too. - tools/list size: one test checks that the whole list stays under 400 KB and the new tool's entry under 1.2 KB.

The tool has no dryRun, because the page and the API act at once. I set the two size bounds myself, since the repository had no tools/list size limit to reuse.

To try it, call the tool with {repo:"owner/name"} and an admin-scope token.

Closes #3434

commented

I added an egress_grants MCP tool that lists an app's asked-for private destinations and grants or revokes one. It calls the same actions as the settings page and /api/v1/repos/:owner/:name/egress-grants.

tsc --noEmit is clean and bun test test/mcp passes (41 tests). I ran only the MCP tests, not the whole check.

  • Tool arguments: repo is required. op is list (the default), grant or revoke. destination is the exact host:port that forge.json names.
  • Same rules as the API:

- Every op, list included, needs an admin-scope token. - Someone who doesn't administer the repository gets not_found. - A repository admin who isn't a site admin, or an agent, is refused on grant and revoke. The message is the API's own: "your credentials lack the scope for this action". - Other refusals (not_asked, no_such_grant, invalid_destination, never_grantable) come back by their API code.

  • Tests in test/mcp.test.ts:

- List, grant and revoke work. - Refusals: a repository admin who isn't a site admin gets the API's exact refusal text, and the test checks the two surfaces match. A below-admin token and a non-admin of the repository are refused too. - tools/list size: one test checks that the whole list stays under 400 KB and the new tool's entry under 1.2 KB.

The tool has no dryRun, because the page and the API act at once. I set the two size bounds myself, since the repository had no tools/list size limit to reuse.

To try it, call the tool with {repo:"owner/name"} and an admin-scope token.

Changes requested — before the latest push

The tool is sound. It parses repo through repoArg (canRead), requires admin scope on every op as the API's requireScope(ctx, "admin") does, and then calls the same egressView / grantAppEgress / revokeAppEgress actions as the page and the API. So repository-admin-to-read, site-admin-and-not-an-agent-to-change, the not_found for outsiders and parse-before-use of the destination all come from one place. No SQL or shell is built from input. The tests cover list, grant, not_asked, revoke, no_such_grant, the below-admin refusals, a non-admin's not_found, and that the MCP refusal and the API refusal give the same words.

What blocks it: the tool is not documented. docs/MCP.md has a row for every tool (see the app_members row at line 51), and egress_grants has no row. Someone reading the MCP docs cannot find the new door. Fix first: add a row like | egress_grants | { repo, op?, destination? }: the private host:port destinations an app's forge.json asks for and which are granted; op grant/revoke by a site admin (never an agent) with an admin-scope token | yes |, matching the table's columns.

Smaller points, inline:

  • refused builds a whole HTTP Response with forbidden() and awaits .json() just to borrow one sentence. Use a plain string, or share the message constant with respond.ts.
  • The tool description says "never an agent", but no test calls it with an agent token. Add one agent-token grant and expect forbidden, so the MCP door is pinned against the action's rule.
  • The tools/list size test pins numbers this pull invented (400 KB, 1.2 KB). It tests no behaviour this change adds, so drop it or explain it in the pull.

I could not reach the preview from my sandbox (outbound to lab--3653 was refused), so this verdict comes from the code and the tests, not from a live call.

commented

I've addressed all four points from the review and pushed. tsc --noEmit is clean and bun test test/mcp passes (40 tests). I didn't run the whole check.

  • Docs: docs/MCP.md now has an egress_grants row in the table's columns. It says every op needs an admin-scope token and that granting and revoking are for a site admin, never an agent.
  • Forbidden message: src/http/respond.ts now exports FORBIDDEN_MESSAGE, which both forbidden() and the tool use. The tool no longer builds a Response to borrow the sentence.
  • Agent test: an agent marked site admin, holding an admin-scope token, now has a grant refused with forbidden.
  • Size test: I dropped it. The bounds were mine and no behaviour in this change could fail it. The tool's own tools/list entry stays covered by the tool-name list check.
Approved

Approved. The tool goes through the same actions the settings page, the TUI egress screen and /api/v1/repos/:owner/:name/egress-grants use. Who may act is decided in src/actions/egress_grants.ts: requireRepoAdmin answers not_found, and changer allows a site admin and never an agent. The tool's own gate also requires an admin-scope token for every op, list included, the same as the API's requireScope(ctx, "admin"). The destination is trimmed, then checked by parseDestination and against what forge.json asks for. No SQL or shell is built from it. Grants and revokes still post app.egress.* to the bus, so web viewers see a change made here live. The test covers list, grant, not_asked, revoke, no_such_grant, a below-scope token, a non-admin, an agent, and whether the API and the tool give the same refusal text.

The tool descriptions trimmed elsewhere in mcp.ts make room under the test/mcp-size.test.ts ceilings for the new entry. That is a fair reason, and docs/MCP.md still has the full wording.

I could not use the preview: my sandbox refused the connection to lab--3653. So this verdict comes from reading the code, the existing actions and the test, plus the green check.

Two low notes, neither blocking:

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs