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

Drain the 199 open pulls: split src/http/mcp.ts (61 pulls collide on it) into one file per tool, re-cut the open pulls onto it, and a hot-file WIP limit #5932

Merged opened this in answer to

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

Merged

mason/forge:mason/5924-drain-the-199-open-pulls-split-src-http → main 7 commits fork 1 thread · 1 unresolved

  • Merged. mason/5924-drain-the-199-open-pulls-split-src-http was merged into main as b5457bd.

Review

Sign in to review this change.
134 files changed +5823 −5114
CLAUDE.md +5
@@ -161,6 +161,8 @@ src/
161161 scope floors, pagination, `securityFloor` — the headers every
162162 response leaves with), api (every /api/v1 route), server
163163 (auth resolution, git smart HTTP, web routes, hook socket), sse
164 mcp (the MCP protocol and dispatch; one file per tool under
165 mcp/tools/, gathered by a generated index)
164166 design/ shared design tokens: themes (palettes both surfaces render)
165167 origins/ other forges as one Provider interface: discover (version-endpoint
166168 probe), providers/{github,forgejo} (+ http: paging, ETags, rate
@@ -254,6 +256,9 @@ flag exists only in the stdio dev runner and is never reachable over SSH.
254256 Describe it in OpenAPI as one file, `contract/openapi/routes/<operationId>.ts`,
255257 exporting `route`; then `bun scripts/openapi-routes.ts` writes its line in the
256258 generated index. Never list it by hand.
259 - **Add an MCP tool:** one file, `http/mcp/tools/<name>.ts`, exporting `tool`;
260 then `bun scripts/mcp-tools.ts` writes its line in the index and its row in
261 docs/MCP.md (docs/MCP.md, "Adding a tool"). Never list it by hand.
257262 - **Add a TUI screen:** one file under `tui/screens/`, exporting
258263 `(f: Session, ctx: Ctx, …) => Screen`. Compose `ui/widgets`; take navigation
259264 from `screens/common.ts` (`ListState`, `Scroll`, `Filter`, `drawList`,
docs/MCP.md +116 −101
@@ -25,107 +25,122 @@ for a tunnel and a forge that sleeps.
2525
2626 ## Tools
2727
28 | tool | does | writes |
29 | -------------- | ------------------------------------------------------------------------------------------------- | ------ |
30 | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | |
31 | `app_deploy` | `{ repo, env, action }`: `redeploy` or `stop` an app's `production` or a pull's `pull-N` preview; a writer's, queued as `POST …/app/:env/redeploy` and `…/stop` are; answers `{ queued, run }` or `{ stopped }` | yes |
32 | `new_app` | `{ name, description?, owner?, visibility?, agent?, pick? }`: make an app from a sentence; answers `{ repo, url, issue, agent, run }` (`run` is the first run's state, or `none`) and draws the `ui://forge/app` card: name, production URL, first issue, agent, first run; refused as `POST /api/v1/apps` is (unverified, app_limit, no_such_agent, name_taken) | yes |
33 | `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` | |
34 | `repo_settings` | `{ repo, description?, visibility? }` (admin token): change a repository's description and visibility as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility }`; a visibility other than public or private is `invalid` | yes |
35 | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes |
36 | `thought` | `{ id }`: where one of your thoughts went (its id comes from `tell` without a repo): the placement, and the earlier thoughts it repeats or takes back and the later ones that did the same; not yours and not there answer alike, `not_found` | |
37 | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes |
38 | `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 | |
39 | `pull` | `{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment | |
40 | `preview` | `{ url }`: what the forge pill says of an app host's URL (production or a pull's preview): `{ app, environment, pull: { number, title } \| null, check: "green" \| "red" \| "running" \| null, firstFailure, logUrl, working: [names] }`; `firstFailure` is the check's first failing test or its summary, `logUrl` is set only for someone who may read the log (a writer), `working` names the agents with a run going; a URL that is not one of this forge's app hosts is `invalid`, an app you cannot read is `not_found` and a pull you cannot see is `no_such_pull`, as `repo` and `pull` say | |
41 | `edit` | a new title or body for an issue or a pull: its author's, or a writer's; an agent edits only what it wrote | yes |
42 | `close` | `{ repo, number, state?, reason?, into? }`: close an issue or a pull (`state` closed by default) or reopen it (`open`); the same rules and refusals as `PATCH …/issues/:number`: an agent closes only what the rules give it and reopens nothing; answers `state` and `closeReason` | yes |
43 | `pulls` | `{ repo, state?, limit? }`: a repository's pulls, newest first, one brief line each: `number`, `title`, `author`, `head`, `check` (state), `approvals`, `changesRequested`, `mergeable` (null once closed); `state` open (default) or closed, `limit` 1-50 (default 20); no bodies, no diffs | no |
44 | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents | no |
45 | `seal` | `{ repo, number, sealed }`: seal a security report (and the pull that fixes it) so only writers and the crew see it, or unseal it for every reader; a writer's, recorded | yes |
46 | `secrets` | `{ scope, ref?, action, name?, value? }` (forge admin): `list` the names and timestamps of the forge's sealed secrets, `set` or `remove` one; `scope` is forge, org or repo, `ref` the org or `owner/name`; a value is never read back | yes |
47 | `mirror_set` | `{ action, endpoint?, bucket?, region?, accessKey?, secretKey? }` (forge admin, admin-scope token): `set` seals the off-box snapshot mirror, `forget` clears it; the Backups page's rules and refusals, a blank field keeps what was sealed; answers `{ place, says }`, never a key | yes |
48 | `org_teams` | `{ org, action, team, user?, permission? }` (admin-scope token, an org owner): `create` a team (`permission` read, write or admin; read by default), `add_member` a `user`, or `remove` the team; the same as POST/DELETE `/api/v1/orgs/:org/teams` | yes |
49 | `now` | what the crew is doing right now: who, on what, since when, at what cost; a planner that declined the direction and waits for you comes first (`run: null`, its words in `says`) | |
50 | `next_steps` | on a spawned forge, your first three steps and whether each is done; empty elsewhere or for anyone but its owner | |
51 | `returns` | `{ repo?, days? }`: what the crew's tokens bought over the last `days` (1–90, default 7): dollars, runs, changes landed, cost per change; `repo` narrows it to one app of yours | |
52 | `stop` | stop one run by its id from `now` | yes |
53 | `health` | how this forge is: the version and commit it runs, draining or not, the rollout and its incidents | |
54 | `system` | the machine the forge runs on, now: CPU, load, memory, disk, network, processes; a forge admin, or an agent granted `telemetry:read` | |
55 | `telemetry_grant` | `{ agent, on }`: grant an agent `telemetry:read`, or take it back with `false`, as the Telemetry page does; a person admin's, with an admin token; answers `says` | yes |
56 | `cloud_accounts` | the forge's cloud accounts, each attached provider token with its use, verification, write permission and today's calls; a forge admin only (counted as a write so a `ro` token never lists them) | yes |
57 | `cloud_account` | `{ verb, provider, name, value?, to?, allowed?, repo? }`: change a cloud account: `attach` or `rotate` a token (`value`), `verify` it, `rename` it (`to`), `remove` it, `allow_writes` (`allowed` true or false) or `audit` (`repo` owner/name); a forge admin with an admin-scope token; answers a one-line `says` | yes |
58 | `audit` | who can do what, per app, read from its contract.json in git: yes, own or no per door, and whether outsiders may direct its crew (`crewOutsiders`) | |
59 | `account_verify` | `{ username }`: mark a person's email verified, as `POST …/admin/accounts/:username/verify` does; a person admin's, with an admin token | yes |
60 | `outsiders` | `{ repo, on?, dryRun? }`: whether people who cannot write may direct the crew; with `on`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes |
61 | `origins` | `{ action, id? }`: `list` your connected origins, `repos` the repositories one holds (`id`; `mirroredAs` says which are already here), or `sync` one now; the same as `GET /api/v1/user/origins`, `…/:id/repos` and `POST …/:id/sync`, for you only, an admin token as there; never a credential in an answer | yes |
62 | `decisions_by` | `{ repo, by?, dryRun? }`: the standing rule for a repository's hand-overs (`me`, `decider`, `decider_tell`); with `by`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes |
63 | `archive` | `{ repo | repos, archived?, dryRun? }`: archive (true) or wake (false) a repository, by name; planned first (`dryRun` is true unless `false`); only the repository's admin with an admin token, never an agent. `repos` (at most 50) runs the fleet `archive` operation over them, answers each one's outcome and the `operation` id; `limb` with `action: "undo"`, that `operation` and any one of the repositories as `app` reverts it for a day | yes |
64 | `events` | `{ repo, limit?, cursor? }`: the repository's events, newest first, as you may see them (a webhook's URL and an app's variable names are the admins'): `{ items, next_cursor }`, `limit` 1 to 200 (default 50), `cursor` from `next_cursor` | no |
65 | `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 |
66 | `trail` | `{ repo, shown?, dryRun? }`: where a migrated repository came from (`provenance`: source, sourceUrl, parents, migratedAt) and whether it is shown; with `shown`, shows or hides the trail — planned first (`dryRun` is true unless `false`), only the repository's admin with an admin token, never an agent. Hiding keeps the record; a hidden one reads `null` to anyone but an admin | yes |
67 | `branches` | `{ repo, name?, action?, from?, kind? }`: without `name`, lists the branches; `kind: "tags"` lists the tags instead (as GET `/api/v1/repos/:owner/:name/tags` does, newest first; no `name` or `action`); with `name` and `action` (`create` from `from`, or `delete`) it does what the branches page does: the default branch is never deleted, only a writer may act | yes |
68 | `migrate` | `{ url, name?, crew? }` brings a public repository in by URL (history only; nothing reaches back) and answers `{ job, state, url }`; `{ job }` alone reads that job. Same validation, scope (`rw`) and private-address refusal as `POST /api/v1/migrate` | yes |
69 | `commits` | `{ repo, ref?, limit?, oid? }`: a branch's recent commits, newest first (`limit` 1–100, default 30; refused outside that, where REST clamps), as `GET …/commits` answers; with `oid` (4–64 hex) one commit with its diff and its check, as `GET …/commits/{oid}` and `…/check` answer | |
70 | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | |
71 | `following` | where a fork stands against what it follows, and what the crew is doing about it | |
72 | `family` | the forge that sent this one and, for a site admin, those it sent, with state and version | |
73 | `forges` | an admin's list of the forges sent from here: name, address, whom for, apps, claimed or not, last activity | |
74 | `follow_mode` | `{ repo, … }`: choose how a spawned forge follows its parent, `tell-me` or `take-updates`; its `forge` repository's admin only | yes |
75 | `sever` | `{ repo }`: cut a spawned forge's link to the forge its code came from; its `forge` repository's owner only; deletes nothing | yes |
76 | `propose_to_parent_forge` | `{ repo }`: offer this forge's landed change to its parent as a pull on `child/<name>/…`; the same tip answers with that pull | yes |
77 | `forge_proposals` | `{ repo }`: what this forge proposed to its parent, newest first; `reachable` is false if the parent did not answer | |
78 | `contribute` | for any arriving agent: what this forge is, its bottleneck, open “help wanted” issues, how to propose | |
79 | `spawn` | `{ name, apps?, crew?, dryRun? }`: a child forge, planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make | yes |
80 | `agent` | `{ action, username, … }`: `create` a crew agent, `update` its `model`, `brief` or `budgetUsd`, or `remove` it; an admin's, with an `admin` token, as `/api/v1/agents` | yes |
81 | `claim_link` | `{ name }`: a new claim link for a child you spawned that nobody has claimed; the old one stops working. An admin's | yes |
82 | `upstream` | `{ repo, enabled?, url?, branch?, check? }`: the repository a fork follows (an admin reads it); with `enabled` sets it, with `check: true` checks it now; setting and checking need an admin token | yes |
83 | `take_updates` | a fork takes its parent's updates when git can do it alone; a conflict is refused. On a spawned forge's code repository it opens the one `forge/take-updates` pull and never moves the default branch | yes |
84 | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes |
85 | `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 | |
86 | `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` | |
87 | `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 |
88 | `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 |
89 | `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 | |
90 | `search` | `{ q, kind?, repo? }`: issues, pulls, repositories and code you can read, the same results as /search; `repo` keeps to one | |
91 | `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` | |
28 <!-- tools:begin: generated by `bun scripts/mcp-tools.ts` from each tool's `doc` -->
29
30 | tool | does | writes |
31 | ---- | ---- | ------ |
32 | `access` | `{ since?, ua?, path? }`: what outside agents tried: the redacted log of non-browser requests to public paths, newest first; `since` an ISO time (default a day, at most 14 days), `ua` part of the user agent, `path` a prefix; a forge admin's | yes |
33 | `account_role` | `{ username, role, usdPerMonth?, dryRun? }`: make a person a `member`, or a `guest` again, with an optional starter allowance (`usdPerMonth`, 0.01 to 10000, promoting only); planned first (`dryRun` is true unless `false`) | yes |
34 | `account_suspend` | `{ username, suspended }`: suspend a person's account, or unsuspend it with `false`; their sessions and tokens stop at once; the API's rules (not yourself, another admin or an agent; not the only owner of an organization); an admin token | yes |
35 | `account_verify` | `{ username }`: mark a person's email verified, as `POST …/admin/accounts/:username/verify` does; a person admin's, with an admin token | yes |
36 | `agent` | `{ action, username, … }`: `create` a crew agent, `update` its `model`, `brief` or `budgetUsd`, or `remove` it; an admin's, with an `admin` token, as `/api/v1/agents` | yes |
37 | `app_deploy` | `{ repo, env, action }`: `redeploy` or `stop` an app's `production` or a pull's `pull-N` preview; a writer's, queued as `POST …/app/:env/redeploy` and `…/stop` are; answers `{ queued, run }` or `{ stopped }` | yes |
38 | `app_env` | `{ repo, action, name?, value?, scope? }`: `list` an app's variable names and scopes, `set` one, or `unset` it, in `all` (default), `production` or `preview`; the API's rules and errors, an admin of the repository with an admin-scope token; a value is never read back | yes |
39 | `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 |
40 | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | |
41 | `archive` | `{ repo | repos, archived?, dryRun? }`: archive (true) or wake (false) a repository, by name; planned first (`dryRun` is true unless `false`); only the repository's admin with an admin token, never an agent. `repos` (at most 50) runs the fleet `archive` operation over them, answers each one's outcome and the `operation` id; `limb` with `action: "undo"`, that `operation` and any one of the repositories as `app` reverts it for a day | yes |
42 | `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 | |
43 | `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` | |
44 | `audit` | who can do what, per app, read from its contract.json in git: yes, own or no per door, and whether outsiders may direct its crew (`crewOutsiders`) | |
45 | `board_delete` | `{ slug }` (person admins): delete a board by slug; its history keeps it | yes |
46 | `board_pins` | `{ op?, slug?, title?, by? }` (person admins): without `op`, the panels pinned to your home (`slug`, `title`); `pin` or `unpin` a panel by board `slug` and panel `title`, or `move` it up or down with `by` (-1 or 1) | yes |
47 | `board_write` | `{ slug, board }` (person admins): write a whole board under a slug, made or replaced: `{ title, range, refresh, panels: [{ title, chart, query, w, h }] }` | yes |
48 | `boards` | `{ slug?, panel?, from?, to?, step? }` (person admins): without `slug`, lists the forge's boards (slug, title, range, refresh, panels); with `slug`, that board; with `panel` (place from 0, up to 23), that panel's data over the `from`/`to` window and `step` | |
49 | `branches` | `{ repo, name?, action?, from?, kind? }`: without `name`, lists the branches; `kind: "tags"` lists the tags instead (as GET `/api/v1/repos/:owner/:name/tags` does, newest first; no `name` or `action`); with `name` and `action` (`create` from `from`, or `delete`) it does what the branches page does: the default branch is never deleted, only a writer may act | yes |
50 | `claim_link` | `{ name }`: a new claim link for a child you spawned that nobody has claimed; the old one stops working. An admin's | yes |
51 | `close` | `{ repo, number, state?, reason?, into? }`: close an issue or a pull (`state` closed by default) or reopen it (`open`); the same rules and refusals as `PATCH …/issues/:number`: an agent closes only what the rules give it and reopens nothing; answers `state` and `closeReason` | yes |
52 | `cloud_account` | `{ verb, provider, name, value?, to?, allowed?, repo? }`: change a cloud account: `attach` or `rotate` a token (`value`), `verify` it, `rename` it (`to`), `remove` it, `allow_writes` (`allowed` true or false) or `audit` (`repo` owner/name); a forge admin with an admin-scope token; answers a one-line `says` | yes |
53 | `cloud_accounts` | the forge's cloud accounts, each attached provider token with its use, verification, write permission and today's calls; a forge admin only (counted as a write so a `ro` token never lists them) | yes |
54 | `commits` | `{ repo, ref?, limit?, oid? }`: a branch's recent commits, newest first (`limit` 1–100, default 30; refused outside that, where REST clamps), as `GET …/commits` answers; with `oid` (4–64 hex) one commit with its diff and its check, as `GET …/commits/{oid}` and `…/check` answer | |
55 | `confirm_wait` | `{ app, issue, blocker }`: a person says `issue` may go ahead although `blocker` closed as dropped or split; the API's confirm action, so writers only and never the crew | yes |
56 | `contribute` | for any arriving agent: what this forge is, its bottleneck, open “help wanted” issues, how to propose | |
57 | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes |
58 | `decisions_by` | `{ repo, by?, dryRun? }`: the standing rule for a repository's hand-overs (`me`, `decider`, `decider_tell`); with `by`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes |
59 | `edit` | a new title or body for an issue or a pull: its author's, or a writer's; an agent edits only what it wrote | yes |
60 | `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 |
61 | `email_verify` | no input: send the caller a new verification email, with the API's rate limit and errors; the answer never carries the link | yes |
62 | `events` | `{ repo, limit?, cursor? }`: the repository's events, newest first, as you may see them (a webhook's URL and an app's variable names are the admins'): `{ items, next_cursor }`, `limit` 1 to 200 (default 50), `cursor` from `next_cursor` | |
63 | `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 | |
64 | `family` | the forge that sent this one and, for a site admin, those it sent, with state and version | |
65 | `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` | |
9266 | `feed_chips` | `{ kind?, value?, hidden? }`: the feed chips you switched off, which `feed` and `asks` leave out; with `kind` (`kind` or `person`), `value` and `hidden` it switches one off or on | yes |
93 | `make_it_yours` | `{ repo, name?, visibility?, dryRun? }`: a fork of an app you can see, as your own app, following the original; code and history copied, data and secrets not; private unless `visibility` is public (a public original only); at most 5 copies an account a day; plans unless `dryRun` is false | yes |
94 | `origin_apps` | `{ action, id?, host?, kind?, clientId?, clientSecret? }`: `list` your OAuth apps for connecting origins, `add` one (a `host`, or `kind` github), or `remove` one by `id`; the API's rules and errors, an admin-scope token; a secret is never read back, only `hasSecret` | yes |
95 | `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 |
96 | `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 |
97 | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`); draws the `ui://forge/map` card | |
98 | `milestones` | `{ app, issue? }`: how far each thing asked for is (and, with `issue`, whether you watch it: `watching`), from the dependency tree: the plan's limbs with their weights, or one milestone opened (who is building, what is blocked and on what, what merged in 7 days, an estimate, and for an owner `spend`: dollars, changes landed ever, the costliest three issues) | |
99 | `limb` | `{ app, issue, action, reason?, pillar?, operation?, dryRun? }`: defer, resume or close a whole limb as not doing, or undo an operation; plans unless `dryRun` is false, and applying takes an admin token | yes |
100 | `watch` | `{ app, issue, watch? }`: watch a milestone, or stop with `watch: false`: one item in your feed that follows it, and one push to your phone when it lands | yes |
101 | `runs` | `{ repo, limit? }`: the repository's recent runs, newest first (id, kind, state, issue or pull number, started, ended), 1–50, default 20; read as the runs page reads them | |
102 | `run_log` | `{ repo, run, tail? }`: a run's log, or its last `tail` lines (1–2000), with the REST route's access: a hidden log or unreadable repository is not found | |
103 | `review` | `{ repo, number, verdict, reason? }`: approve a pull (`approve`) or request changes (`changes`, with a `reason`), as the page does; a writer's or the crew's, and never your own pull | yes |
104 | `threads` | `{ repo, number, anchor?, resolved? }`: a pull's review threads with their comments; with `anchor` (from the list) resolve it (`resolved` true, the default) or reopen it (false), by the API's rules: a writer or someone who commented in the thread | yes |
105 | `usage` | `{ repo, days? }`: what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue, and each account's 5-hour and weekly standing; a writer's | |
106 | `ssh_keys` | `{ action?, title?, key?, id? }`: list your own SSH keys (default), add one (`key` is an authorized_keys line, `title` optional) or remove one by `id`; the API's rules and errors, add and remove need an `rw` token | yes |
107 | `boards` | `{ slug?, panel?, from?, to?, step? }` (person admins): without `slug`, lists the forge's boards (slug, title, range, refresh, panels); with `slug`, that board; with `panel` (place from 0, up to 23), that panel's data over the `from`/`to` window and `step` | |
108 | `board_write` | `{ slug, board }` (person admins): write a whole board under a slug, made or replaced: `{ title, range, refresh, panels: [{ title, chart, query, w, h }] }` | yes |
109 | `board_delete` | `{ slug }` (person admins): delete a board by slug; its history keeps it | yes |
110 | `board_pins` | `{ op?, slug?, title?, by? }` (person admins): without `op`, the panels pinned to your home (`slug`, `title`); `pin` or `unpin` a panel by board `slug` and panel `title`, or `move` it up or down with `by` (-1 or 1) | yes |
111 | `labels` | `{ repo, number, labels }`: set an issue's or a pull's labels to exactly these (`[]` clears); only labels the repository has, by a writer | yes |
112 | `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 |
113 | `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 |
114 | `subscription_budget` | `{ id, usd }`: set an account's whole-life budget in dollars (up to the `subscription.budget_max_usd` limit), or clear it with `usd: null`, as the Subscriptions page does; a locked budget can only be lowered; a forge admin with an admin token | yes |
115 | `wait_for` | `{ app, issue, blockedBy, stop? }`: the issue waits for another, or stops with `stop: true`; the page's rules, and the answer carries the issue's URL | yes |
116 | `confirm_wait` | `{ app, issue, blocker }`: a person says `issue` may go ahead although `blocker` closed as dropped or split; the API's confirm action, so writers only and never the crew | yes |
117 | `hold` | `{ app, pull, why?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; plans unless `dryRun: false` | yes |
118 | `app_env` | `{ repo, action, name?, value?, scope? }`: `list` an app's variable names and scopes, `set` one, or `unset` it, in `all` (default), `production` or `preview`; the API's rules and errors, an admin of the repository with an admin-scope token; a value is never read back | yes |
119 | `email_verify` | no input: send the caller a new verification email, with the API's rate limit and errors; the answer never carries the link | yes |
120 | `plan` | `{ app, issues }`: open up to 12 linked issues, all or none (see below) | yes |
121 | `prompts` | `{ repo, action, kind?, token?, at?, id? }`: list, add, run or remove an app's prompt sources (Instagram), as the `…/prompts` API does; the token goes in once and is never shown; listed only for an admin-scope token, and only the repository's admin may use it | yes |
122 | `account_suspend` | `{ username, suspended }`: suspend a person's account, or unsuspend it with `false`; their sessions and tokens stop at once; the API's rules (not yourself, another admin or an agent; not the only owner of an organization); an admin token | yes |
123 | `parent_unlink` | no input: stop signing in through the parent forge, as Settings does; refused when it is the account's last way in, and "Nothing to unlink." when nothing is linked; an admin token | yes |
124 | `account_role` | `{ username, role, usdPerMonth?, dryRun? }`: make a person a `member`, or a `guest` again, with an optional starter allowance (`usdPerMonth`, 0.01 to 10000, promoting only); planned first (`dryRun` is true unless `false`) | yes |
125 | `mint_invite` | `{ days? }`: mint a single-use invite link that expires in `days` (1–30, default 7) and is shown once; a person admin's, with an admin token | yes |
126 | `signup` | `{ mode?, dryRun? }`: who may sign up: `open` (default), `invite` (only a person with an open invite link) or `closed` (no new accounts); without `mode` it reads the setting, with `mode` it plans first (`dryRun` is true unless `false`), and applying needs an admin token | yes |
127 | `access` | `{ since?, ua?, path? }`: what outside agents tried: the redacted log of non-browser requests to public paths, newest first; `since` an ISO time (default a day, at most 14 days), `ua` part of the user agent, `path` a prefix; a forge admin's | yes |
128 | `whoami_can` | `{ kind? }`: your role and that role's row of the roles table, each ability yes or no; `kind: "passkeys"` lists your own passkeys (`{ items }`: id, name, created, lastUsed), people only | |
67 | `follow_mode` | `{ repo, … }`: choose how a spawned forge follows its parent, `tell-me` or `take-updates`; its `forge` repository's admin only | yes |
68 | `following` | where a fork stands against what it follows, and what the crew is doing about it | |
69 | `forge_proposals` | `{ repo }`: what this forge proposed to its parent, newest first; `reachable` is false if the parent did not answer | |
70 | `forges` | an admin's list of the forges sent from here: name, address, whom for, apps, claimed or not, last activity | |
71 | `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 |
72 | `health` | how this forge is: the version and commit it runs, draining or not, the rollout and its incidents | |
73 | `hold` | `{ app, pull, why?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; plans unless `dryRun: false` | yes |
74 | `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 |
75 | `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 |
76 | `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 | |
77 | `labels` | `{ repo, number, labels }`: set an issue's or a pull's labels to exactly these (`[]` clears); only labels the repository has, by a writer | yes |
78 | `limb` | `{ app, issue, action, reason?, pillar?, operation?, dryRun? }`: defer, resume or close a whole limb as not doing, or undo an operation; plans unless `dryRun` is false, and applying takes an admin token | yes |
79 | `make_it_yours` | `{ repo, name?, visibility?, dryRun? }`: a fork of an app you can see, as your own app, following the original; code and history copied, data and secrets not; private unless `visibility` is public (a public original only); at most 5 copies an account a day; plans unless `dryRun` is false | yes |
80 | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`); draws the `ui://forge/map` card | |
81 | `migrate` | `{ url, name?, crew? }` brings a public repository in by URL (history only; nothing reaches back) and answers `{ job, state, url }`; `{ job }` alone reads that job. Same validation, scope (`rw`) and private-address refusal as `POST /api/v1/migrate` | yes |
82 | `milestones` | `{ app, issue? }`: how far each thing asked for is (and, with `issue`, whether you watch it: `watching`), from the dependency tree: the plan's limbs with their weights, or one milestone opened (who is building, what is blocked and on what, what merged in 7 days, an estimate, and for an owner `spend`: dollars, changes landed ever, the costliest three issues) | |
83 | `mint_invite` | `{ days? }`: mint a single-use invite link that expires in `days` (1–30, default 7) and is shown once; a person admin's, with an admin token | yes |
84 | `mirror_set` | `{ action, endpoint?, bucket?, region?, accessKey?, secretKey? }` (forge admin, admin-scope token): `set` seals the off-box snapshot mirror, `forget` clears it; the Backups page's rules and refusals, a blank field keeps what was sealed; answers `{ place, says }`, never a key | yes |
85 | `new_app` | `{ name, description?, owner?, visibility?, agent?, pick? }`: make an app from a sentence; answers `{ repo, url, issue, agent, run }` (`run` is the first run's state, or `none`) and draws the `ui://forge/app` card: name, production URL, first issue, agent, first run; refused as `POST /api/v1/apps` is (unverified, app_limit, no_such_agent, name_taken) | yes |
86 | `next_steps` | on a spawned forge, your first three steps and whether each is done; empty elsewhere or for anyone but its owner | |
87 | `now` | what the crew is doing right now: who, on what, since when, at what cost; a planner that declined the direction and waits for you comes first (`run: null`, its words in `says`) | |
88 | `org_teams` | `{ org, action, team, user?, permission? }` (admin-scope token, an org owner): `create` a team (`permission` read, write or admin; read by default), `add_member` a `user`, or `remove` the team; the same as POST/DELETE `/api/v1/orgs/:org/teams` | yes |
89 | `origin_apps` | `{ action, id?, host?, kind?, clientId?, clientSecret? }`: `list` your OAuth apps for connecting origins, `add` one (a `host`, or `kind` github), or `remove` one by `id`; the API's rules and errors, an admin-scope token; a secret is never read back, only `hasSecret` | yes |
90 | `origins` | `{ action, id? }`: `list` your connected origins, `repos` the repositories one holds (`id`; `mirroredAs` says which are already here), or `sync` one now; the same as `GET /api/v1/user/origins`, `…/:id/repos` and `POST …/:id/sync`, for you only, an admin token as there; never a credential in an answer | yes |
91 | `outsiders` | `{ repo, on?, dryRun? }`: whether people who cannot write may direct the crew; with `on`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes |
92 | `parent_unlink` | no input: stop signing in through the parent forge, as Settings does; refused when it is the account's last way in, and "Nothing to unlink." when nothing is linked; an admin token | yes |
93 | `plan` | `{ app, issues }`: open up to 12 linked issues, all or none (see below) | yes |
94 | `preview` | `{ url }`: what the forge pill says of an app host's URL (production or a pull's preview): `{ app, environment, pull: { number, title } \| null, check: "green" \| "red" \| "running" \| null, firstFailure, logUrl, working: [names] }`; `firstFailure` is the check's first failing test or its summary, `logUrl` is set only for someone who may read the log (a writer), `working` names the agents with a run going; a URL that is not one of this forge's app hosts is `invalid`, an app you cannot read is `not_found` and a pull you cannot see is `no_such_pull`, as `repo` and `pull` say | |
95 | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | |
96 | `prompts` | `{ repo, action, kind?, token?, at?, id? }`: list, add, run or remove an app's prompt sources (Instagram), as the `…/prompts` API does; the token goes in once and is never shown; listed only for an admin-scope token, and only the repository's admin may use it | yes |
97 | `propose_to_parent_forge` | `{ repo }`: offer this forge's landed change to its parent as a pull on `child/<name>/…`; the same tip answers with that pull | yes |
98 | `pull` | `{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment | |
99 | `pulls` | `{ repo, state?, limit? }`: a repository's pulls, newest first, one brief line each: `number`, `title`, `author`, `head`, `check` (state), `approvals`, `changesRequested`, `mergeable` (null once closed); `state` open (default) or closed, `limit` 1-50 (default 20); no bodies, no diffs | |
100 | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents | |
101 | `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` | |
102 | `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 |
103 | `repo_settings` | `{ repo, description?, visibility? }` (admin token): change a repository's description and visibility as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility }`; a visibility other than public or private is `invalid` | yes |
104 | `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 |
105 | `returns` | `{ repo?, days? }`: what the crew's tokens bought over the last `days` (1–90, default 7): dollars, runs, changes landed, cost per change; `repo` narrows it to one app of yours | |
106 | `review` | `{ repo, number, verdict, reason? }`: approve a pull (`approve`) or request changes (`changes`, with a `reason`), as the page does; a writer's or the crew's, and never your own pull | yes |
107 | `run_log` | `{ repo, run, tail? }`: a run's log, or its last `tail` lines (1–2000), with the REST route's access: a hidden log or unreadable repository is not found | |
108 | `runs` | `{ repo, limit? }`: the repository's recent runs, newest first (id, kind, state, issue or pull number, started, ended), 1–50, default 20; read as the runs page reads them | |
109 | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes |
110 | `seal` | `{ repo, number, sealed }`: seal a security report (and the pull that fixes it) so only writers and the crew see it, or unseal it for every reader; a writer's, recorded | yes |
111 | `search` | `{ q, kind?, repo? }`: issues, pulls, repositories and code you can read, the same results as /search; `repo` keeps to one | |
112 | `secrets` | `{ scope, ref?, action, name?, value? }` (forge admin): `list` the names and timestamps of the forge's sealed secrets, `set` or `remove` one; `scope` is forge, org or repo, `ref` the org or `owner/name`; a value is never read back | yes |
113 | `sever` | `{ repo }`: cut a spawned forge's link to the forge its code came from; its `forge` repository's owner only; deletes nothing | yes |
114 | `signup` | `{ mode?, dryRun? }`: who may sign up: `open` (default), `invite` (only a person with an open invite link) or `closed` (no new accounts); without `mode` it reads the setting, with `mode` it plans first (`dryRun` is true unless `false`), and applying needs an admin token | yes |
115 | `spawn` | `{ name, apps?, crew?, dryRun? }`: a child forge, planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make | yes |
116 | `ssh_keys` | `{ action?, title?, key?, id? }`: list your own SSH keys (default), add one (`key` is an authorized_keys line, `title` optional) or remove one by `id`; the API's rules and errors, add and remove need an `rw` token | yes |
117 | `stop` | stop one run by its id from `now` | yes |
118 | `subscription_budget` | `{ id, usd }`: set an account's whole-life budget in dollars (up to the `subscription.budget_max_usd` limit), or clear it with `usd: null`, as the Subscriptions page does; a locked budget can only be lowered; a forge admin with an admin token | yes |
119 | `system` | the machine the forge runs on, now: CPU, load, memory, disk, network, processes; a forge admin, or an agent granted `telemetry:read` | |
120 | `take_updates` | a fork takes its parent's updates when git can do it alone; a conflict is refused. On a spawned forge's code repository it opens the one `forge/take-updates` pull and never moves the default branch | yes |
121 | `telemetry_grant` | `{ agent, on }`: grant an agent `telemetry:read`, or take it back with `false`, as the Telemetry page does; a person admin's, with an admin token; answers `says` | yes |
122 | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes |
123 | `thought` | `{ id }`: where one of your thoughts went (its id comes from `tell` without a repo): the placement, and the earlier thoughts it repeats or takes back and the later ones that did the same; not yours and not there answer alike, `not_found` | |
124 | `threads` | `{ repo, number, anchor?, resolved? }`: a pull's review threads with their comments; with `anchor` (from the list) resolve it (`resolved` true, the default) or reopen it (false), by the API's rules: a writer or someone who commented in the thread | yes |
125 | `trail` | `{ repo, shown?, dryRun? }`: where a migrated repository came from (`provenance`: source, sourceUrl, parents, migratedAt) and whether it is shown; with `shown`, shows or hides the trail — planned first (`dryRun` is true unless `false`), only the repository's admin with an admin token, never an agent. Hiding keeps the record; a hidden one reads `null` to anyone but an admin | yes |
126 | `upstream` | `{ repo, enabled?, url?, branch?, check? }`: the repository a fork follows (an admin reads it); with `enabled` sets it, with `check: true` checks it now; setting and checking need an admin token | yes |
127 | `usage` | `{ repo, days? }`: what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue, and each account's 5-hour and weekly standing; a writer's | |
128 | `wait_for` | `{ app, issue, blockedBy, stop? }`: the issue waits for another, or stops with `stop: true`; the page's rules, and the answer carries the issue's URL | yes |
129 | `watch` | `{ app, issue, watch? }`: watch a milestone, or stop with `watch: false`: one item in your feed that follows it, and one push to your phone when it lands | yes |
130 | `whoami_can` | `{ kind? }`: your role and that role's row of the roles table, each ability yes or no; `kind: "passkeys"` lists your own passkeys (`{ items }`: id, name, created, lastUsed), people only | |
131
132 <!-- tools:end -->
133
134 ### Adding a tool
135
136 One tool, one file (#5924): `src/http/mcp/tools/<name>.ts` exports `tool`,
137 its definition and `run`, and nothing else; its arguments' shared helpers
138 are in `src/http/mcp/shared.ts`. Its row in the table above is its `doc`.
139 Then run `bun scripts/mcp-tools.ts`: it adds one line to the sorted
140 `tools/index.ts` and one row to the table, so two pulls adding two tools
141 edit no line in common. `test/mcp-tools.test.ts` holds every tool file to
142 the same contract and goes red when either generated file is stale; the
143 tool's own behaviour is tested in `test/mcp-<name>.test.ts`.
129144
130145 ### Room for a new tool
131146
@@ -207,7 +222,7 @@ can call `apps` and is refused `tell`.
207222 `src/http/oauth.ts`, `src/core/oauth.ts`, `src/web/pages/oauth.ts`,
208223 `test/oauth-flow.test.ts`.
209224
210 `src/http/mcp.ts`, `src/actions/tell.ts`, `test/mcp.test.ts`.
225 `src/http/mcp.ts`, `src/http/mcp/tools/`, `src/actions/tell.ts`, `test/mcp.test.ts`.
211226
212227 ## `plan`
213228
scripts/mcp-tools.ts added +81
@@ -0,0 +1,81 @@
1 /**
2 * Writes the MCP tools' generated files from `src/http/mcp/tools/*.ts` (#5924):
3 *
4 * - `src/http/mcp/tools/index.ts`: one sorted line per tool file, so a pull
5 * that adds a tool adds one line and never edits another pull's.
6 * - the `## Tools` table in `docs/MCP.md`, between its `tools:begin` and
7 * `tools:end` markers, from each tool's `doc` and `writes`.
8 *
9 * bun scripts/mcp-tools.ts write both
10 * bun scripts/mcp-tools.ts --check exit 1, naming the file, when either is stale
11 *
12 * `test/mcp-tools.test.ts` runs the check, so a pull that adds a tool file
13 * without running this goes red with the command to run.
14 */
15 import { readdirSync, readFileSync, writeFileSync } from "node:fs";
16 import { join } from "node:path";
17
18 const ROOT = join(import.meta.dir, "..");
19 const DIR = join(ROOT, "src/http/mcp/tools");
20 const INDEX = join(DIR, "index.ts");
21 const DOC = join(ROOT, "docs/MCP.md");
22 const BEGIN = "<!-- tools:begin: generated by `bun scripts/mcp-tools.ts` from each tool's `doc` -->";
23 const END = "<!-- tools:end -->";
24
25 /** The tool files, by name: every `<name>.ts` here but the index. */
26 export function toolFiles(dir = DIR): string[] {
27 return readdirSync(dir)
28 .filter((f) => /^[a-z][a-z0-9_]*\.ts$/.test(f) && f !== "index.ts")
29 .map((f) => f.slice(0, -3))
30 .sort();
31 }
32
33 export function indexText(names: readonly string[]): string {
34 return [
35 "// Generated by `bun scripts/mcp-tools.ts` from the files beside it; do not edit.",
36 "// One line per tool, sorted: a new tool is a new file and one new line here.",
37 ...names.map((n) => `export { tool as ${n} } from "./${n}.ts";`),
38 "",
39 ].join("\n");
40 }
41
42 type Documented = { readonly name: string; readonly doc?: string; readonly writes: boolean };
43
44 export function tableText(tools: readonly Documented[]): string {
45 const rows = [...tools]
46 .filter((t) => t.doc !== undefined)
47 .sort((a, b) => a.name.localeCompare(b.name))
48 .map((t) => `| \`${t.name}\` | ${t.doc} | ${t.writes ? "yes" : ""} |`);
49 return [BEGIN, "", "| tool | does | writes |", "| ---- | ---- | ------ |", ...rows, "", END].join("\n");
50 }
51
52 /** docs/MCP.md with its table replaced by `table`. */
53 export function withTable(md: string, table: string): string {
54 const from = md.indexOf(BEGIN);
55 const to = md.indexOf(END);
56 if (from < 0 || to < from) throw new Error(`docs/MCP.md has no ${BEGIN} … ${END} markers`);
57 return md.slice(0, from) + table + md.slice(to + END.length);
58 }
59
60 if (import.meta.main) {
61 const check = process.argv.includes("--check");
62 const stale: string[] = [];
63 const index = indexText(toolFiles());
64 if (readFileSync(INDEX, "utf8") !== index) {
65 stale.push("src/http/mcp/tools/index.ts");
66 if (!check) writeFileSync(INDEX, index);
67 }
68 // After the index is current, so a new file's row is in the table.
69 const { TOOLS } = await import("../src/http/mcp/tools.ts");
70 const md = readFileSync(DOC, "utf8");
71 const doc = withTable(md, tableText(TOOLS));
72 if (md !== doc) {
73 stale.push("docs/MCP.md");
74 if (!check) writeFileSync(DOC, doc);
75 }
76 if (check && stale.length > 0) {
77 console.error(`stale: ${stale.join(", ")}; run \`bun scripts/mcp-tools.ts\``);
78 process.exit(1);
79 }
80 if (!check) console.log(stale.length > 0 ? `wrote ${stale.join(", ")}` : "current");
81 }
src/http/mcp.ts +17 −5003

Diff not shown — 5,020 changed lines (limit 5,000). +17 −5003

View file
src/http/mcp/shared.ts added +220
@@ -0,0 +1,220 @@
1 import { mayView } from "../../core/sealed.ts";
2 import { getIssue } from "../../core/issues.ts";
3 import { canRead, getRepo, type Repo } from "../../core/repos.ts";
4 import { scopeAllows } from "../../core/tokens.ts";
5 import { getUserByName, type User } from "../../core/users.ts";
6 import { applyFleetIds, previewFleetIds } from "../../actions/fleet.ts";
7 import { KIND_CHIPS, parseChipArgs } from "../../core/feed_chips.ts";
8 import type { Ctx } from "../context.ts";
9 import type { BoardError } from "../../actions/boards.ts";
10 import { BOARD_NOT_SAVED } from "../../actions/board_words.ts";
11
12 export type Args = Record<string, unknown>;
13 export type Answer =
14 | { ok: true; value: unknown; card?: Record<string, unknown> }
15 | { ok: false; error: string; message: string };
16
17 export const good = (value: unknown): Answer => ({ ok: true, value });
18 export const attachWords = (error: string): string =>
19 ({
20 forbidden: "attaching a look is for a person who can write this app",
21 not_open: "only an open pull takes a look",
22 no_app: "this repository has no app, so its pulls have no looks",
23 bad_width: "width is phone or desktop",
24 bad_note: "say what it shows in one line, at most 200 characters (says)",
25 bad_image: "image must be a PNG or JPEG, base64, that reads",
26 too_large: "the picture is over 5 MB",
27 })[error] ?? "refused";
28
29 export const bad = (error: string, message: string): Answer => ({
30 ok: false,
31 error,
32 message,
33 });
34
35 export interface Tool {
36 readonly name: string;
37 /** Its row in docs/MCP.md's tool table: markdown, one line; absent, the tool has no row yet. */
38 readonly doc?: string;

Non-blocking: because doc is optional, a new tool file can land with no row in docs/MCP.md, and nothing goes red. 21 tools have no doc today (asks*, capacity, flow, quotas, …), the same as on main, so this is not a regression. A follow-up could make doc required and give those 21 rows, so the generated table stays complete by construction.

39 readonly description: string;
40 readonly inputSchema: Record<string, unknown>;
41 /** Whether it changes anything: `rw` scope, and said so to the client. */
42 readonly writes: boolean;
43 /** Listed, and callable, for a forge admin only. */
44 readonly adminOnly?: true;
45 /** Listed, and callable, only with a token of this scope. */
46 readonly needsAdminScope?: true;
47 /** With this argument absent the tool only reads, whatever the token. */
48 readonly readsWithout?: string;
49 /** A call this accepts only reads, whatever the token. */
50 readonly readsWhen?: (args: Args) => boolean;
51 /** A write that only plans (`dryRun` not false) is a read: `ro` may ask. */
52 readonly dryByDefault?: true;
53 run(ctx: Ctx, user: User, args: Args): Promise<Answer> | Answer;
54 }
55
56 /** Whether this caller is shown the tool and may call it: one rule for the manifest and dispatch. */
57 export const visible = (tool: Tool, ctx: Ctx, user: User): boolean =>
58 (tool.adminOnly !== true || user.isAdmin) &&
59 (tool.needsAdminScope !== true || scopeAllows(ctx.auth.scope ?? "ro", "admin"));
60
61 /** Whether this call only plans or reads, so a read-only token may make it. */
62 export const plansOnly = (tool: Tool, args: Args): boolean =>
63 (tool.dryByDefault === true && args["dryRun"] !== false) ||
64 (tool.readsWithout !== undefined && args[tool.readsWithout] === undefined) ||
65 tool.readsWhen?.(args) === true;
66
67 export const CHIP_ARGS = {
68 hide_kinds: {
69 type: "array",
70 items: { type: "string", enum: [...KIND_CHIPS] },
71 maxItems: 50,
72 description: "Kinds to hide.",
73 },
74 hide_people: {
75 type: "array",
76 items: { type: "string", maxLength: 39 },
77 maxItems: 50,
78 description: "Usernames to hide.",
79 },
80 } as const;
81
82 /** The filter a call names, or undefined for the viewer's saved one. */
83 export function chipArgs(ctx: Ctx, args: Record<string, unknown>) {
84 return parseChipArgs(args, (name) => {
85 const who = getUserByName(ctx.app.db, name.trim());
86 return who && who.kind !== "org" ? who.id : undefined;
87 });
88 }
89
90 export const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
91
92 export const REPO_ARG = { type: "string" } as const;
93
94 /** A repository the caller may read, by `owner/name`; a wrong one is answered with that form. */
95 export function repoArg(ctx: Ctx, user: User, args: Args): Repo | Answer {
96 const [owner, name, ...rest] = str(args["repo"]).split("/");
97 const repo =
98 owner && name && rest.length === 0
99 ? getRepo(ctx.app.db, owner, name)
100 : undefined;
101 // One answer for "no such repository" and "not yours to see".
102 return repo !== undefined && canRead(ctx.app.db, repo, user)
103 ? repo
104 : bad("not_found", "no such app or repository (owner/name)");
105 }
106
107 export const MAX_ARCHIVE_REPOS = 50;
108
109 /** `archive` over `repos`: the fleet's own operation, each row's outcome and the id to undo. */
110 export async function archiveMany(ctx: Ctx, user: User, args: Args): Promise<Answer> {
111 const names = args["repos"];
112 if (
113 !Array.isArray(names) ||
114 names.length === 0 ||
115 names.length > MAX_ARCHIVE_REPOS ||
116 names.some((n) => typeof n !== "string")
117 )
118 return bad("invalid", `\`repos\` is one to ${MAX_ARCHIVE_REPOS} names, owner/name each`);
119 if (args["repo"] !== undefined)
120 return bad("invalid", "give `repo` or `repos`, not both");
121 if (args["archived"] !== undefined && typeof args["archived"] !== "boolean")
122 return bad("invalid", "`archived` is true or false");
123 const archived = args["archived"] ?? true;
124 const dryRun = args["dryRun"] !== false;
125 if (user.kind === "agent")
126 return bad("forbidden", "archiving or waking is a person's call, not an agent's");
127 if (!dryRun && !scopeAllows(ctx.auth.scope ?? "ro", "admin"))
128 return bad("forbidden", "archiving or waking needs an admin token");
129 const ids: string[] = [];
130 const missing: string[] = [];
131 for (const name of new Set(names as string[])) {
132 const repo = repoArg(ctx, user, { repo: name });
133 if (isAnswer(repo)) missing.push(name);
134 else ids.push(repo.id);
135 }
136 const query = `mcp: ${ids.length + missing.length} named`;
137 const action = { verb: "archive", value: archived };
138 const unknown = missing.map((repo) => ({
139 repo,
140 outcome: "refused",
141 reason: "no such app or repository (owner/name)",
142 }));
143 const rowsOf = (rows: readonly { label: string; outcome: string; reason: string }[]) => [
144 ...rows.map((r) => ({ repo: r.label, outcome: r.outcome, reason: r.reason })),
145 ...unknown,
146 ];
147 if (dryRun) {
148 const plan = previewFleetIds(ctx.app, user, "repo", ids, query, action);
149 if (!plan.ok) return bad(plan.error.code, plan.error.message);
150 return good({
151 dryRun: true,
152 archived,
153 rows: rowsOf(plan.value.rows),
154 says: "This is the plan; nothing changed. Send dryRun: false to do it.",
155 });
156 }
157 const op = await applyFleetIds(ctx.app, user, "repo", ids, query, action);
158 if (!op.ok) return bad(op.error.code, op.error.message);
159 return good({
160 dryRun: false,
161 archived,
162 operation: op.value.id,
163 rows: rowsOf(op.value.rows),
164 says: `Done. The \`limb\` tool with action undo and operation ${op.value.id} reverts it for 24 hours.`,
165 });
166 }
167
168 // tools/list is held under 40 KB (test/mcp-size.test.ts). The server checks
169 // every length at the boundary, so the listing leaves the maxLength hints out.
170 export function lean(schema: unknown): unknown {
171 if (Array.isArray(schema)) return schema.map(lean);
172 if (schema === null || typeof schema !== "object") return schema;
173 return Object.fromEntries(
174 Object.entries(schema).filter(([k]) => k !== "maxLength").map(([k, v]) => [k, lean(v)]),
175 );
176 }
177
178 export const APP_ARG = { type: "string" } as const;
179
180 // A child's claim link is a live secret: whoever holds it takes the child.
181 // It stays out of an agent's conversation unless asked for there on purpose
182 // (#839); a person admin of this forge gets a fresh one on /forges, in their
183 // own browser, and hands it over.
184 export const REVEAL_ARG = {
185 type: "boolean",
186 description:
187 "true: show the secret link.",
188 } as const;
189 export const LINK_KEPT =
190 "The claim link is a secret, so it is not in this conversation: a person admin of this forge gets a fresh one on /forges (Send a fresh link) and hands it to the child's person. Send reveal: true only to have it here on purpose.";
191 export const LINK_REVEALED =
192 "claimUrl is a live secret, now in this conversation: whoever holds it takes the child. Hand it only to the child's person; the secret is in its #fragment, so send it whole.";
193
194 /** The app a plan tool names, under `app`, read like any repository. */
195 export const planRepo = (ctx: Ctx, user: User, args: Args): Repo | Answer =>
196 repoArg(ctx, user, { repo: args["app"] });
197
198 /** The issue a plan tool names by number, in that repository. */
199 export function issueArg(ctx: Ctx, repo: Repo, args: Args, key: string) {
200 const n = args[key];
201 const issue =
202 typeof n === "number" && Number.isInteger(n) && n > 0
203 ? getIssue(ctx.app.db, repo.id, n)
204 : undefined;
205 // A sealed report is not there for anyone who may not see it.
206 return issue !== undefined && mayView(ctx.app.db, repo, issue, ctx.auth.user)
207 ? issue
208 : bad("no_such_issue", `no issue #${String(n)}`);
209 }
210
211 export const isAnswer = (v: Repo | Answer): v is Answer => "ok" in v;
212
213 /** Boards are a forge admin's who is a person, as the API gates them: anyone else is forbidden, not told what is there. */
214 export const boardRefused = (): Answer => bad("forbidden", "boards are for a forge admin who is a person");
215 export const mayBoards = (user: User): boolean => user.isAdmin && user.kind !== "agent";
216 export function boardFailed(e: BoardError): Answer {
217 if (e === "not_found") return bad("not_found", "no such board");
218 if (typeof e === "object") return bad("refused", e.refused);
219 return bad(e, BOARD_NOT_SAVED);
220 }
src/http/mcp/tools.ts added +9
@@ -0,0 +1,9 @@
1 /**
2 * Every MCP tool but `report`, by name: one file each under `tools/`,
3 * gathered by the generated `tools/index.ts` (`bun scripts/mcp-tools.ts`).
4 * A new tool is a new file there, so pulls adding tools do not collide (#5924).
5 */
6 import type { Tool } from "./shared.ts";
7 import * as each from "./tools/index.ts";
8
9 export const TOOLS: readonly Tool[] = Object.values(each).sort((a, b) => a.name.localeCompare(b.name));
src/http/mcp/tools/access.ts added +32
@@ -0,0 +1,32 @@
1 import { listAccess } from "../../../actions/access_log.ts";
2 import { good, bad, type Tool } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "access",
6 doc: "`{ since?, ua?, path? }`: what outside agents tried: the redacted log of non-browser requests to public paths, newest first; `since` an ISO time (default a day, at most 14 days), `ua` part of the user agent, `path` a prefix; a forge admin's",
7 description:
8 "Non-browser public-path requests, redacted, newest first (max 500). Forge admin.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 since: { type: "string", maxLength: 40, description: "ISO time." },
13 ua: { type: "string", maxLength: 100, description: "User agent part." },
14 path: { type: "string", maxLength: 200, description: "Path prefix." },
15 },
16 },
17 writes: true,
18 run(ctx, user, args) {
19 const text = (k: string): string | undefined =>
20 typeof args[k] === "string" && args[k] !== "" ? (args[k] as string) : undefined;
21 const since = text("since");
22 const ua = text("ua");
23 const path = text("path");
24 const listed = listAccess(ctx.app, user, {
25 ...(since === undefined ? {} : { since: since.slice(0, 40) }),
26 ...(ua === undefined ? {} : { ua: ua.slice(0, 100) }),
27 ...(path === undefined ? {} : { path: path.slice(0, 200) }),
28 });
29 if (!listed.ok) return bad("not_found", "no such tool here");
30 return good({ requests: listed.value });
31 },
32 };
src/http/mcp/tools/account_role.ts added +52
@@ -0,0 +1,52 @@
1 import { SPEND_WORDS } from "../../../actions/spend_words.ts";
2 import { setRole } from "../../../actions/roles.ts";
3 import { scopeAllows } from "../../../core/tokens.ts";
4 import { good, bad, type Tool, mayBoards } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "account_role",
8 doc: "`{ username, role, usdPerMonth?, dryRun? }`: make a person a `member`, or a `guest` again, with an optional starter allowance (`usdPerMonth`, 0.01 to 10000, promoting only); planned first (`dryRun` is true unless `false`)",
9 description:
10 "Make a person a member, or a guest again; promoting may give a monthly `usdPerMonth` allowance.",
11 inputSchema: {
12 type: "object",
13 properties: {
14 username: { type: "string" },
15 role: { type: "string", enum: ["member", "guest"] },
16 usdPerMonth: { type: ["number", "null"], description: "0.01–10000 or none." },
17 dryRun: { type: "boolean", description: "false to apply" },
18 },
19 required: ["username", "role"],
20 },
21 writes: true,
22 dryByDefault: true,
23 run(ctx, user, args) {
24 const { username, role, usdPerMonth } = args;
25 if (
26 typeof username !== "string" ||
27 (role !== "member" && role !== "guest") ||
28 !(usdPerMonth === undefined || usdPerMonth === null || (typeof usdPerMonth === "number" && Number.isFinite(usdPerMonth)))
29 )
30 return bad("invalid", 'send a username and a role, "member" or "guest"');
31 if (!mayBoards(user)) return bad("forbidden", "a role is a forge admin's to set");
32 const usd = typeof usdPerMonth === "number" ? usdPerMonth : null;
33 if (args["dryRun"] !== false)
34 return good({
35 username,
36 role,
37 usdPerMonth: usd,
38 says: "This is the plan; nothing changed. Send dryRun: false to set it.",
39 });
40 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
41 return bad("forbidden", "a role needs an admin token");
42 const set = setRole(ctx.app, user, username, role, usd);
43 if (!set.ok)
44 return bad(
45 set.error === "invalid_amount" ? "invalid" : "forbidden",
46 set.error === "invalid_amount"
47 ? SPEND_WORDS.usdPerMonth
48 : "no such person, or not one a role is for",
49 );
50 return good(set.value);
51 },
52 };
src/http/mcp/tools/account_suspend.ts added +32
@@ -0,0 +1,32 @@
1 import { suspendAccount, unsuspendAccount } from "../../../actions/accounts.ts";
2 import { scopeAllows } from "../../../core/tokens.ts";
3 import { lastOwnerSays } from "../../../core/orgs.ts";
4 import { ADMIN_WORDS } from "../../../actions/admin_words.ts";
5 import { good, bad, type Tool, mayBoards } from "../shared.ts";
6
7 export const tool: Tool = {
8 name: "account_suspend",
9 doc: "`{ username, suspended }`: suspend a person's account, or unsuspend it with `false`; their sessions and tokens stop at once; the API's rules (not yourself, another admin or an agent; not the only owner of an organization); an admin token",
10 description: "Suspend an account, or `suspended: false` to lift it.",
11 inputSchema: {
12 type: "object",
13 properties: { username: { type: "string" }, suspended: { type: "boolean" } },
14 required: ["username", "suspended"],
15 },
16 writes: true,
17 async run(ctx, user, args) {
18 const { username, suspended } = args;
19 if (typeof username !== "string" || typeof suspended !== "boolean")
20 return bad("invalid", "send a username and suspended, true or false");
21 if (!mayBoards(user)) return bad("forbidden", "an account is a forge admin's to suspend");
22 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
23 return bad("forbidden", "suspending needs an admin token");
24 const done = suspended
25 ? await suspendAccount(ctx.app, user, username)
26 : unsuspendAccount(ctx.app, user, username);
27 if (done.ok) return good({ username: done.value.username, suspendedAt: done.value.suspendedAt });
28 if (done.error === "not_found") return bad("not_found", "no such person");
29 if (done.error === "last_owner") return bad("last_owner", lastOwnerSays(ctx.app.db, username));
30 return bad("not_allowed", ADMIN_WORDS.selfSuspend);
31 },
32 };
src/http/mcp/tools/account_verify.ts added +27
@@ -0,0 +1,27 @@
1 import { verifyAccount } from "../../../actions/accounts.ts";
2 import { scopeAllows } from "../../../core/tokens.ts";
3 import { good, bad, type Tool, mayBoards } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "account_verify",
7 doc: "`{ username }`: mark a person's email verified, as `POST …/admin/accounts/:username/verify` does; a person admin's, with an admin token",
8 description: "Mark a person's email verified (admin).",
9 inputSchema: {
10 type: "object",
11 properties: { username: { type: "string" } },
12 required: ["username"],
13 },
14 writes: true,
15 run(ctx, user, args) {
16 const { username } = args;
17 if (typeof username !== "string") return bad("invalid", "send a username");
18 if (!mayBoards(user)) return bad("forbidden", "verifying a person is a forge admin's call");
19 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
20 return bad("forbidden", "verifying a person needs an admin token");
21 const done = verifyAccount(ctx.app, user, username);
22 if (done.ok) return good({ username: done.value.username, verified: true });
23 return done.error === "not_found"
24 ? bad("not_found", "no such account")
25 : bad("invalid", "an agent or a removed account has no email to verify");
26 },
27 };
src/http/mcp/tools/agent.ts added +63
@@ -0,0 +1,63 @@
1 import { createAgent, deleteAgent, updateAgent } from "../../../actions/crew.ts";
2 import type { Agent } from "../../../core/agents.ts";
3 import { good, bad, type Tool, str } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "agent",
7 doc: "`{ action, username, … }`: `create` a crew agent, `update` its `model`, `brief` or `budgetUsd`, or `remove` it; an admin's, with an `admin` token, as `/api/v1/agents`",
8 description: "Admin: create|update|remove an agent.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 action: { type: "string" },
13 username: { type: "string" },
14 displayName: { type: "string" },
15 model: { type: "string" },
16 brief: { type: "string" },
17 budgetUsd: { type: "number" },
18 },
19 required: ["action", "username"],
20 },
21 writes: true,
22 adminOnly: true,
23 needsAdminScope: true,
24 async run(ctx, user, args) {
25 const username = str(args["username"]);
26 const model = args["model"];
27 const brief = args["brief"];
28 const budgetUsd = args["budgetUsd"];
29 const fields = {
30 ...(typeof model === "string" ? { model } : {}),
31 ...(typeof brief === "string" ? { brief } : {}),
32 ...(typeof budgetUsd === "number" ? { budgetUsd } : {}),
33 };
34 const shown = (a: Agent) => ({
35 username: a.user.username,
36 displayName: a.user.displayName,
37 model: a.model,
38 brief: a.brief,
39 budgetUsd: a.budgetUsd,
40 createdAt: a.createdAt,
41 });
42 const action = args["action"];
43 if (action === "create") {
44 const made = await createAgent(ctx.app, user, {
45 username,
46 ...(typeof args["displayName"] === "string"
47 ? { displayName: args["displayName"] }
48 : {}),
49 ...fields,
50 });
51 return made.ok ? good(shown(made.value)) : bad(made.error, made.error);
52 }
53 if (action === "update") {
54 const done = updateAgent(ctx.app, user, username, fields);
55 return done.ok ? good(shown(done.value)) : bad(done.error, done.error);
56 }
57 if (action === "remove") {
58 const gone = deleteAgent(ctx.app, user, username);
59 return gone.ok ? good({ removed: username }) : bad(gone.error, gone.error);
60 }
61 return bad("invalid", "action is create, update or remove");
62 },
63 };
src/http/mcp/tools/app_deploy.ts added +37
@@ -0,0 +1,37 @@
1 import { redeployEnv, stopEnv } from "../../../actions/apps.ts";
2 import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "app_deploy",
6 doc: "`{ repo, env, action }`: `redeploy` or `stop` an app's `production` or a pull's `pull-N` preview; a writer's, queued as `POST …/app/:env/redeploy` and `…/stop` are; answers `{ queued, run }` or `{ stopped }`",
7 description:
8 "Redeploy or stop production or a pull-N preview.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 repo: REPO_ARG,
13 env: { type: "string" },
14 action: { type: "string", enum: ["redeploy", "stop"] },
15 },
16 required: ["repo", "env", "action"],
17 },
18 writes: true,
19 async run(ctx, user, args) {
20 const repo = repoArg(ctx, user, args);
21 if (isAnswer(repo)) return repo;
22 const env = str(args["env"]);
23 const action = str(args["action"]);
24 if (action !== "redeploy" && action !== "stop")
25 return bad("invalid_action", "action is redeploy or stop");
26 if (action === "stop") {
27 const stopped = await stopEnv(ctx.app, user, repo, env);
28 return stopped.ok
29 ? good({ stopped: true })
30 : bad(stopped.error, `refused: ${stopped.error}`);
31 }
32 const queued = await redeployEnv(ctx.app, user, repo, env);
33 return queued.ok
34 ? good({ queued: true, run: queued.value?.id ?? null })
35 : bad(queued.error, `refused: ${queued.error}`);
36 },
37 };
src/http/mcp/tools/app_env.ts added +50
@@ -0,0 +1,50 @@
1 import { scopeAllows } from "../../../core/tokens.ts";
2 import { deleteAppEnvVar, listAppEnv, setAppEnvVar } from "../../../actions/apps.ts";
3 import { type Answer, good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "app_env",
7 doc: "`{ repo, action, name?, value?, scope? }`: `list` an app's variable names and scopes, `set` one, or `unset` it, in `all` (default), `production` or `preview`; the API's rules and errors, an admin of the repository with an admin-scope token; a value is never read back",
8 description:
9 "An app's environment variables: list names, set or unset one by scope; values are write-only. Admin.",
10 inputSchema: {
11 type: "object",
12 properties: {
13 repo: REPO_ARG,
14 action: { type: "string", enum: ["list", "set", "unset"] },
15 name: { type: "string" },
16 value: { type: "string" },
17 scope: { type: "string", enum: ["all", "production", "preview"] },
18 },
19 required: ["repo", "action"],
20 },
21 writes: true,
22 async run(ctx, user, args) {
23 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
24 return bad("forbidden", "an app's environment needs an admin-scope token");
25 const repo = repoArg(ctx, user, args);
26 if (isAnswer(repo)) return repo;
27 const action = args["action"];
28 const scope = typeof args["scope"] === "string" ? args["scope"] : "all";
29 const name = typeof args["name"] === "string" ? args["name"] : "";
30 // The API's errors, by code: never the value.
31 const refused = (error: string): Answer =>
32 error === "not_found"
33 ? bad("not_found", "no such app or repository (owner/name)")
34 : bad(error, error);
35 if (action === "list") {
36 const listed = listAppEnv(ctx.app, user, repo);
37 return listed.ok ? good({ items: listed.value, next_cursor: null }) : refused(listed.error);
38 }
39 if (action === "set") {
40 const value = typeof args["value"] === "string" ? args["value"] : "";
41 const set = await setAppEnvVar(ctx.app, user, repo, name, value, scope);
42 return set.ok ? good(set.value) : refused(set.error);
43 }
44 if (action === "unset") {
45 const removed = await deleteAppEnvVar(ctx.app, user, repo, name, scope);
46 return removed.ok ? good({}) : refused(removed.error);
47 }
48 return bad("invalid", "`action` is list, set or unset");
49 },
50 };
src/http/mcp/tools/app_members.ts added +43
@@ -0,0 +1,43 @@
1 import { scopeAllows } from "../../../core/tokens.ts";
2 import { readAppMembers, setAppMembersThrough } from "../../../actions/app_members.ts";
3 import { type Answer, good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "app_members",
7 doc: "`{ 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",
8 description:
9 "The people a private app names, who may enter it and its previews. With `members`, plans.",
10 inputSchema: {
11 type: "object",
12 properties: {
13 repo: REPO_ARG,
14 members: { type: "array", items: { type: "string" } },
15 dryRun: { type: "boolean" },
16 },
17 required: ["repo"],
18 },
19 writes: true,
20 dryByDefault: true,
21 run(ctx, user, args) {
22 const repo = repoArg(ctx, user, args);
23 if (isAnswer(repo)) return repo;
24 const members = args["members"];
25 const answer = (
26 result: ReturnType<typeof readAppMembers> | ReturnType<typeof setAppMembersThrough>,
27 ): Answer =>
28 result.ok
29 ? good(result.value)
30 : result.error.kind === "invalid"
31 ? bad("invalid", result.error.message)
32 : result.error.kind === "forbidden"
33 ? bad("forbidden", "the people an app names are its writers' to choose")
34 : bad("not_found", "no such app or repository (owner/name)");
35 if (members === undefined) return answer(readAppMembers(ctx.app, user, repo));
36 if (!Array.isArray(members) || !members.every((m) => typeof m === "string"))
37 return bad("invalid", "members is a list of usernames");
38 const dryRun = args["dryRun"] !== false;
39 if (!dryRun && !scopeAllows(ctx.auth.scope ?? "ro", "admin"))
40 return bad("forbidden", "changing who may enter an app needs an admin-scope token");
41 return answer(setAppMembersThrough(ctx.app, user, repo, { members, dryRun }));
42 },
43 };
src/http/mcp/tools/apps.ts added +37
@@ -0,0 +1,37 @@
1 import { appsRuntime, envsFor } from "../../../apps/runtime.ts";
2 import { listActiveRuns } from "../../../core/runs.ts";
3 import { appsOf } from "../../../web/studio/asks.ts";
4 import { good, type Tool } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "apps",
8 doc: "the apps you can see: live or not, where served, how each runs itself, what is working",
9 description:
10 "The apps here you can see: live or not, where served, how each runs, what is working.",
11 inputSchema: { type: "object", properties: {} },
12 writes: false,
13 async run(ctx, user) {
14 const { repos, records } = appsOf(ctx, user);
15 const runtime = appsRuntime(ctx.app);
16 const active = listActiveRuns(
17 ctx.app.db,
18 repos.map((r) => r.id),
19 );
20 return good(
21 repos.map((repo) => {
22 const record = records.get(repo.id)!;
23 const production = envsFor(ctx.app, user, record).find(
24 (e) => e.env === "production",
25 );
26 return {
27 app: `${repo.ownerName}/${repo.name}`,
28 description: repo.description,
29 live: production?.state ?? "stopped",
30 url: runtime?.urlFor(record, "production") ?? null,
31 autonomy: repo.autonomy,
32 working: active.filter((r) => r.repoId === repo.id).length,
33 };
34 }),
35 );
36 },
37 };
src/http/mcp/tools/archive.ts added +52
@@ -0,0 +1,52 @@
1 import { scopeAllows } from "../../../core/tokens.ts";
2 import { setArchived } from "../../../actions/repos.ts";
3 import { good, bad, type Tool, REPO_ARG, repoArg, MAX_ARCHIVE_REPOS, archiveMany, isAnswer } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "archive",
7 doc: "`{ repo | repos, archived?, dryRun? }`: archive (true) or wake (false) a repository, by name; planned first (`dryRun` is true unless `false`); only the repository's admin with an admin token, never an agent. `repos` (at most 50) runs the fleet `archive` operation over them, answers each one's outcome and the `operation` id; `limb` with `action: \"undo\"`, that `operation` and any one of the repositories as `app` reverts it for a day",
8 description:
9 "Archive a repository or wake it (`archived` true/false); deletes nothing. Plans first; admin token.",
10 inputSchema: {
11 type: "object",
12 properties: {
13 repo: REPO_ARG,
14 repos: {
15 type: "array",
16 items: { type: "string" },
17 maxItems: MAX_ARCHIVE_REPOS,
18 description: "owner/name list, one operation.",
19 },
20 archived: { type: "boolean" },
21 dryRun: {
22 type: "boolean",
23 description: "false to apply",
24 },
25 },
26 },
27 writes: true,
28 dryByDefault: true,
29 async run(ctx, user, args) {
30 if (args["repos"] !== undefined) return archiveMany(ctx, user, args);
31 const repo = repoArg(ctx, user, args);
32 if (isAnswer(repo)) return repo;
33 const archived = args["archived"];
34 if (archived === undefined) return good({ archived: repo.archivedAt !== null });
35 if (typeof archived !== "boolean")
36 return bad("invalid", "`archived` is true or false");
37 const dryRun = args["dryRun"] !== false;
38 if (!dryRun && !scopeAllows(ctx.auth.scope ?? "ro", "admin"))
39 return bad("forbidden", "archiving or waking needs an admin token");
40 const set = await setArchived(ctx.app, user, repo, archived, dryRun);
41 if (!set.ok)
42 return bad("forbidden", "only an admin of this repository archives or wakes it");
43 return good({
44 ...set.value,
45 says: dryRun
46 ? `This is the plan; nothing changed. It is ${set.value.was ? "archived" : "awake"} now; send dryRun: false to ${set.value.archived ? "archive" : "wake"} it.`
47 : set.value.archived
48 ? "Archived: put away, not deleted."
49 : "Awake again.",
50 });
51 },
52 };
src/http/mcp/tools/asked.ts added +35
@@ -0,0 +1,35 @@
1 import { askedIn, askedJson, askedRepos, isAskedOrder, isAskedShow } from "../../../core/asked.ts";
2 import { streamJson, thoughtStream } from "../../../core/thought_stream.ts";
3 import { good, bad, type Tool, str, REPO_ARG } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "asked",
7 doc: "`{ 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",
8 description:
9 "What people asked the crew for where you write: by default nothing merged yet, oldest first.",
10 inputSchema: {
11 type: "object",
12 properties: {
13 repo: REPO_ARG,
14 show: { type: "string", enum: ["unshipped", "flagged", "all"] },
15 order: { type: "string", enum: ["oldest", "newest"] },
16 },
17 },
18 writes: false,
19 run(ctx, user, args) {
20 const show = args["show"] ?? "unshipped";
21 if (!isAskedShow(show))
22 return bad("invalid_show", "show is unshipped, flagged or all");
23 const order = args["order"] ?? "oldest";
24 if (!isAskedOrder(order))
25 return bad("invalid_order", "order is oldest or newest");
26 const spec = args["repo"] === undefined ? null : str(args["repo"]);
27 const repos = askedRepos(ctx.app.db, user, spec);
28 if (spec !== null && repos.length === 0)
29 return bad("not_found", "no such app or repository you can write (owner/name)");
30 return good({
31 asked: askedIn(ctx.app.db, repos, { show, order }).map(askedJson),
32 thoughts: thoughtStream(ctx.app.db, user).map(streamJson),
33 });
34 },
35 };
src/http/mcp/tools/asks.ts added +15
@@ -0,0 +1,15 @@
1 import { asksAnswer } from "../../../web/studio/asks.ts";
2 import { good, bad, type Tool, CHIP_ARGS, chipArgs } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "asks",
6 description:
7 "What waits on you, most important first: decision, options, cost of inaction; saved filter applies",
8 inputSchema: { type: "object", properties: { ...CHIP_ARGS } },
9 writes: false,
10 run(ctx, user, args) {
11 const chips = chipArgs(ctx, args);
12 if (chips && "error" in chips) return bad("invalid", chips.error);
13 return good(asksAnswer(ctx, user, chips));
14 },
15 };
src/http/mcp/tools/asks_act.ts added +27
@@ -0,0 +1,27 @@
1 import { applyAsksBulk, previewAsksBulk } from "../../../actions/asks_bulk.ts";
2 import { good, bad, type Tool } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "asks_act",
6 description:
7 "Decide asks (`items`: ask ids, `issue:<id>`): dismiss, decline, ship (final), close (`reason`), mute, replan",
8 inputSchema: {
9 type: "object",
10 properties: {
11 action: { type: "string", enum: ["dismiss", "decline", "mute", "ship", "close", "replan"] },
12 items: { type: "array", items: { type: "string" }, maxItems: 100 },
13 reason: { type: "string", maxLength: 300 },
14 dryRun: { type: "boolean", description: "false to apply" },
15 },
16 required: ["action", "items"],
17 },
18 writes: true,
19 dryByDefault: true,
20 async run(ctx, user, args) {
21 const answer =
22 args["dryRun"] === false
23 ? await applyAsksBulk(ctx.app, user, args["action"], args["items"], args["reason"])
24 : previewAsksBulk(ctx.app, user, args["action"], args["items"], args["reason"]);
25 return answer.ok ? good(answer.value) : bad(answer.error.code, answer.error.message);
26 },
27 };
src/http/mcp/tools/asks_decide.ts added +18
@@ -0,0 +1,18 @@
1 import { decideAsk } from "../../../actions/asks_decide.ts";
2 import { good, bad, type Tool } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "asks_decide",
6 description:
7 "Let the decider answer a hand-over (`item`: `parked:<id>`); only a sure set_aside (70%+) clears it.",
8 inputSchema: {
9 type: "object",
10 properties: { item: { type: "string", maxLength: 80 } },
11 required: ["item"],
12 },
13 writes: true,
14 async run(ctx, user, args) {
15 const done = await decideAsk(ctx.app, user, args["item"]);
16 return done.ok ? good(done.value) : bad(done.error.code, done.error.message);
17 },
18 };
src/http/mcp/tools/asks_proposal.ts added +24
@@ -0,0 +1,24 @@
1 import { actOnProposal, isProposalVerb, PROPOSAL_VERBS } from "../../../actions/proposals.ts";
2 import { good, bad, type Tool } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "asks_proposal",
6 description:
7 "Act on a wave proposal (`asks` row): `approve`, `redirect` (`words`), `later` (`again`). Owner.",
8 inputSchema: {
9 type: "object",
10 properties: {
11 id: { type: "string", maxLength: 80 },
12 verb: { type: "string", enum: [...PROPOSAL_VERBS] },
13 words: { type: "string", maxLength: 65536 },
14 },
15 required: ["id", "verb"],
16 },
17 writes: true,
18 run(ctx, user, args) {
19 const verb = args["verb"];
20 if (!isProposalVerb(verb)) return bad("invalid", `verb is ${PROPOSAL_VERBS.join(", ")}`);
21 const done = actOnProposal(ctx.app, user, verb, args["id"], args["words"]);
22 return done.ok ? good(done.value ?? { ok: true }) : bad(done.error.code, done.error.message);
23 },
24 };
src/http/mcp/tools/asks_undecide.ts added +18
@@ -0,0 +1,18 @@
1 import { undoDecideAsk } from "../../../actions/asks_decide.ts";
2 import { good, bad, type Tool } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "asks_undecide",
6 description:
7 "Put a card the decider set aside back on your board, within a day. `id` is from `asks_decide`.",
8 inputSchema: {
9 type: "object",
10 properties: { id: { type: "string", maxLength: 80 } },
11 required: ["id"],
12 },
13 writes: true,
14 run(ctx, user, args) {
15 const done = undoDecideAsk(ctx.app, user, args["id"]);
16 return done.ok ? good(done.value) : bad(done.error.code, done.error.message);
17 },
18 };
src/http/mcp/tools/assigned.ts added +30
@@ -0,0 +1,30 @@
1 import { mayView } from "../../../core/sealed.ts";
2 import { listAssignedTo } from "../../../core/issues.ts";
3 import { canRead, getReposByIds } from "../../../core/repos.ts";
4 import { good, bad, type Tool } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "assigned",
8 doc: "`{ 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`",
9 description: "Open issues and pulls assigned to you.",
10 inputSchema: {
11 type: "object",
12 properties: { limit: { type: "integer", minimum: 1, maximum: 50 } },
13 },
14 writes: false,
15 run(ctx, user, args) {
16 const limit = args["limit"] ?? 50;
17 if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 50)
18 return bad("invalid", "`limit` is a whole number from 1 to 50");
19 const db = ctx.app.db;
20 const issues = listAssignedTo(db, user.id, limit);
21 const repos = getReposByIds(db, issues.map((i) => i.repoId));
22 const items = [];
23 for (const issue of issues) {
24 const repo = repos.get(issue.repoId);
25 if (!repo || !canRead(db, repo, user) || !mayView(db, repo, issue, user)) continue;
26 items.push({ ...issue, repo: { owner: repo.ownerName, name: repo.name } });
27 }
28 return good({ items, next_cursor: null });
29 },
30 };
src/http/mcp/tools/attach_look.ts added +59
@@ -0,0 +1,59 @@
1 import { attachLook } from "../../../actions/attach_look.ts";
2 import { lookUrl } from "../../../core/looks.ts";
3 import { getPull } from "../../../core/pulls.ts";
4 import { good, attachWords, bad, type Tool, str, APP_ARG, planRepo, isAnswer } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "attach_look",
8 description:
9 "Attach a PNG or JPEG (base64 `image`, max 5 MB) to an open pull: `phone` or `desktop`, `says`.",
10 inputSchema: {
11 type: "object",
12 properties: {
13 app: APP_ARG,
14 pull: { type: "integer" },
15 width: { type: "string", enum: ["phone", "desktop"] },
16 says: {
17 type: "string",
18 },
19 image: { type: "string" },
20 dryRun: {
21 type: "boolean",
22 description: "false to apply",
23 },
24 },
25 required: ["app", "pull", "width", "says", "image"],
26 },
27 writes: true,
28 dryByDefault: true,
29 run(ctx, user, args) {
30 const repo = planRepo(ctx, user, args);
31 if (isAnswer(repo)) return repo;
32 const n = args["pull"];
33 const pull =
34 typeof n === "number" && Number.isInteger(n) && n > 0
35 ? getPull(ctx.app.db, repo.id, n)
36 : undefined;
37 if (pull === undefined)
38 return bad("no_such_pull", `no pull #${String(n)}`);
39 const input = {
40 width: str(args["width"]),
41 note: str(args["says"]),
42 image: typeof args["image"] === "string" ? args["image"] : "",
43 };
44 const name = `${repo.ownerName}/${repo.name}#${pull.number}`;
45 const dry = args["dryRun"] !== false;
46 const attached = attachLook(ctx.app, user, repo, pull, input, dry);
47 if (!attached.ok) return bad(attached.error, attachWords(attached.error));
48 if (dry)
49 return good({
50 says: `Would attach a ${input.width} look to ${name}. Nothing changed; send dryRun: false to do it.`,
51 dryRun: true,
52 });
53 return good({
54 says: `Attached a ${input.width} look to ${name}.`,
55 url: lookUrl(`/${repo.ownerName}/${repo.name}`, attached.value),
56 dryRun: false,
57 });
58 },
59 };
src/http/mcp/tools/audit.ts added +53
@@ -0,0 +1,53 @@
1 import { audit } from "../../../contract/audit.ts";
2 import { appsOf } from "../../../web/studio/asks.ts";
3 import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "audit",
7 doc: "who can do what, per app, read from its contract.json in git: yes, own or no per door, and whether outsiders may direct its crew (`crewOutsiders`)",
8 description:
9 "Who can do what in every app you can see, from each contract.json: resource × verb, yes, own or no.",
10 inputSchema: {
11 type: "object",
12 properties: {
13 repo: {
14 ...REPO_ARG,
15 description:
16 "owner/name; all when absent.",
17 },
18 },
19 },
20 writes: false,
21 async run(ctx, user, args) {
22 const one =
23 typeof args["repo"] === "string" ? repoArg(ctx, user, args) : undefined;
24 if (one !== undefined && isAnswer(one)) return one;
25 const repos = one === undefined ? appsOf(ctx, user).repos : [one];
26 const audited = await Promise.all(
27 repos.map((repo) => audit(ctx.app.reposRoot, repo)),
28 );
29 return good(
30 audited.map((a) => ({
31 app: `${a.repo.ownerName}/${a.repo.name}`,
32 commit: a.oid ?? null,
33 // Who may direct the crew is part of who can do what here.
34 crewOutsiders: a.repo.crewOutsiders,
35 standing: a.standing.kind,
36 ...(a.standing.kind === "unsound"
37 ? { problems: a.standing.problems }
38 : a.standing.kind === "sound"
39 ? {
40 identities: a.standing.identities,
41 open: a.standing.open.map((c) => `${c.resource}.${c.verb}`),
42 cells: a.standing.cells.map((c) => ({
43 resource: c.resource,
44 verb: c.verb,
45 as: c.as,
46 answer: c.answer,
47 })),
48 }
49 : {}),
50 })),
51 );
52 },
53 };
src/http/mcp/tools/board_delete.ts added +20
@@ -0,0 +1,20 @@
1 import { deleteBoard } from "../../../actions/boards.ts";
2 import { good, type Tool, str, boardRefused, mayBoards, boardFailed } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "board_delete",
6 doc: "`{ slug }` (person admins): delete a board by slug; its history keeps it",
7 description:
8 "Delete a board by slug (history keeps it). Person admins only.",
9 inputSchema: {
10 type: "object",
11 properties: { slug: { type: "string", maxLength: 40 } },
12 required: ["slug"],
13 },
14 writes: true,
15 async run(ctx, user, args) {
16 if (!mayBoards(user)) return boardRefused();
17 const done = await deleteBoard(ctx.app, user, str(args["slug"]));
18 return done.ok ? good({ deleted: str(args["slug"]) }) : boardFailed(done.error);
19 },
20 };
src/http/mcp/tools/board_pins.ts added +35
@@ -0,0 +1,35 @@
1 import { listPinned as listPinnedPanels, movePinned, pinPanel, unpinPanel } from "../../../actions/board-pins.ts";
2 import { good, bad, type Tool, boardRefused, mayBoards } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "board_pins",
6 doc: "`{ op?, slug?, title?, by? }` (person admins): without `op`, the panels pinned to your home (`slug`, `title`); `pin` or `unpin` a panel by board `slug` and panel `title`, or `move` it up or down with `by` (-1 or 1)",
7 description:
8 "The panels pinned to the caller's home (person admins). No `op` lists them.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 op: { type: "string", enum: ["pin", "unpin", "move"] },
13 slug: { type: "string", maxLength: 40 },
14 title: { type: "string", maxLength: 80 },
15 by: { type: "integer", enum: [-1, 1] },
16 },
17 },
18 writes: true,
19 async run(ctx, user, args) {
20 if (!mayBoards(user)) return boardRefused();
21 const op = args["op"];
22 if (op === undefined) {
23 const listed = await listPinnedPanels(ctx.app, user);
24 return listed.ok ? good({ pins: listed.value.map((p) => ({ slug: p.slug, title: p.title })) }) : boardRefused();
25 }
26 const [slug, title] = [args["slug"], args["title"]];
27 const done =
28 op === "pin" ? await pinPanel(ctx.app, user, slug, title)
29 : op === "unpin" ? unpinPanel(ctx.app, user, slug, title)
30 : op === "move" ? movePinned(ctx.app, user, slug, title, args["by"])
31 : undefined;
32 if (!done) return bad("invalid", "op must be pin, unpin or move");
33 return done.ok ? good({ ...done.value }) : bad(done.error, "that could not be pinned");
34 },
35 };
src/http/mcp/tools/board_write.ts added +23
@@ -0,0 +1,23 @@
1 import { writeBoard } from "../../../actions/boards.ts";
2 import { good, type Tool, str, boardRefused, mayBoards, boardFailed } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "board_write",
6 doc: "`{ slug, board }` (person admins): write a whole board under a slug, made or replaced: `{ title, range, refresh, panels: [{ title, chart, query, w, h }] }`",
7 description:
8 "Write a whole board under a slug: {title, range, refresh, panels: [{title, chart, query, w, h}]}.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 slug: { type: "string", maxLength: 40 },
13 board: { type: "object" },
14 },
15 required: ["slug", "board"],
16 },
17 writes: true,
18 async run(ctx, user, args) {
19 if (!mayBoards(user)) return boardRefused();
20 const done = await writeBoard(ctx.app, user, str(args["slug"]), args["board"]);
21 return done.ok ? good({ slug: str(args["slug"]), ...done.value }) : boardFailed(done.error);
22 },
23 };
src/http/mcp/tools/boards.ts added +47
@@ -0,0 +1,47 @@
1 import { listAllBoards, showBoard } from "../../../actions/boards.ts";
2 import { panelData, parseWindow } from "../../../boards/panel_data.ts";
3 import { good, bad, type Tool, str, boardRefused, mayBoards, boardFailed } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "boards",
7 doc: "`{ slug?, panel?, from?, to?, step? }` (person admins): without `slug`, lists the forge's boards (slug, title, range, refresh, panels); with `slug`, that board; with `panel` (place from 0, up to 23), that panel's data over the `from`/`to` window and `step`",
8 description:
9 "The forge's boards for person admins: slug, title, range, refresh and panels.",
10 inputSchema: {
11 type: "object",
12 properties: {
13 slug: { type: "string", maxLength: 40 },
14 panel: { type: "integer", minimum: 0, maximum: 23, description: "One panel, from 0." },
15 from: { type: "string", maxLength: 64 },
16 to: { type: "string", maxLength: 64 },
17 step: { type: "string", maxLength: 16, description: "With panel: seconds per bucket, or 5m." },
18 },
19 },
20 writes: false,
21 async run(ctx, user, args) {
22 if (!mayBoards(user)) return boardRefused();
23 if (args["slug"] === undefined) {
24 const listed = await listAllBoards(ctx.app, user);
25 return listed.ok
26 ? good({ boards: listed.value.map((b) => ({ slug: b.slug, ...b.board })) })
27 : boardFailed(listed.error);
28 }
29 const shown = await showBoard(ctx.app, user, str(args["slug"]));
30 if (!shown.ok) return boardFailed(shown.error);
31 if (args["panel"] === undefined) return good({ slug: str(args["slug"]), ...shown.value });
32 const index = args["panel"];
33 const panel = typeof index === "number" && Number.isInteger(index) ? shown.value.panels[index] : undefined;
34 if (!panel) return bad("not_found", "that board has no panel there");
35 const asked = new URLSearchParams();
36 for (const k of ["from", "to", "step"]) {
37 const v = args[k];
38 if (v !== undefined) asked.set(k, typeof v === "number" ? String(v) : str(v));
39 }
40 const window = parseWindow(asked);
41 if (!window.ok) return bad("invalid_window", window.error);
42 const data = panelData(ctx.app.db, shown.value, panel, window.window);
43 if (!data.ok) return bad("bad_query", data.error);
44 const { ok: _, ...out } = data;
45 return good({ board: str(args["slug"]), panel: index, title: panel.title, chart: panel.chart, ...out });
46 },
47 };
src/http/mcp/tools/branches.ts added +89
@@ -0,0 +1,89 @@
1 import { createBranch, deleteBranch } from "../../../actions/branches.ts";
2 import { listBranches } from "../../../git/branches.ts";
3 import { listRefs } from "../../../git/read.ts";
4 import { repoDir } from "../../../git/repos.ts";
5 import { runGit } from "../../../git/env.ts";
6 import { CREATE_ERRORS, DELETE_ERRORS } from "../../../web/pages/branches.ts";
7 import { BRANCH_NOT_CREATED, BRANCH_NOT_DELETED } from "../../../actions/branch_words.ts";
8 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
9
10 export const tool: Tool = {
11 name: "branches",
12 doc: "`{ repo, name?, action?, from?, kind? }`: without `name`, lists the branches; `kind: \"tags\"` lists the tags instead (as GET `/api/v1/repos/:owner/:name/tags` does, newest first; no `name` or `action`); with `name` and `action` (`create` from `from`, or `delete`) it does what the branches page does: the default branch is never deleted, only a writer may act",
13 description:
14 "A repository's branches; `name` and `action` create (from `from`) or delete one, not the default.",
15 inputSchema: {
16 type: "object",
17 properties: {
18 repo: REPO_ARG,
19 name: { type: "string", maxLength: 255 },
20 action: { type: "string", enum: ["create", "delete"] },
21 from: { type: "string", maxLength: 255 },
22 kind: { type: "string", enum: ["branches", "tags"], description: "tags: newest first" },
23 },
24 required: ["repo"],
25 },
26 writes: true,
27 readsWithout: "name",
28 async run(ctx, user, args) {
29 const kind = args["kind"] ?? "branches";
30 if (kind !== "branches" && kind !== "tags") return bad("invalid", "`kind` is branches or tags");
31 if (kind === "tags" && (args["name"] !== undefined || args["action"] !== undefined))
32 return bad("invalid", "`kind: tags` only lists; leave out `name` and `action`");
33 const repo = repoArg(ctx, user, args);
34 if (isAnswer(repo)) return repo;
35 if (kind === "tags") {
36 const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name);
37 const refs = await listRefs(dir, "refs/tags/");
38 if (!refs.ok) return good({ tags: [] });
39 const order = await runGit(
40 ["for-each-ref", "--sort=-creatordate", "--format=%(refname)", "--", "refs/tags/"],
41 { cwd: dir },
42 );
43 const rank = new Map((order.ok ? order.value.split("\n") : []).map((n, i) => [n, i]));
44 refs.value.sort((a, b) => (rank.get(a.name) ?? 0) - (rank.get(b.name) ?? 0));
45 return good({
46 tags: refs.value.map((r) => ({
47 name: r.name.replace("refs/tags/", ""),
48 oid: r.oid,
49 commit: r.peeled ?? r.oid,
50 })),
51 });
52 }
53 const base = `/${repo.ownerName}/${repo.name}/branches`;
54 if (args["name"] === undefined) {
55 const listed = await listBranches(
56 repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
57 repo.defaultBranch,
58 );
59 return good({
60 branches: listed.ok
61 ? listed.value.branches.map((b) => ({ name: b.name, default: b.isDefault, ahead: b.ahead, behind: b.behind }))
62 : [],
63 url: base,
64 });
65 }
66 const name = args["name"];
67 const action = args["action"];
68 const from = args["from"];
69 if (typeof name !== "string" || name.length > 255)
70 return bad("invalid", "`name` is a branch name");
71 if (action !== "create" && action !== "delete")
72 return bad("invalid", "`action` is create or delete");
73 if (from !== undefined && (typeof from !== "string" || from.length > 255))
74 return bad("invalid", "`from` is a branch, tag or commit");
75 if (action === "create") {
76 const made = await createBranch(ctx.app, user, repo, {
77 name,
78 ...(from === undefined || from === "" ? {} : { from }),
79 });
80 if (!made.ok)
81 return bad(made.error, CREATE_ERRORS[made.error] ?? BRANCH_NOT_CREATED);
82 return good({ says: `Created ${made.value.name} at ${made.value.oid.slice(0, 7)}.`, url: base });
83 }
84 const gone = await deleteBranch(ctx.app, user, repo, name);
85 if (!gone.ok)
86 return bad(gone.error, DELETE_ERRORS[gone.error] ?? BRANCH_NOT_DELETED);
87 return good({ says: `Deleted ${gone.value.name}.`, url: base });
88 },
89 };
src/http/mcp/tools/capacity.ts added +43
@@ -0,0 +1,43 @@
1 import { changeCapacity, listCapacity } from "../../../actions/capacity.ts";
2 import { scopeAllows } from "../../../core/tokens.ts";
3 import { good, bad, type Tool } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "capacity",
7 description:
8 "The forge's capacity and guards, live. With `key` and `value` (\"\" restores default), plans.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 key: {
13 type: "string",
14 enum: ["crew.concurrency", "checks.concurrency", "checks.shards", "cells.egress", "guard_new_accounts", "signin.edge"],
15 },
16 value: { type: "string", maxLength: 16 },
17 dryRun: { type: "boolean", description: "false to apply" },
18 },
19 },
20 writes: true,
21 dryByDefault: true,
22 run(ctx, user, args) {
23 const listed = listCapacity(ctx.app, user);
24 if (!listed.ok) return bad("not_found", "no such tool here");
25 const key = typeof args["key"] === "string" ? args["key"] : undefined;
26 if (key === undefined) return good(listed.value);
27 const value = typeof args["value"] === "string" ? args["value"] : "";
28 const now = listed.value.settings.find((s) => s.key === key);
29 if (now === undefined) return bad("no_such_setting", `no capacity setting ${key}`);
30 if (args["dryRun"] !== false)
31 return good({
32 says: `Would set ${key} from ${now.value || "auto"} to ${value || "its default"}. Send dryRun: false to change it.`,
33 });
34 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
35 return bad("forbidden", "changing the forge's capacity needs an admin-scope token");
36 const changed = changeCapacity(ctx.app, user, key, value);
37 if (!changed.ok) return bad(changed.error, "that value is outside what this setting takes");
38 return good({
39 says: `${key}: ${changed.value.from || "auto"} → ${changed.value.to || "auto"}, from the next pick on.`,
40 ...changed.value,
41 });
42 },
43 };
src/http/mcp/tools/close.ts added +53
@@ -0,0 +1,53 @@
1 import { closingFrom, setIssueStateThrough } from "../../../actions/issues.ts";
2 import { CLOSE_REASONS } from "../../../core/issues.ts";
3 import { good, bad, type Tool, REPO_ARG, repoArg, issueArg, isAnswer } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "close",
7 doc: "`{ repo, number, state?, reason?, into? }`: close an issue or a pull (`state` closed by default) or reopen it (`open`); the same rules and refusals as `PATCH …/issues/:number`: an agent closes only what the rules give it and reopens nothing; answers `state` and `closeReason`",
8 description:
9 "Close or reopen (`state` open) an issue or pull. `reason`: done, dropped, split (`into`).",
10 inputSchema: {
11 type: "object",
12 properties: {
13 repo: REPO_ARG,
14 number: { type: "integer", minimum: 1 },
15 state: { type: "string", enum: ["closed", "open"] },
16 reason: { type: "string", enum: [...CLOSE_REASONS] },
17 into: {
18 type: "array",
19 items: { type: "integer", minimum: 1 },
20 maxItems: 50,
21 },
22 },
23 required: ["repo", "number"],
24 },
25 writes: true,
26 async run(ctx, user, args) {
27 const repo = repoArg(ctx, user, args);
28 if (isAnswer(repo)) return repo;
29 const issue = issueArg(ctx, repo, args, "number");
30 if ("ok" in issue) return issue;
31 const state = args["state"] ?? "closed";
32 if (state !== "closed" && state !== "open")
33 return bad("invalid_state", "state is closed or open");
34 // The PATCH route's own check of `reason` and `into`.
35 const closing = closingFrom(args);
36 if (closing === undefined)
37 return bad("invalid_reason", "a reason is done, dropped, or split (with `into`: up to 50 issue numbers)");
38 const changed = await setIssueStateThrough(ctx.app, user, repo, issue, state, closing);
39 if (!changed.ok)
40 return bad(
41 changed.error,
42 changed.error === "forbidden"
43 ? "this is not yours to close or reopen"
44 : "that state could not be set",
45 );
46 return good({
47 says: `#${issue.number} is ${changed.value.state}.`,
48 number: issue.number,
49 state: changed.value.state,
50 closeReason: changed.value.closeReason,
51 });
52 },
53 };
src/http/mcp/tools/cloud_account.ts added +61
@@ -0,0 +1,61 @@
1 import { addAccount, allowWrite, auditAccount, removeAccount, renameAccount, reverify, rotateAccount } from "../../../actions/cloud.ts";
2 import { PROVIDERS } from "../../../cloud/providers.ts";
3 import { scopeAllows } from "../../../core/tokens.ts";
4 import { good, bad, type Tool, str } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "cloud_account",
8 doc: "`{ verb, provider, name, value?, to?, allowed?, repo? }`: change a cloud account: `attach` or `rotate` a token (`value`), `verify` it, `rename` it (`to`), `remove` it, `allow_writes` (`allowed` true or false) or `audit` (`repo` owner/name); a forge admin with an admin-scope token; answers a one-line `says`",
9 description:
10 "Change a cloud account (admin token) by `verb`.",
11 inputSchema: {
12 type: "object",
13 properties: {
14 verb: { type: "string", enum: ["attach", "verify", "rotate", "rename", "remove", "allow_writes", "audit"] },
15 provider: { type: "string", enum: [...PROVIDERS] },
16 name: { type: "string", maxLength: 32 },
17 value: { type: "string", maxLength: 4096, description: "For attach, rotate." },
18 to: { type: "string", maxLength: 32 },
19 allowed: { type: "boolean", description: "For allow_writes." },
20 repo: { type: "string", maxLength: 200, description: "For audit." },
21 },
22 required: ["verb", "provider", "name"],
23 },
24 writes: true,
25 async run(ctx, user, args) {
26 if (!user.isAdmin) return bad("not_found", "no such tool here");
27 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
28 return bad("forbidden", "changing cloud accounts needs an admin-scope token");
29 const verb = str(args["verb"]);
30 const [provider, name] = [str(args["provider"]), str(args["name"])];
31 const value = str(args["value"]);
32 if (str(args["verb"]) === "allow_writes" && typeof args["allowed"] !== "boolean")
33 return bad("bad_args", "allow_writes needs allowed: true or false");
34 const done =
35 verb === "attach"
36 ? await addAccount(ctx.app, user, provider, name, value)
37 : verb === "rotate"
38 ? await rotateAccount(ctx.app, user, provider, name, value)
39 : verb === "verify"
40 ? await reverify(ctx.app, user, provider, name)
41 : verb === "rename"
42 ? renameAccount(ctx.app, user, provider, name, str(args["to"]))
43 : verb === "remove"
44 ? removeAccount(ctx.app, user, provider, name)
45 : verb === "allow_writes"
46 ? allowWrite(ctx.app, user, provider, name, args["allowed"] === true)
47 : verb === "audit"
48 ? await auditAccount(ctx.app, user, provider, name, str(args["repo"]))
49 : undefined;
50 if (done === undefined) return bad("bad_verb", "verb is attach, verify, rotate, rename, remove, allow_writes or audit");
51 if (!done.ok) return bad("refused", done.error);
52 const v = done.value as unknown;
53 const said =
54 typeof v === "string"
55 ? v
56 : verb === "audit"
57 ? `${(v as { opened: number }).opened} issue(s) opened, ${(v as { found: number }).found} found`
58 : "done";
59 return good({ says: `${verb} ${provider}/${name}: ${said}.` });
60 },
61 };
src/http/mcp/tools/cloud_accounts.ts added +21
@@ -0,0 +1,21 @@
1 import { usesByVendor, type VendorUse } from "../../../cloud/declared.ts";
2 import { cloudOverview } from "../../../cloud/overview.ts";
3 import { good, bad, type Tool } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "cloud_accounts",
7 doc: "the forge's cloud accounts, each attached provider token with its use, verification, write permission and today's calls; a forge admin only (counted as a write so a `ro` token never lists them)",
8 description:
9 "Cloud accounts (forge admin): each provider token's use, verification, write permission, calls today.",
10 inputSchema: { type: "object", properties: {} },
11 // A read, but marked a write so a `ro` token never lists the accounts; it
12 // is a forge admin's and the audit rows name every vendor call.
13 writes: true,
14 async run(ctx, user) {
15 if (!user.isAdmin) return bad("not_found", "no such tool here");
16 const uses = await usesByVendor(ctx.app.db, ctx.app.reposRoot, user).catch(() => new Map<string, VendorUse[]>());
17 const seen = cloudOverview(ctx.app, user, uses);
18 if (!seen.ok) return bad("not_found", "no such tool here");
19 return good(seen.value);
20 },
21 };
src/http/mcp/tools/commits.ts added +54
@@ -0,0 +1,54 @@
1 import { commitLog } from "../../../git/read.ts";
2 import { diffCommit } from "../../../git/diff.ts";
3 import { repoDir } from "../../../git/repos.ts";
4 import { isSafeRef } from "../../../git/guard.ts";
5 import { checkOfCommit } from "../../../actions/runs.ts";
6 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
7
8 export const tool: Tool = {
9 name: "commits",
10 doc: "`{ repo, ref?, limit?, oid? }`: a branch's recent commits, newest first (`limit` 1–100, default 30; refused outside that, where REST clamps), as `GET …/commits` answers; with `oid` (4–64 hex) one commit with its diff and its check, as `GET …/commits/{oid}` and `…/check` answer",
11 description:
12 "Recent commits; `oid`: one commit, diff and check.",
13 inputSchema: {
14 type: "object",
15 properties: {
16 repo: REPO_ARG,
17 ref: { type: "string", maxLength: 255, description: "Branch, tag or commit." },
18 limit: { type: "integer", minimum: 1, maximum: 100 },
19 oid: { type: "string", minLength: 4, maxLength: 64, },
20 },
21 required: ["repo"],
22 },
23 writes: false,
24 async run(ctx, user, args) {
25 const repo = repoArg(ctx, user, args);
26 if (isAnswer(repo)) return repo;
27 const oid = args["oid"];
28 if (oid !== undefined) {
29 if (typeof oid !== "string" || !/^[0-9a-fA-F]{4,64}$/.test(oid))
30 return bad("invalid", "`oid` is 4 to 64 hex digits");
31 const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name);
32 const log = await commitLog(dir, oid, { limit: 1 });
33 const commit = log.ok ? log.value[0] : undefined;
34 if (!commit) return bad("not_found", "no such commit");
35 const diff = await diffCommit(dir, commit.oid);
36 const check = await checkOfCommit(ctx.app, user, repo, commit.oid);
37 const files = diff.ok ? diff.value : { files: [], additions: 0, deletions: 0 };
38 return good({
39 says: `${commit.oid.slice(0, 7)} ${commit.subject}: ${files.files.length} file(s) changed.`,
40 ...commit,
41 diff: files,
42 check: check.ok ? check.value : null,
43 });
44 }
45 const ref = args["ref"] ?? repo.defaultBranch;
46 if (typeof ref !== "string" || ref.length > 255 || !isSafeRef(ref))
47 return bad("invalid", "`ref` is a branch, tag or commit");
48 const limit = args["limit"] ?? 30;
49 if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 100)
50 return bad("invalid", "`limit` is a whole number from 1 to 100");
51 const log = await commitLog(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), ref, { limit });
52 return good({ commits: log.ok ? log.value : [] });
53 },
54 };
src/http/mcp/tools/confirm_wait.ts added +37
@@ -0,0 +1,37 @@
1 import { confirmIssue } from "../../../actions/dependencies.ts";
2 import { good, bad, type Tool, APP_ARG, planRepo, issueArg, isAnswer } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "confirm_wait",
6 doc: "`{ app, issue, blocker }`: a person says `issue` may go ahead although `blocker` closed as dropped or split; the API's confirm action, so writers only and never the crew",
7 description:
8 "Let an issue go ahead though its blocker closed dropped or split.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 app: APP_ARG,
13 issue: { type: "integer" },
14 blocker: { type: "integer" },
15 },
16 required: ["app", "issue", "blocker"],
17 },
18 writes: true,
19 run(ctx, user, args) {
20 const repo = planRepo(ctx, user, args);
21 if (isAnswer(repo)) return repo;
22 const issue = issueArg(ctx, repo, args, "issue");
23 if ("ok" in issue) return issue;
24 const blocker = issueArg(ctx, repo, args, "blocker");
25 if ("ok" in blocker) return blocker;
26 const done = confirmIssue(ctx.app, user, repo, issue, blocker);
27 if (!done.ok)
28 return bad(
29 done.error,
30 "You need write access, and not a place on the crew, to confirm a wait.",
31 );
32 return good({
33 says: `#${issue.number} may go ahead without #${blocker.number}.`,
34 url: `${ctx.url.origin}/${repo.ownerName}/${repo.name}/issues/${issue.number}`,
35 });
36 },
37 };
src/http/mcp/tools/contribute.ts added +12
@@ -0,0 +1,12 @@
1 import { welcome } from "../../../system/telemetry.ts";
2 import { good, type Tool } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "contribute",
6 doc: "for any arriving agent: what this forge is, its bottleneck, open “help wanted” issues, how to propose",
7 description:
8 "For an arriving agent: what this forge is, its bottleneck, “help wanted” issues, how to propose.",
9 inputSchema: { type: "object", properties: {} },
10 writes: false,
11 run: (ctx) => good(welcome(ctx.app)),
12 };
src/http/mcp/tools/converge.ts added +34
@@ -0,0 +1,34 @@
1 import { NOT_A_MEMBER } from "../../../core/roles.ts";
2 import { ARCHIVED_WHY, forkParentFor } from "../../../core/repos.ts";
3 import { convergeFork } from "../../../crew/converge.ts";
4 import { DIRECTION_NOTE } from "../../../crew/direct.ts";
5 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
6
7 export const tool: Tool = {
8 name: "converge",
9 doc: "the fork's crew merges where both sides changed the same files; comes back as a pull",
10 description:
11 "Have a fork's crew merge the parent's updates where both changed the same files, as a pull.",
12 inputSchema: {
13 type: "object",
14 properties: { repo: REPO_ARG },
15 required: ["repo"],
16 },
17 writes: true,
18 async run(ctx, user, args) {
19 const repo = repoArg(ctx, user, args);
20 if (isAnswer(repo)) return repo;
21 const parent = forkParentFor(ctx.app.db, repo, user);
22 if (parent === undefined)
23 return bad("not_a_fork", "this repository follows nothing");
24 const outcome = await convergeFork(ctx.app, repo, parent, user);
25 if (outcome.kind === "forbidden") return bad("forbidden", DIRECTION_NOTE);
26 if (outcome.kind === "not_member") return bad("not_member", NOT_A_MEMBER);
27 if (outcome.kind === "archived") return bad("archived", ARCHIVED_WHY);
28 return good({
29 kind: outcome.kind,
30 issue: "issue" in outcome ? outcome.issue.number : null,
31 pull: "pull" in outcome ? outcome.pull.number : null,
32 });
33 },
34 };
src/http/mcp/tools/decisions_by.ts added +45
@@ -0,0 +1,45 @@
1 import { isDecisionsBy } from "../../../core/repos.ts";
2 import { scopeAllows } from "../../../core/tokens.ts";
3 import { setDecisionsBy } from "../../../actions/repos.ts";
4 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "decisions_by",
8 doc: "`{ repo, by?, dryRun? }`: the standing rule for a repository's hand-overs (`me`, `decider`, `decider_tell`); with `by`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent",
9 description:
10 "Who decides a repository's parked hand-over cards: `me`, `decider`, `decider_tell`. No `by` reads it.",
11 inputSchema: {
12 type: "object",
13 properties: {
14 repo: REPO_ARG,
15 by: { type: "string", enum: ["me", "decider", "decider_tell"] },
16 dryRun: {
17 type: "boolean",
18 description: "false to apply",
19 },
20 },
21 required: ["repo"],
22 },
23 writes: true,
24 dryByDefault: true,
25 async run(ctx, user, args) {
26 const repo = repoArg(ctx, user, args);
27 if (isAnswer(repo)) return repo;
28 const by = args["by"];
29 if (by === undefined) return good({ by: repo.decisionsBy });
30 if (!isDecisionsBy(by))
31 return bad("bad_action", "`by` is me, decider or decider_tell");
32 const dryRun = args["dryRun"] !== false;
33 if (!dryRun && !scopeAllows(ctx.auth.scope ?? "ro", "admin"))
34 return bad("forbidden", "setting the standing rule needs an admin token");
35 const set = await setDecisionsBy(ctx.app, user, repo, by, dryRun);
36 if (!set.ok)
37 return bad("forbidden", "only an admin of this app sets its standing rule");
38 return good({
39 ...set.value,
40 says: dryRun
41 ? `This is the plan; nothing changed. Decisions go by "${set.value.was}" now; send dryRun: false to make it "${set.value.by}".`
42 : `Decisions here now go by "${set.value.by}".`,
43 });
44 },
45 };
src/http/mcp/tools/edit.ts added +48
@@ -0,0 +1,48 @@
1 import { editIssue, TAKEN_UP } from "../../../actions/issues.ts";
2 import { good, bad, type Tool, REPO_ARG, repoArg, issueArg, isAnswer } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "edit",
6 doc: "a new title or body for an issue or a pull: its author's, or a writer's; an agent edits only what it wrote",
7 description:
8 "Retitle or rewrite an issue or pull: yours, or any where you write; an agent edits only its own.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 repo: REPO_ARG,
13 number: { type: "integer", minimum: 1 },
14 title: { type: "string", maxLength: 256 },
15 body: { type: "string", maxLength: 65536 },
16 },
17 required: ["repo", "number"],
18 },
19 writes: true,
20 run(ctx, user, args) {
21 const repo = repoArg(ctx, user, args);
22 if (isAnswer(repo)) return repo;
23 // The same door the plan tools use for an issue by number, so what
24 // it comes to refuse (a sealed one, say) is refused here too.
25 const issue = issueArg(ctx, repo, args, "number");
26 if ("ok" in issue) return issue;
27 const edited = editIssue(ctx.app, user, repo, issue, {
28 title: typeof args["title"] === "string" ? args["title"] : undefined,
29 body: typeof args["body"] === "string" ? args["body"] : undefined,
30 });
31 if (!edited.ok)
32 return bad(
33 edited.error,
34 edited.error === "forbidden"
35 ? "only the author, or a person who writes here, edits this"
36 : edited.error === "taken_up"
37 ? TAKEN_UP
38 : edited.error === "nothing_to_change"
39 ? "send a title, a body, or both"
40 : "that could not be changed",
41 );
42 return good({
43 says: `Edited #${issue.number}: ${edited.value.title}`,
44 number: issue.number,
45 title: edited.value.title,
46 });
47 },
48 };
src/http/mcp/tools/egress_grants.ts added +51
@@ -0,0 +1,51 @@
1 import { scopeAllows } from "../../../core/tokens.ts";
2 import { egressView, grantAppEgress, revokeAppEgress } from "../../../actions/egress_grants.ts";
3 import { FORBIDDEN_MESSAGE } from "../../respond.ts";
4 import { type Answer, good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "egress_grants",
8 doc: "`{ 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`",
9 description:
10 "An app's private destinations and their grants: list, grant or revoke (site admin). See docs/MCP.md.",
11 inputSchema: {
12 type: "object",
13 properties: {
14 repo: REPO_ARG,
15 op: { type: "string", enum: ["list", "grant", "revoke"] },
16 destination: { type: "string" },
17 },
18 required: ["repo"],
19 },
20 writes: true,
21 readsWithout: "op",
22 async run(ctx, user, args) {
23 const repo = repoArg(ctx, user, args);
24 if (isAnswer(repo)) return repo;
25 const op = args["op"] === undefined ? "list" : str(args["op"]);
26 if (op !== "list" && op !== "grant" && op !== "revoke")
27 return bad("invalid", "op is list, grant or revoke");
28 // The API's rule: reading or changing grants takes an admin-scope token.
29 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
30 return bad("forbidden", "egress grants need an admin-scope token");
31 const refused = (error: string): Answer => {
32 if (error === "not_found")
33 return bad("not_found", "no such app or repository (owner/name)");
34 if (error === "forbidden") return bad("forbidden", FORBIDDEN_MESSAGE);
35 return bad(error, error);
36 };
37 if (op === "list") {
38 const view = await egressView(ctx.app, user, repo);
39 return view.ok
40 ? good({ items: view.value.rows, changes: view.value.changes })
41 : refused(view.error);
42 }
43 const destination = str(args["destination"]);
44 if (op === "grant") {
45 const granted = await grantAppEgress(ctx.app, user, repo, destination);
46 return granted.ok ? good(granted.value) : refused(granted.error);
47 }
48 const revoked = revokeAppEgress(ctx.app, user, repo, destination);
49 return revoked.ok ? good({ revoked: revoked.value }) : refused(revoked.error);
50 },
51 };
src/http/mcp/tools/email_verify.ts added +27
@@ -0,0 +1,27 @@
1 import { resendVerification } from "../../../actions/verify.ts";
2 import { CAPPED_WORDS } from "../../../actions/mail.ts";
3 import { VERIFY_WORDS } from "../../../actions/verify_words.ts";
4 import { good, bad, type Tool } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "email_verify",
8 doc: "no input: send the caller a new verification email, with the API's rate limit and errors; the answer never carries the link",
9 description: "Send yourself a new verification link.",
10 inputSchema: { type: "object", properties: {} },
11 writes: true,
12 async run(ctx, user) {
13 const sent = await resendVerification(ctx.app, user);
14 if (sent.ok)
15 return good({
16 outcome: sent.value,
17 says: sent.value === "sent" ? "A new verification email is on its way." : "Your email is already verified.",
18 });
19 if (sent.error === "too_soon")
20 return bad("too_soon", VERIFY_WORDS.too_soon);
21 if (sent.error === "no_mail")
22 return bad("no_mail", VERIFY_WORDS.no_mail);
23 if (sent.error === "capped") return bad("capped", CAPPED_WORDS);
24 if (sent.error === "unauthorized") return bad("unauthorized", "sign in first");
25 return bad("failed", VERIFY_WORDS.failed);
26 },
27 };
src/http/mcp/tools/events.ts added +28
@@ -0,0 +1,28 @@
1 import { EVENTS_LIMIT_MAX, eventsLimit, eventsPage, validEventsCursor } from "../../../core/events_page.ts";
2 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "events",
6 doc: "`{ repo, limit?, cursor? }`: the repository's events, newest first, as you may see them (a webhook's URL and an app's variable names are the admins'): `{ items, next_cursor }`, `limit` 1 to 200 (default 50), `cursor` from `next_cursor`",
7 description: "Repository events.",
8 inputSchema: {
9 type: "object",
10 properties: {
11 repo: REPO_ARG,
12 limit: { type: "integer" },
13 cursor: { type: "string" },
14 },
15 required: ["repo"],
16 },
17 writes: false,
18 run(ctx, user, args) {
19 const repo = repoArg(ctx, user, args);
20 if (isAnswer(repo)) return repo;
21 const limit = eventsLimit(args["limit"]);
22 if (limit === undefined) return bad("invalid", `limit is 1 to ${EVENTS_LIMIT_MAX}`);
23 const given = args["cursor"];
24 if (given !== undefined && (typeof given !== "string" || !validEventsCursor(given)))
25 return bad("invalid", "that cursor was not issued here");
26 return good(eventsPage(ctx.app.db, repo.id, user, limit, given));
27 },
28 };
src/http/mcp/tools/explore.ts added +27
@@ -0,0 +1,27 @@
1 import { explore, exploreLine, parseExploreQuery } from "../../../actions/explore.ts";
2 import { good, type Tool, str } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "explore",
6 doc: "`{ 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",
7 description:
8 "The apps, people (and for a site admin, spawned forges) you can see, ranked.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 query: {
13 type: "string",
14 maxLength: 200,
15 description: "Words; kind:, sort: filters.",
16 },
17 },
18 },
19 writes: false,
20 async run(ctx, user, args) {
21 const query = parseExploreQuery(str(args["query"]));
22 return good({
23 query: exploreLine(query),
24 items: await explore(ctx.app, user, query),
25 });
26 },
27 };
src/http/mcp/tools/family.ts added +14
@@ -0,0 +1,14 @@
1 import { familyView } from "../../../spawn/family.ts";
2 import { good, type Tool } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "family",
6 doc: "the forge that sent this one and, for a site admin, those it sent, with state and version",
7 description:
8 "The forge that sent this one and, for a site admin, those it sent, with state and version.",
9 inputSchema: { type: "object", properties: {} },
10 writes: false,
11 async run(ctx, user) {
12 return good(await familyView(ctx.app, user.isAdmin === true));
13 },
14 };
src/http/mcp/tools/feed.ts added +37
@@ -0,0 +1,37 @@
1 import { FEED_LIMIT_DEFAULT, FEED_LIMIT_MAX, parseCursor, readFeed } from "../../../feed/feed.ts";
2 import { good, bad, type Tool, CHIP_ARGS, chipArgs, str } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "feed",
6 doc: "`{ 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`",
7 description:
8 "Your ranked home timeline across visible apps; `cursor` pages; saved filter (`feed_chips`) applies.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 limit: { type: "integer", minimum: 1, maximum: FEED_LIMIT_MAX },
13 cursor: { type: "string" },
14 ...CHIP_ARGS,
15 },
16 },
17 writes: false,
18 run(ctx, user, args) {
19 const chips = chipArgs(ctx, args);
20 if (chips && "error" in chips) return bad("invalid", chips.error);
21 const raw = args["limit"];
22 const limit = raw === undefined ? FEED_LIMIT_DEFAULT : Number(raw);
23 if (!Number.isInteger(limit) || limit < 1 || limit > FEED_LIMIT_MAX)
24 return bad("invalid", `limit is 1 to ${FEED_LIMIT_MAX}`);
25 const given = args["cursor"];
26 const cursor = given === undefined ? undefined : parseCursor(str(given));
27 if (given !== undefined && cursor === undefined)
28 return bad("invalid", "that cursor was not issued here");
29 return good(
30 readFeed(ctx.app.db, user, {
31 limit,
32 ...(chips === undefined ? {} : { chips }),
33 ...(cursor === undefined ? {} : { cursor }),
34 }),
35 );
36 },
37 };
src/http/mcp/tools/feed_chips.ts added +42
@@ -0,0 +1,42 @@
1 import { getUserByName, getUsersByIds } from "../../../core/users.ts";
2 import { chipsOf, isKindChip, setChip } from "../../../core/feed_chips.ts";
3 import { good, bad, type Tool, str } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "feed_chips",
7 doc: "`{ kind?, value?, hidden? }`: the feed chips you switched off, which `feed` and `asks` leave out; with `kind` (`kind` or `person`), `value` and `hidden` it switches one off or on",
8 description:
9 "Feed chips you switched off in `feed` and `asks`. With `kind`, `value` and `hidden`, sets one.",
10 inputSchema: {
11 type: "object",
12 properties: {
13 kind: { type: "string", enum: ["kind", "person"] },
14 value: { type: "string", maxLength: 100 },
15 hidden: { type: "boolean" },
16 },
17 },
18 writes: true,
19 readsWithout: "kind",
20 run(ctx, user, args) {
21 if (args["kind"] !== undefined) {
22 const value = str(args["value"]);
23 const hidden = args["hidden"];
24 if (typeof hidden !== "boolean") return bad("invalid", "hidden is true or false");
25 if (args["kind"] === "kind") {
26 if (!isKindChip(value)) return bad("invalid", "that kind has no chip");
27 setChip(ctx.app.db, user.id, { kind: "kind", value }, hidden);
28 } else if (args["kind"] === "person") {
29 const who = getUserByName(ctx.app.db, value);
30 if (!who || who.kind === "org") return bad("not_found", "no such person");
31 setChip(ctx.app.db, user.id, { kind: "person", value: who.id }, hidden);
32 } else return bad("invalid", "kind is `kind` or `person`");
33 }
34 const chips = chipsOf(ctx.app.db, user.id);
35 return good({
36 kinds: [...chips.kinds].sort(),
37 people: [...getUsersByIds(ctx.app.db, [...chips.people]).values()]
38 .map((u) => u.username)
39 .sort(),
40 });
41 },
42 };
src/http/mcp/tools/flow.ts added +18
@@ -0,0 +1,18 @@
1 import { noteRead, telemetryReader } from "../../../actions/telemetry.ts";
2 import { flowFor } from "../../../system/telemetry.ts";
3 import { good, bad, type Tool } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "flow",
7 description:
8 "Pipeline (forge admin): stage counts, `oldestWaiting`, `capacity`, bottleneck, `queue`, `reviewing`.",
9 inputSchema: { type: "object", properties: {} },
10 writes: false,
11 run(ctx, user) {
12 const reader = telemetryReader(ctx.app, user);
13 if (!reader)
14 return bad("forbidden", "only an admin of this forge, or an agent granted telemetry:read, reads its pipeline");
15 noteRead(ctx.app, user, reader, "flow");
16 return good(flowFor(ctx.app, reader));
17 },
18 };
src/http/mcp/tools/follow_mode.ts added +26
@@ -0,0 +1,26 @@
1 import { FOLLOW_MODE_REFUSALS, setFollowMode } from "../../../actions/follow.ts";
2 import { FOLLOW_MODES } from "../../../spawn/chain.ts";
3 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "follow_mode",
7 doc: "`{ repo, … }`: choose how a spawned forge follows its parent, `tell-me` or `take-updates`; its `forge` repository's admin only",
8 description:
9 "How a spawned forge follows its parent: `tell-me` or `take-updates`. Its `forge` repository's admin.",
10 inputSchema: {
11 type: "object",
12 properties: {
13 repo: REPO_ARG,
14 mode: { type: "string", enum: [...FOLLOW_MODES] },
15 },
16 required: ["repo", "mode"],
17 },
18 writes: true,
19 async run(ctx, user, args) {
20 const repo = repoArg(ctx, user, args);
21 if (isAnswer(repo)) return repo;
22 const set = await setFollowMode(ctx.app, user, repo, args["mode"]);
23 if (set.ok) return good({ says: `Now following in ${set.value} mode.`, mode: set.value });
24 return bad(set.error, FOLLOW_MODE_REFUSALS[set.error].message);
25 },
26 };
src/http/mcp/tools/following.ts added +46
@@ -0,0 +1,46 @@
1 import { upstreamPayload } from "../../../spawn/upstream.ts";
2 import { relationOf } from "../../../actions/forks.ts";
3 import { chainOfRepo } from "../../../spawn/chain.ts";
4 import { convergeState } from "../../../crew/converge.ts";
5 import { parentForgeFor } from "../../../core/parent_releases.ts";
6 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
7
8 export const tool: Tool = {
9 name: "following",
10 doc: "where a fork stands against what it follows, and what the crew is doing about it",
11 description:
12 "Where a fork stands against what it follows: in step, behind, ahead, diverged; files to decide.",
13 inputSchema: {
14 type: "object",
15 properties: { repo: REPO_ARG },
16 required: ["repo"],
17 },
18 writes: false,
19 async run(ctx, user, args) {
20 const repo = repoArg(ctx, user, args);
21 if (isAnswer(repo)) return repo;
22 const relation = await relationOf(ctx.app, user, repo);
23 const chain = await chainOfRepo(ctx.app, repo);
24 if (relation === undefined)
25 return chain === undefined
26 ? bad("not_a_fork", "this repository follows nothing")
27 : good({ chain: chain.lines, severedAt: chain.severedAt, upstream: await upstreamPayload(ctx.app) });
28 const converge =
29 relation.state === "diverged" && !relation.clean
30 ? convergeState(ctx.app, repo, relation.parent)
31 : undefined;
32 return good({
33 chain: chain?.lines ?? null,
34 severedAt: chain?.severedAt ?? null,
35 upstream: chain === undefined ? null : await upstreamPayload(ctx.app),
36 parent: `${relation.parent.ownerName}/${relation.parent.name}`,
37 state: relation.state,
38 behind: relation.behind,
39 ahead: relation.ahead,
40 clean: relation.clean,
41 conflicts: relation.conflicts,
42 parentForge: parentForgeFor(ctx.app.config),
43 converge: converge?.kind ?? null,
44 });
45 },
46 };
src/http/mcp/tools/forge_proposals.ts added +21
@@ -0,0 +1,21 @@
1 import { forgeProposalLine, forgeProposals } from "../../../actions/upstream.ts";
2 import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "forge_proposals",
6 doc: "`{ repo }`: what this forge proposed to its parent, newest first; `reachable` is false if the parent did not answer",
7 description:
8 "What this forge proposed to its parent, newest first; `reachable` false if it did not answer.",
9 inputSchema: {
10 type: "object",
11 properties: { repo: REPO_ARG },
12 required: ["repo"],
13 },
14 writes: false,
15 async run(ctx, user, args) {
16 const repo = repoArg(ctx, user, args);
17 if (isAnswer(repo)) return repo;
18 const items = await forgeProposals(ctx.app, repo, Date.now(), true);
19 return good({ items: items.map((p) => ({ ...p, says: forgeProposalLine(p) })) });
20 },
21 };
src/http/mcp/tools/forges.ts added +23
@@ -0,0 +1,23 @@
1 import { clientRows } from "../../../spawn/clients.ts";
2 import { good, bad, type Tool } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "forges",
6 doc: "an admin's list of the forges sent from here: name, address, whom for, apps, claimed or not, last activity",
7 description:
8 "The forges sent from here (admin): name, address, for, apps, claimed, spendTodayUsd, ceilingUsd.",
9 inputSchema: { type: "object", properties: {} },
10 writes: false,
11 async run(ctx, user) {
12 if (!user.isAdmin)
13 return bad("forbidden", "only an admin of this forge sees the forges it sent");
14 const forges = await clientRows(ctx.app);
15 return good({
16 says:
17 forges.length === 0
18 ? "This forge has sent no forges yet."
19 : `${forges.length} forge${forges.length === 1 ? "" : "s"} sent from here.`,
20 forges,
21 });
22 },
23 };
src/http/mcp/tools/front_page.ts added +51
@@ -0,0 +1,51 @@
1 import { frontInput, frontJson, setFront } from "../../../actions/front.ts";
2 import { ADMIN_WORDS } from "../../../actions/admin_words.ts";
3 import { good, bad, type Tool } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "front_page",
7 doc: "`{ 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)",
8 description:
9 "The front page as a stranger sees it; a site admin also gets and sets `pinned`, `hidden`, `weights`.",
10 inputSchema: {
11 type: "object",
12 properties: {
13 pinned: { type: "array", items: { type: "string" } },
14 hidden: { type: "array", items: { type: "string" } },
15 weights: {
16 type: "object",
17 description: "0–10 each.",
18 properties: {
19 forks: { type: "number", minimum: 0, maximum: 10 },
20 ships: { type: "number", minimum: 0, maximum: 10 },
21 use: { type: "number", minimum: 0, maximum: 10 },
22 fresh: { type: "number", minimum: 0, maximum: 10 },
23 },
24 },
25 dryRun: { type: "boolean" },
26 },
27 },
28 writes: true,
29 dryByDefault: true,
30 run(ctx, user, args) {
31 if (
32 args["pinned"] === undefined &&
33 args["hidden"] === undefined &&
34 args["weights"] === undefined
35 )
36 return good(frontJson(ctx.app, user));
37 const picks = frontInput(ctx.app, args);
38 if (picks === undefined)
39 return bad("invalid", "pinned and hidden are lists of owner/name; weights an object of forks, ships, use, fresh");
40 const set = setFront(ctx.app, user, picks, args["dryRun"] !== false);
41 if (!set.ok)
42 return set.error.kind === "forbidden"
43 ? bad("forbidden", ADMIN_WORDS.frontPage)
44 : bad("invalid", set.error.message);
45 return good({
46 dryRun: args["dryRun"] !== false,
47 picks: set.value,
48 ...(args["dryRun"] === false ? { front: frontJson(ctx.app, user) } : {}),
49 });
50 },
51 };
src/http/mcp/tools/health.ts added +12
@@ -0,0 +1,12 @@
1 import { readHealth } from "../../../actions/health.ts";
2 import { good, type Tool } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "health",
6 doc: "how this forge is: the version and commit it runs, draining or not, the rollout and its incidents",
7 description:
8 "How this forge is: version, commit, draining or not, rollout, incidents.",
9 inputSchema: { type: "object", properties: {} },
10 writes: false,
11 run: (ctx, user) => good(readHealth(ctx.app, user)),
12 };
src/http/mcp/tools/here.ts added +30
@@ -0,0 +1,30 @@
1 import { manifestFor } from "../../../actions/here.ts";
2 import { good, bad, type Tool, str } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "here",
6 description:
7 "What you can do on a forge page, as who you are: each open action with its HTTP route and MCP tool.",
8 inputSchema: {
9 type: "object",
10 properties: { url: { type: "string", maxLength: 2000 } },
11 required: ["url"],
12 },
13 writes: false,
14 run(ctx, user, args) {
15 const raw = str(args["url"]);
16 const parsed = URL.parse(raw, ctx.url.origin);
17 if (parsed === null || parsed.origin !== ctx.url.origin)
18 return bad("not_found", "not a page of this forge");
19 const found = manifestFor(
20 ctx.app,
21 user,
22 ctx.auth.via === "token" ? ctx.auth.scope : undefined,
23 ctx.url.origin,
24 parsed.pathname,
25 );
26 return found.ok
27 ? good(found.value)
28 : bad("not_found", "no such page here, or it is not visible to you");
29 },
30 };
src/http/mcp/tools/hold.ts added +80
@@ -0,0 +1,80 @@
1 import { holdPull, releasePull } from "../../../actions/holds.ts";
2 import { getPull } from "../../../core/pulls.ts";
3 import { good, bad, type Tool, str, APP_ARG, planRepo, isAnswer } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "hold",
7 doc: "`{ app, pull, why?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; plans unless `dryRun: false`",
8 description:
9 "Hold a pull (`why`) or let it go (`release: true`): while held nothing reviews or lands it. Writers.",
10 inputSchema: {
11 type: "object",
12 properties: {
13 app: APP_ARG,
14 pull: { type: "integer" },
15 why: {
16 type: "string",
17 description: "Why, one line.",
18 },
19 release: {
20 type: "boolean",
21 },
22 dryRun: {
23 type: "boolean",
24 description: "false to apply",
25 },
26 },
27 required: ["app", "pull"],
28 },
29 writes: true,
30 dryByDefault: true,
31 run(ctx, user, args) {
32 const repo = planRepo(ctx, user, args);
33 if (isAnswer(repo)) return repo;
34 const n = args["pull"];
35 const pull =
36 typeof n === "number" && Number.isInteger(n) && n > 0
37 ? getPull(ctx.app.db, repo.id, n)
38 : undefined;
39 if (pull === undefined)
40 return bad("no_such_pull", `no pull #${String(n)}`);
41 const dry = args["dryRun"] !== false;
42 const name = `${repo.ownerName}/${repo.name}#${pull.number}`;
43 const hint = "Nothing changed; send dryRun: false to do it.";
44 if (args["release"] === true) {
45 const released = releasePull(ctx.app, user, repo, pull, dry);
46 if (!released.ok)
47 return bad(
48 released.error,
49 "holding and releasing are for a person who can write this app",
50 );
51 return good({
52 says: !released.value
53 ? `Nothing holds ${name}.`
54 : dry
55 ? `Would release ${name}. ${hint}`
56 : `Released ${name}. The rules take it from here.`,
57 held: dry ? released.value : false,
58 dryRun: dry,
59 });
60 }
61 const held = holdPull(ctx.app, user, repo, pull, str(args["why"]), dry);
62 if (!held.ok)
63 return bad(
64 held.error,
65 held.error === "not_open"
66 ? "only an open pull can be held"
67 : held.error === "empty_why" || held.error === "why_too_long"
68 ? "say why in one line, at most 200 characters (why)"
69 : "holding and releasing are for a person who can write this app",
70 );
71 return good({
72 says: dry
73 ? `Would hold ${name}: "${held.value.why}". ${hint}`
74 : `Held ${name}. No rule reviews or lands it until it is released.`,
75 held: !dry,
76 hold: { by: held.value.heldByName, why: held.value.why },
77 dryRun: dry,
78 });
79 },
80 };
src/http/mcp/tools/hooks.ts added +72
@@ -0,0 +1,72 @@
1 import { canAdmin } from "../../../core/repos.ts";
2 import { listWebhooks } from "../../../core/webhooks.ts";
3 import { createWebhook, deleteWebhook } from "../../../actions/repos.ts";
4 import { good, bad, type Answer, type Args, type Tool, REPO_ARG, repoArg, isAnswer, str } from "../shared.ts";
5
6 const HOOK_LIMITS = { url: 2048, kinds: 200, secret: 200, id: 64 } as const;
7
8 /** The refusal for a `hooks` argument that is not text or is over its declared length. */
9 function hookTooLong(args: Args): Answer | undefined {
10 for (const [key, max] of Object.entries(HOOK_LIMITS)) {
11 const v = args[key];
12 if (v !== undefined && (typeof v !== "string" || v.length > max))
13 return bad("invalid", `${key} is text of at most ${max} characters`);
14 }
15 return undefined;
16 }
17
18 const notAdmin = (): Answer => bad("not_found", "no such app or repository, or you do not administer it");
19
20 export const tool: Tool = {
21 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 description: "List, add (`url`, `secret`, `kinds`) or remove (`id`) a repository's webhooks; admins only, a secret is never shown.",
24 inputSchema: {
25 type: "object",
26 properties: {
27 repo: REPO_ARG,
28 action: { type: "string", enum: ["list", "add", "remove"] },
29 url: { type: "string", maxLength: 2048 },
30 kinds: { type: "string", maxLength: 200 },
31 secret: { type: "string", maxLength: 200 },
32 id: { type: "string", maxLength: 64 },
33 },
34 required: ["repo", "action"],
35 },
36 writes: true,
37 needsAdminScope: true,
38 run(ctx, user, args) {
39 const repo = repoArg(ctx, user, args);
40 if (isAnswer(repo)) return repo;
41 const action = args["action"];
42 if (action === "list") {
43 if (!canAdmin(ctx.app.db, repo, user)) return notAdmin();
44 return good(listWebhooks(ctx.app.db, repo.id));
45 }
46 if (action === "add") {
47 const long = hookTooLong(args);
48 if (long !== undefined) return long;
49 const secret = args["secret"];
50 if (typeof secret !== "string" || secret.length < 16)
51 return bad("invalid", "secret is required: 16 to 200 characters, the key deliveries are signed with");
52 const created = createWebhook(ctx.app, user, repo, {
53 url: str(args["url"]),
54 kinds: str(args["kinds"]) || "*",
55 secret,
56 });
57 if (!created.ok)
58 return created.error === "invalid_url"
59 ? bad("invalid_url", "a webhook url is https://…")
60 : notAdmin();
61 // Unlike the API's one-time reveal, the secret is never echoed here.
62 return good(created.value.webhook);
63 }
64 if (action === "remove") {
65 const long = hookTooLong(args);
66 if (long !== undefined) return long;
67 const removed = deleteWebhook(ctx.app, user, repo, str(args["id"]));
68 return removed.ok ? good({}) : bad("not_found", "no such webhook here");
69 }
70 return bad("invalid", "action is list, add or remove");
71 },
72 };
src/http/mcp/tools/index.ts added +122
@@ -0,0 +1,122 @@
1 // Generated by `bun scripts/mcp-tools.ts` from the files beside it; do not edit.
2 // One line per tool, sorted: a new tool is a new file and one new line here.
3 export { tool as access } from "./access.ts";
4 export { tool as account_role } from "./account_role.ts";
5 export { tool as account_suspend } from "./account_suspend.ts";
6 export { tool as account_verify } from "./account_verify.ts";
7 export { tool as agent } from "./agent.ts";
8 export { tool as app_deploy } from "./app_deploy.ts";
9 export { tool as app_env } from "./app_env.ts";
10 export { tool as app_members } from "./app_members.ts";
11 export { tool as apps } from "./apps.ts";
12 export { tool as archive } from "./archive.ts";
13 export { tool as asked } from "./asked.ts";
14 export { tool as asks } from "./asks.ts";
15 export { tool as asks_act } from "./asks_act.ts";
16 export { tool as asks_decide } from "./asks_decide.ts";
17 export { tool as asks_proposal } from "./asks_proposal.ts";
18 export { tool as asks_undecide } from "./asks_undecide.ts";
19 export { tool as assigned } from "./assigned.ts";
20 export { tool as attach_look } from "./attach_look.ts";
21 export { tool as audit } from "./audit.ts";
22 export { tool as board_delete } from "./board_delete.ts";
23 export { tool as board_pins } from "./board_pins.ts";
24 export { tool as board_write } from "./board_write.ts";
25 export { tool as boards } from "./boards.ts";
26 export { tool as branches } from "./branches.ts";
27 export { tool as capacity } from "./capacity.ts";
28 export { tool as claim_link } from "./claim_link.ts";
29 export { tool as close } from "./close.ts";
30 export { tool as cloud_account } from "./cloud_account.ts";
31 export { tool as cloud_accounts } from "./cloud_accounts.ts";
32 export { tool as commits } from "./commits.ts";
33 export { tool as confirm_wait } from "./confirm_wait.ts";
34 export { tool as contribute } from "./contribute.ts";
35 export { tool as converge } from "./converge.ts";
36 export { tool as decisions_by } from "./decisions_by.ts";
37 export { tool as edit } from "./edit.ts";
38 export { tool as egress_grants } from "./egress_grants.ts";
39 export { tool as email_verify } from "./email_verify.ts";
40 export { tool as events } from "./events.ts";
41 export { tool as explore } from "./explore.ts";
42 export { tool as family } from "./family.ts";
43 export { tool as feed } from "./feed.ts";
44 export { tool as feed_chips } from "./feed_chips.ts";
45 export { tool as flow } from "./flow.ts";
46 export { tool as follow_mode } from "./follow_mode.ts";
47 export { tool as following } from "./following.ts";
48 export { tool as forge_proposals } from "./forge_proposals.ts";
49 export { tool as forges } from "./forges.ts";
50 export { tool as front_page } from "./front_page.ts";
51 export { tool as health } from "./health.ts";
52 export { tool as here } from "./here.ts";
53 export { tool as hold } from "./hold.ts";
54 export { tool as hooks } from "./hooks.ts";
55 export { tool as invites } from "./invites.ts";
56 export { tool as issue } from "./issue.ts";
57 export { tool as issue_teams } from "./issue_teams.ts";
58 export { tool as labels } from "./labels.ts";
59 export { tool as limb } from "./limb.ts";
60 export { tool as limits } from "./limits.ts";
61 export { tool as make_it_yours } from "./make_it_yours.ts";
62 export { tool as map } from "./map.ts";
63 export { tool as migrate } from "./migrate.ts";
64 export { tool as milestones } from "./milestones.ts";
65 export { tool as mint_invite } from "./mint_invite.ts";
66 export { tool as mirror_set } from "./mirror_set.ts";
67 export { tool as model_ceiling } from "./model_ceiling.ts";
68 export { tool as model_grant } from "./model_grant.ts";
69 export { tool as new_app } from "./new_app.ts";
70 export { tool as next_steps } from "./next_steps.ts";
71 export { tool as now } from "./now.ts";
72 export { tool as org_teams } from "./org_teams.ts";
73 export { tool as origin_apps } from "./origin_apps.ts";
74 export { tool as origins } from "./origins.ts";
75 export { tool as outsiders } from "./outsiders.ts";
76 export { tool as parent_unlink } from "./parent_unlink.ts";
77 export { tool as plan } from "./plan.ts";
78 export { tool as preview } from "./preview.ts";
79 export { tool as probe } from "./probe.ts";
80 export { tool as prompts } from "./prompts.ts";
81 export { tool as proposal_tokens } from "./proposal_tokens.ts";
82 export { tool as propose_to_parent_forge } from "./propose_to_parent_forge.ts";
83 export { tool as pull } from "./pull.ts";
84 export { tool as pulls } from "./pulls.ts";
85 export { tool as quotas } from "./quotas.ts";
86 export { tool as read_file } from "./read_file.ts";
87 export { tool as repo } from "./repo.ts";
88 export { tool as repo_labels } from "./repo_labels.ts";
89 export { tool as repo_settings } from "./repo_settings.ts";
90 export { tool as reports } from "./reports.ts";
91 export { tool as returns } from "./returns.ts";
92 export { tool as review } from "./review.ts";
93 export { tool as run_log } from "./run_log.ts";
94 export { tool as runs } from "./runs.ts";
95 export { tool as say } from "./say.ts";
96 export { tool as scorecard } from "./scorecard.ts";
97 export { tool as seal } from "./seal.ts";
98 export { tool as search } from "./search.ts";
99 export { tool as secrets } from "./secrets.ts";
100 export { tool as sever } from "./sever.ts";
101 export { tool as share } from "./share.ts";
102 export { tool as signup } from "./signup.ts";
103 export { tool as sizes } from "./sizes.ts";
104 export { tool as spawn } from "./spawn.ts";
105 export { tool as spend } from "./spend.ts";
106 export { tool as ssh_keys } from "./ssh_keys.ts";
107 export { tool as stop } from "./stop.ts";
108 export { tool as subscription_budget } from "./subscription_budget.ts";
109 export { tool as system } from "./system.ts";
110 export { tool as take_updates } from "./take_updates.ts";
111 export { tool as teams } from "./teams.ts";
112 export { tool as telemetry_grant } from "./telemetry_grant.ts";
113 export { tool as tell } from "./tell.ts";
114 export { tool as thought } from "./thought.ts";
115 export { tool as threads } from "./threads.ts";
116 export { tool as trail } from "./trail.ts";
117 export { tool as upstream } from "./upstream.ts";
118 export { tool as usage } from "./usage.ts";
119 export { tool as variant } from "./variant.ts";
120 export { tool as wait_for } from "./wait_for.ts";
121 export { tool as watch } from "./watch.ts";
122 export { tool as whoami_can } from "./whoami_can.ts";
src/http/mcp/tools/invites.ts added +35
@@ -0,0 +1,35 @@
1 import { list as listInviteLinks, revoke as revokeInviteLink } from "../../../actions/invites.ts";
2 import { scopeAllows } from "../../../core/tokens.ts";
3 import { good, bad, type Tool, mayBoards } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "invites",
7 doc: "`{ 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",
8 description: "List open invites, or revoke one by `id`.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 action: { type: "string", enum: ["list", "revoke"] },
13 id: { type: "string" },
14 },
15 required: ["action"],
16 },
17 writes: true,
18 run(ctx, user, args) {
19 if (!mayBoards(user)) return bad("forbidden", "invites are a forge admin's");
20 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
21 return bad("forbidden", "invites need an admin token");
22 const action = args["action"];
23 if (action === "list") {
24 const listed = listInviteLinks(ctx.app, user);
25 return listed.ok ? good({ items: listed.value }) : bad("forbidden", "invites are a forge admin's");
26 }
27 if (action === "revoke") {
28 const id = args["id"];
29 if (typeof id !== "string" || id === "") return bad("invalid", "revoke needs an invite id");
30 const done = revokeInviteLink(ctx.app, user, id);
31 return done.ok ? good(done.value) : bad("not_found", "no such open invite");
32 }
33 return bad("invalid", 'action is "list" or "revoke"');
34 },
35 };
src/http/mcp/tools/issue.ts added +78
@@ -0,0 +1,78 @@
1 import { mayView } from "../../../core/sealed.ts";
2 import { getIssue, labelsFor, listComments } from "../../../core/issues.ts";
3 import { isCrew } from "../../../core/agents.ts";
4 import { dependenciesOf } from "../../../core/dependencies.ts";
5 import { getUsersByIds } from "../../../core/users.ts";
6 import { markReaders } from "../../../crew/direct.ts";
7 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
8
9 export const tool: Tool = {
10 name: "issue",
11 doc: "`{ 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",
12 description:
13 "One issue with comments, `blockedBy`, `blocks`; crew agents get `untrusted: true` on non-writers' text.",
14 inputSchema: {
15 type: "object",
16 properties: {
17 repo: REPO_ARG,
18 number: { type: "integer", minimum: 1 },
19 },
20 required: ["repo", "number"],
21 },
22 writes: false,
23 async run(ctx, user, args) {
24 const repo = repoArg(ctx, user, args);
25 if (isAnswer(repo)) return repo;
26 const n = args["number"];
27 const issue =
28 typeof n === "number" && Number.isInteger(n) && n > 0
29 ? getIssue(ctx.app.db, repo.id, n)
30 : undefined;
31 if (issue === undefined || !mayView(ctx.app.db, repo, issue, user))
32 return bad("not_found", `no issue #${String(n)}`);
33 // Seen only after the sealed check, so a sealed pull is not told apart.
34 if (issue.kind !== "issue") return good({ kind: "pull", tool: "pull" });
35 const db = ctx.app.db;
36 // Marked only for a crew agent, as the API does: to anyone else the
37 // mark would say which commenters cannot write.
38 const marking = user.kind === "agent" && isCrew(db, repo.id, user.id);
39 const mark = <T extends { authorName: string; originAuthor: string | null }>(
40 items: readonly T[],
41 ): (T & { untrusted?: string })[] =>
42 marking ? markReaders(db, repo, items) : [...items];
43 const comments = mark(listComments(db, issue.id));
44 const authors = getUsersByIds(db, [...new Set(comments.map((c) => c.authorId))]);
45 const deps = dependenciesOf(db, issue.id);
46 const brief = (i: typeof issue) => ({
47 number: i.number,
48 title: i.title,
49 state: i.state,
50 ...(i.closeReason === null ? {} : { closeReason: i.closeReason }),
51 });
52 const seen = (i: typeof issue) => mayView(db, repo, i, user);
53 const flag = (x: { untrusted?: string }) =>
54 x.untrusted === undefined ? {} : { untrusted: true };
55 const [self] = mark([issue]);
56 return good({
57 number: issue.number,
58 title: issue.title,
59 state: issue.state,
60 closeReason: issue.closeReason,
61 body: issue.body,
62 author: issue.authorName,
63 authorIsAgent: issue.authorIsAgent,
64 ...flag(self!),
65 labels: labelsFor(db, issue.id).map((l) => l.name),
66 assignee: issue.assigneeName,
67 comments: comments.map((c) => ({
68 author: c.authorName,
69 authorIsAgent: authors.get(c.authorId)?.kind === "agent",
70 body: c.body,
71 at: c.createdAt,
72 ...flag(c),
73 })),
74 blockedBy: deps.blockedBy.filter(seen).map(brief),
75 blocks: deps.blocks.filter(seen).map(brief),
76 });
77 },
78 };
src/http/mcp/tools/issue_teams.ts added +36
@@ -0,0 +1,36 @@
1 import { ISSUE_TEAMS_SAY, nameTeams } from "../../../actions/issue_teams.ts";
2 import { teamsOfIssue } from "../../../core/issue_teams.ts";
3 import { good, bad, type Tool, REPO_ARG, repoArg, issueArg, isAnswer } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "issue_teams",
7 description:
8 "Name up to two crew teams (design, data-science, security, platform) to review an issue's pull.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 repo: REPO_ARG,
13 number: { type: "integer", minimum: 1 },
14 teams: { type: "array", maxItems: 2, items: { type: "string", maxLength: 64 } },
15 },
16 required: ["repo", "number"],
17 },
18 writes: true,
19 run(ctx, user, args) {
20 const repo = repoArg(ctx, user, args);
21 if (isAnswer(repo)) return repo;
22 const issue = issueArg(ctx, repo, args, "number");
23 if ("ok" in issue) return issue;
24 const teams = args["teams"];
25 if (teams === undefined) return good({ number: issue.number, teams: teamsOfIssue(ctx.app.db, issue.id) });
26 if (!Array.isArray(teams) || !teams.every((t) => typeof t === "string"))
27 return bad("invalid", "teams is a list of team slugs");
28 const set = nameTeams(ctx.app, user, repo, issue, teams);
29 if (!set.ok) return bad(set.error, ISSUE_TEAMS_SAY[set.error] ?? "that could not be changed");
30 return good({
31 number: issue.number,
32 teams: set.value,
33 says: set.value.length === 0 ? `#${issue.number} names no team.` : `#${issue.number} names ${set.value.join(" and ")}.`,
34 });
35 },
36 };
src/http/mcp/tools/labels.ts added +63
@@ -0,0 +1,63 @@
1 import { THREAD_SAYS } from "../../../actions/thread_words.ts";
2 import { setIssueLabelsThrough } from "../../../actions/issues.ts";
3 import { originError } from "../../../web/pages/thread.ts";
4 import { good, bad, type Tool, REPO_ARG, repoArg, issueArg, isAnswer } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "labels",
8 doc: "`{ repo, number, labels }`: set an issue's or a pull's labels to exactly these (`[]` clears); only labels the repository has, by a writer",
9 description:
10 "Set an issue's or pull's labels to exactly these (none clears), from the repository's. Writers.",
11 inputSchema: {
12 type: "object",
13 properties: {
14 repo: REPO_ARG,
15 number: { type: "integer", minimum: 1 },
16 labels: {
17 type: "array",
18 items: { type: "string", maxLength: 64 },
19 maxItems: 50,
20 },
21 },
22 required: ["repo", "number", "labels"],
23 },
24 writes: true,
25 async run(ctx, user, args) {
26 const repo = repoArg(ctx, user, args);
27 if (isAnswer(repo)) return repo;
28 const chosen = args["labels"];
29 if (
30 !Array.isArray(chosen) ||
31 chosen.length > 50 ||
32 !chosen.every((l) => typeof l === "string" && l.length <= 64)
33 )
34 return bad("invalid_labels", "labels is a list of up to 50 label names");
35 const issue = issueArg(ctx, repo, args, "number");
36 if ("ok" in issue) return issue;
37 // The permission is the action's to check, as on the page.
38 const applied = await setIssueLabelsThrough(
39 ctx.app,
40 user,
41 repo,
42 issue,
43 chosen,
44 );
45 if (!applied.ok)
46 return bad(
47 applied.error,
48 originError(applied.error) ??
49 (applied.error === "unknown_label"
50 ? THREAD_SAYS.unknownLabel
51 : THREAD_SAYS.labelsForbidden),
52 );
53 const names = applied.value.map((l) => l.name);
54 return good({
55 says:
56 names.length === 0
57 ? `Cleared the labels of #${issue.number}.`
58 : `Labels of #${issue.number} set to ${names.join(", ")}.`,
59 labels: names,
60 url: `${ctx.url.origin}/${repo.ownerName}/${repo.name}/${issue.kind === "pull" ? "pulls" : "issues"}/${issue.number}`,
61 });
62 },
63 };
src/http/mcp/tools/limb.ts added +100
@@ -0,0 +1,100 @@
1 import { scopeAllows } from "../../../core/tokens.ts";
2 import { undoFleetOp } from "../../../actions/fleet.ts";
3 import { applyLimb, isLimbAction, previewLimb } from "../../../actions/limbs.ts";
4 import { isReplanAction, previewReplan, replanLimb, takeBackLimb } from "../../../actions/limb_replan.ts";
5 import { good, bad, type Tool, str, APP_ARG, planRepo, isAnswer } from "../shared.ts";
6
7 export const tool: Tool = {
8 name: "limb",
9 doc: "`{ app, issue, action, reason?, pillar?, operation?, dryRun? }`: defer, resume or close a whole limb as not doing, or undo an operation; plans unless `dryRun` is false, and applying takes an admin token",
10 description:
11 "Act on a limb by `action`; `close` takes `reason`.",
12 inputSchema: {
13 type: "object",
14 properties: {
15 app: APP_ARG,
16 issue: {
17 type: "integer",
18 minimum: 1,
19 },
20 action: { type: "string", enum: ["defer", "resume", "close", "raise", "lower", "replan", "takeback", "undo"] },
21 reason: {
22 type: "string",
23 description: "For close: why.",
24 },
25 pillar: {
26 type: "string",
27 description:
28 "For resume.",
29 },
30 under: {
31 type: "string",
32 description:
33 "`top` or a milestone number.",
34 },
35 operation: {
36 type: "string",
37 },
38 dryRun: {
39 type: "boolean",
40 description: "false to apply",
41 },
42 },
43 required: ["app", "action"],
44 },
45 writes: true,
46 dryByDefault: true,
47 async run(ctx, user, args) {
48 const repo = planRepo(ctx, user, args);
49 if (isAnswer(repo)) return repo;
50 const dry = args["dryRun"] !== false;
51 // The fleet's rule: a read credential plans, an admin one applies.
52 if (!dry && !scopeAllows(ctx.auth.scope ?? "ro", "admin"))
53 return bad("forbidden", "applying a limb action takes an admin token");
54 const refused = (e: { code: string; message: string }) =>
55 bad(e.code, e.message);
56 if (args["action"] === "undo") {
57 if (dry) return bad("invalid", "undo is not a dry run: pass dryRun false");
58 const undone = await undoFleetOp(ctx.app, user, str(args["operation"]));
59 return undone.ok ? good(undone.value) : refused(undone.error);
60 }
61 const number = args["issue"];
62 if (
63 isReplanAction(args["action"]) &&
64 typeof number === "number" &&
65 Number.isInteger(number) &&
66 number > 0
67 ) {
68 if (dry) {
69 const plan = previewReplan(ctx.app, user, repo, number);
70 return plan.ok ? good({ dryRun: true, ...plan.value }) : refused(plan.error);
71 }
72 const done =
73 args["action"] === "replan"
74 ? replanLimb(ctx.app, user, repo, number)
75 : takeBackLimb(ctx.app, user, repo, number);
76 return done.ok ? good({ dryRun: false, ...done.value }) : refused(done.error);
77 }
78 if (
79 !isLimbAction(args["action"]) ||
80 typeof number !== "number" ||
81 !Number.isInteger(number) ||
82 number < 1
83 )
84 return bad("invalid", "give the limb's issue number and an action: defer, resume, close, raise, lower, replan, takeback or undo");
85 const request = {
86 action: args["action"],
87 ...(typeof args["reason"] === "string" ? { reason: args["reason"] } : {}),
88 ...(typeof args["pillar"] === "string" ? { pillar: args["pillar"] } : {}),
89 ...(typeof args["under"] === "string" || typeof args["under"] === "number"
90 ? { under: String(args["under"]) }
91 : {}),
92 };
93 if (dry) {
94 const plan = previewLimb(ctx.app, user, repo, number, request);
95 return plan.ok ? good({ dryRun: true, ...plan.value }) : refused(plan.error);
96 }
97 const op = await applyLimb(ctx.app, user, repo, number, request);
98 return op.ok ? good({ dryRun: false, ...op.value }) : refused(op.error);
99 },
100 };
src/http/mcp/tools/limits.ts added +41
@@ -0,0 +1,41 @@
1 import { changeLimit, listLimits } from "../../../actions/limits.ts";
2 import { LIMIT_KEYS } from "../../../settings/limits.ts";
3 import { scopeAllows } from "../../../core/tokens.ts";
4 import { good, bad, type Tool } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "limits",
8 description:
9 "Every limit the forge's owner can hit, value and bounds. `key` and `value` (\"\" restores) plan.",
10 inputSchema: {
11 type: "object",
12 properties: {
13 key: { type: "string", enum: [...LIMIT_KEYS] },
14 value: { type: "string", maxLength: 16 },
15 dryRun: { type: "boolean", description: "false to apply" },
16 },
17 },
18 writes: true,
19 dryByDefault: true,
20 run(ctx, user, args) {
21 const listed = listLimits(ctx.app, user);
22 if (!listed.ok) return bad("not_found", "no such tool here");
23 const key = typeof args["key"] === "string" ? args["key"] : undefined;
24 if (key === undefined) return good(listed.value);
25 const value = typeof args["value"] === "string" ? args["value"] : "";
26 const now = listed.value.limits.find((l) => l.key === key);
27 if (now === undefined) return bad("no_such_limit", `no limit ${key}`);
28 if (args["dryRun"] !== false)
29 return good({
30 says: `Would set ${key} from ${now.value} to ${value || `its default, ${now.defaultValue}`}. Send dryRun: false to change it.`,
31 });
32 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
33 return bad("forbidden", "changing the forge's limits needs an admin-scope token");
34 const changed = changeLimit(ctx.app, user, key, value === "" ? null : value);
35 if (!changed.ok) return bad(changed.error.kind, changed.error.kind === "out_of_bounds" ? changed.error.message : "no such limit");
36 return good({
37 says: `${key}: ${changed.value.from} → ${changed.value.to}, from the next use on.`,
38 ...changed.value,
39 });
40 },
41 };
src/http/mcp/tools/make_it_yours.ts added +56
@@ -0,0 +1,56 @@
1 import { APP_ALREADY_YOURS } from "../../../actions/origin_app_words.ts";
2 import { NOT_A_MEMBER } from "../../../core/roles.ts";
3 import { COPIES_PER_DAY, makeItYours } from "../../../actions/make_it_yours.ts";
4 import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "make_it_yours",
8 doc: "`{ repo, name?, visibility?, dryRun? }`: a fork of an app you can see, as your own app, following the original; code and history copied, data and secrets not; private unless `visibility` is public (a public original only); at most 5 copies an account a day; plans unless `dryRun` is false",
9 description:
10 "Fork an app you can see: its code and history, following the original; nothing else is copied.",
11 inputSchema: {
12 type: "object",
13 properties: {
14 repo: REPO_ARG,
15 name: { type: "string" },
16 visibility: { type: "string", enum: ["private", "public"], description: "Private by default." },
17 dryRun: { type: "boolean" },
18 },
19 required: ["repo"],
20 },
21 writes: true,
22 dryByDefault: true,
23 async run(ctx, user, args) {
24 const repo = repoArg(ctx, user, args);
25 if (isAnswer(repo)) return repo;
26 const name = str(args["name"]);
27 const visibility = str(args["visibility"]);
28 if (visibility !== "" && visibility !== "public" && visibility !== "private")
29 return bad("invalid", "visibility is public or private");
30 const made = await makeItYours(ctx.app, user, repo, {
31 dryRun: args["dryRun"] !== false,
32 ...(name === "" ? {} : { name }),
33 ...(visibility === "" ? {} : { visibility }),
34 });
35 if (!made.ok)
36 return made.error.kind === "limit"
37 ? bad("limit", `at most ${COPIES_PER_DAY} copies a day; another after ${made.error.resetsAt}`)
38 : made.error.kind === "repository_limit"
39 ? bad("repository_limit", made.error.message)
40 : made.error.kind === "storage_limit"
41 ? bad("storage_limit", made.error.message)
42 : made.error.kind === "invalid"
43 ? bad("invalid", made.error.message)
44 : made.error.kind === "own"
45 ? bad("own", APP_ALREADY_YOURS)
46 : made.error.kind === "not_member"
47 ? bad("not_member", NOT_A_MEMBER)
48 : made.error.kind === "unverified"
49 ? bad("unverified", "prove your email first")
50 : bad("not_found", "no such app or repository (owner/name)");
51 const { made: done, ...plan } = made.value;
52 return good(
53 done === undefined ? plan : { ...plan, url: `/${done.repo.ownerName}/${done.repo.name}` },
54 );
55 },
56 };
src/http/mcp/tools/map.ts added +42
@@ -0,0 +1,42 @@
1 import { sealedScope } from "../../../core/sealed.ts";
2 import { repoGraph } from "../../../core/dependencies.ts";
3 import { pillarView } from "../../../core/pillars.ts";
4 import { askToLiveFor } from "../../../web/ask_to_live.ts";
5 import { listActiveRuns } from "../../../core/runs.ts";
6 import { mapJson, marksFor } from "../../../web/pages/map.ts";
7 import { good, type Tool, APP_ARG, planRepo, isAnswer } from "../shared.ts";
8
9 export const tool: Tool = {
10 name: "map",
11 doc: "an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`); draws the `ui://forge/map` card",
12 description:
13 "An app's plan: open issues ready or waiting, critical path, edges, where the crew is, `parkedTwice`.",
14 inputSchema: {
15 type: "object",
16 properties: { app: APP_ARG },
17 required: ["app"],
18 },
19 writes: false,
20 async run(ctx, user, args) {
21 const repo = planRepo(ctx, user, args);
22 if (isAnswer(repo)) return repo;
23 const graph = repoGraph(ctx.app.db, repo.id, {
24 ...sealedScope(ctx.app.db, repo, user),
25 });
26 const pillars = pillarView(ctx.app.db, repo);
27 return good(
28 mapJson(
29 repo,
30 graph,
31 marksFor(
32 ctx.app.db,
33 repo.id,
34 graph,
35 listActiveRuns(ctx.app.db, [repo.id]),
36 ),
37 pillars,
38 askToLiveFor(ctx.app, repo, pillars, user),
39 ),
40 );
41 },
42 };
src/http/mcp/tools/migrate.ts added +64
@@ -0,0 +1,64 @@
1 import { migrateMessage, migrateRepo, migrationJobFor } from "../../../actions/migrate.ts";
2 import type { MirrorJob } from "../../../origins/jobs.ts";
3 import { repositoryLimitMessage } from "../../../actions/repos.ts";
4 import { storageLimitMessage } from "../../../core/storage.ts";
5 import { good, bad, type Tool, str } from "../shared.ts";
6
7 export const tool: Tool = {
8 name: "migrate",
9 doc: "`{ url, name?, crew? }` brings a public repository in by URL (history only; nothing reaches back) and answers `{ job, state, url }`; `{ job }` alone reads that job. Same validation, scope (`rw`) and private-address refusal as `POST /api/v1/migrate`",
10 description:
11 "Bring a repository in by public `url` (`name`, `crew`), history and all. With `job`, its state.",
12 inputSchema: {
13 type: "object",
14 properties: {
15 url: { type: "string", maxLength: 2048 },
16 name: { type: "string", maxLength: 100 },
17 crew: { type: "string", maxLength: 39, description: "Agent to put on it" },
18 job: { type: "string", maxLength: 64 },
19 },
20 },
21 writes: true,
22 readsWithout: "url",
23 async run(ctx, user, args) {
24 const migration = (job: MirrorJob) => ({
25 job: job.id,
26 state: job.stage,
27 detail: job.detail,
28 url:
29 job.repo === undefined || job.stage === "failed"
30 ? null
31 : `${ctx.url.origin}/${job.repo.ownerName}/${job.repo.name}`,
32 error: job.error ?? null,
33 });
34 const url = args["url"];
35 if (url === undefined) {
36 const id = str(args["job"]);
37 if (id === "") return bad("invalid", "send `url` to migrate, or `job` to read one");
38 const found = migrationJobFor(user, id);
39 return found.ok ? good(migration(found.value)) : bad("not_found", "no such migration");
40 }
41 if (typeof url !== "string") return bad("invalid", "`url` is a string");
42 const name = args["name"];
43 const crew = args["crew"];
44 if (name !== undefined && typeof name !== "string") return bad("invalid", "`name` is a string");
45 if (crew !== undefined && typeof crew !== "string") return bad("invalid", "`crew` is a string");
46 const started = await migrateRepo(ctx.app, user, {
47 source: { url },
48 ...(name === undefined ? {} : { name }),
49 ...(crew === undefined ? {} : { crew: { agent: crew, autonomous: false } }),
50 });
51 if (!started.ok)
52 return started.error === "unauthorized"
53 ? bad("unauthorized", "sign in to migrate")
54 : started.error === "repository_limit"
55 ? bad("repository_limit", repositoryLimitMessage(ctx.app, user))
56 : started.error === "storage_limit"
57 ? bad("storage_limit", storageLimitMessage(ctx.app, user.id))
58 : bad(started.error, migrateMessage(started.error));
59 return good({
60 ...migration(started.value.job),
61 url: `${ctx.url.origin}/${started.value.repo.ownerName}/${started.value.repo.name}`,
62 });
63 },
64 };
src/http/mcp/tools/milestones.ts added +40
@@ -0,0 +1,40 @@
1 import { milestoneData, milestonesJson } from "../../../web/pages/milestones.ts";
2 import { good, bad, type Tool, APP_ARG, planRepo, isAnswer } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "milestones",
6 doc: "`{ app, issue? }`: how far each thing asked for is (and, with `issue`, whether you watch it: `watching`), from the dependency tree: the plan's limbs with their weights, or one milestone opened (who is building, what is blocked and on what, what merged in 7 days, an estimate, and for an owner `spend`: dollars, changes landed ever, the costliest three issues)",
7 description:
8 "How far each thing asked for is. With `app`: top-level limbs, done/total, building, recent merges.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 app: APP_ARG,
13 issue: {
14 type: "integer",
15 minimum: 1,
16 description:
17 "Issue number; limbs if absent.",
18 },
19 },
20 required: ["app"],
21 },
22 writes: false,
23 async run(ctx, user, args) {
24 const repo = planRepo(ctx, user, args);
25 if (isAnswer(repo)) return repo;
26 if (args["issue"] === undefined)
27 return good(await milestonesJson(ctx, repo));
28 const number = args["issue"];
29 const data =
30 typeof number === "number" && Number.isInteger(number) && number > 0
31 ? await milestoneData(ctx, repo, number)
32 : undefined;
33 return data === undefined
34 ? bad(
35 "not_a_milestone",
36 "no issue by that number waits for others here",
37 )
38 : good(data);
39 },
40 };
src/http/mcp/tools/mint_invite.ts added +27
@@ -0,0 +1,27 @@
1 import { SPEND_WORDS } from "../../../actions/spend_words.ts";
2 import { inviteLink, mint as mintInviteLink } from "../../../actions/invites.ts";
3 import { scopeAllows } from "../../../core/tokens.ts";
4 import { good, bad, type Tool, mayBoards } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "mint_invite",
8 doc: "`{ days? }`: mint a single-use invite link that expires in `days` (1–30, default 7) and is shown once; a person admin's, with an admin token",
9 description:
10 "Mint a single-use invite link, shown once, expiring in `days` (1–30, default 7). Person admins.",
11 inputSchema: {
12 type: "object",
13 properties: {
14 days: { type: "integer", minimum: 1, maximum: 30 },
15 },
16 },
17 writes: true,
18 run(ctx, user, args) {
19 if (!mayBoards(user)) return bad("forbidden", "an invite is a forge admin's to mint");
20 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
21 return bad("forbidden", "an invite needs an admin token");
22 const made = mintInviteLink(ctx.app, user, args["days"]);
23 if (!made.ok) return bad("invalid", SPEND_WORDS.inviteDays);
24 const origin = ctx.app.config.url ?? ctx.url.origin;
25 return good({ ...made.value.invite, link: inviteLink(origin, made.value.token) });
26 },
27 };
src/http/mcp/tools/mirror_set.ts added +48
@@ -0,0 +1,48 @@
1 import { forgetMirror, saveMirror } from "../../../actions/mirror.ts";
2 import { snapshotsOf } from "../../../snapshot/service.ts";
3 import { good, bad, type Tool } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "mirror_set",
7 doc: "`{ action, endpoint?, bucket?, region?, accessKey?, secretKey? }` (forge admin, admin-scope token): `set` seals the off-box snapshot mirror, `forget` clears it; the Backups page's rules and refusals, a blank field keeps what was sealed; answers `{ place, says }`, never a key",
8 description: "Admin: set or forget the off-box snapshot mirror.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 action: { type: "string" },
13 endpoint: { type: "string" },
14 bucket: { type: "string" },
15 region: { type: "string" },
16 accessKey: { type: "string" },
17 secretKey: { type: "string" },
18 },
19 },
20 writes: true,
21 adminOnly: true,
22 needsAdminScope: true,
23 run(ctx, user, args) {
24 const place = (): string | null => snapshotsOf(ctx.app)?.mirror?.where ?? null;
25 if (args["action"] === "forget") {
26 const gone = forgetMirror(ctx.app, user);
27 if (!gone.ok) return bad("not_found", "not found");
28 return good({ place: place(), says: "The sealed mirror is cleared." });
29 }
30 if (args["action"] !== "set") return bad("invalid", "action is set or forget");
31 const field = (k: string): string | undefined => {
32 const v = args[k];
33 return v === undefined ? "" : typeof v === "string" ? v.trim() : undefined;
34 };
35 const input = {
36 endpoint: field("endpoint"),
37 bucket: field("bucket"),
38 region: field("region"),
39 accessKey: field("accessKey"),
40 secretKey: field("secretKey"),
41 };
42 if (Object.values(input).some((v) => v === undefined))
43 return bad("invalid", "the fields are strings");
44 const saved = saveMirror(ctx.app, user, input as Record<keyof typeof input, string>);
45 if (!saved.ok) return bad(saved.error === "not_found" ? "not_found" : "invalid", saved.error);
46 return good({ place: place(), says: saved.value });
47 },
48 };
src/http/mcp/tools/model_ceiling.ts added +47
@@ -0,0 +1,47 @@
1 import { SPEND_WORDS } from "../../../actions/spend_words.ts";
2 import { setAccountCeilings } from "../../../actions/model_grant.ts";
3 import { scopeAllows } from "../../../core/tokens.ts";
4 import { good, bad, type Tool, mayBoards } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "model_ceiling",
8 description:
9 "Set an account's daily and per-run model spend ceilings in dollars; a run past one does not start.",
10 inputSchema: {
11 type: "object",
12 properties: {
13 username: { type: "string" },
14 dailyUsd: { type: ["number", "null"], description: "0.01–10000; null clears." },
15 perRunUsd: { type: ["number", "null"], description: "0.01–10000; null clears." },
16 dryRun: { type: "boolean", description: "false to apply" },
17 },
18 required: ["username", "dailyUsd", "perRunUsd"],
19 },
20 writes: true,
21 dryByDefault: true,
22 run(ctx, user, args) {
23 const { username, dailyUsd, perRunUsd } = args;
24 if (
25 typeof username !== "string" ||
26 !(dailyUsd === null || (typeof dailyUsd === "number" && Number.isFinite(dailyUsd))) ||
27 !(perRunUsd === null || (typeof perRunUsd === "number" && Number.isFinite(perRunUsd)))
28 )
29 return bad("invalid", "send a username, dailyUsd and perRunUsd in dollars (both null takes them back)");
30 if (!mayBoards(user)) return bad("forbidden", "a ceiling is a forge admin's to set");
31 if (args["dryRun"] !== false)
32 return good({
33 username,
34 dailyUsd,
35 perRunUsd,
36 says: "This is the plan; nothing changed. Send dryRun: false to set it.",
37 });
38 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
39 return bad("forbidden", "a ceiling needs an admin token");
40 const set = setAccountCeilings(ctx.app, user, username, dailyUsd, perRunUsd);
41 if (!set.ok)
42 return set.error === "invalid_amount"
43 ? bad("invalid", SPEND_WORDS.ceilings)
44 : bad("forbidden", "no such account");
45 return good(set.value);
46 },
47 };
src/http/mcp/tools/model_grant.ts added +46
@@ -0,0 +1,46 @@
1 import { SPEND_WORDS } from "../../../actions/spend_words.ts";
2 import { setAccountGrant } from "../../../actions/model_grant.ts";
3 import { scopeAllows } from "../../../core/tokens.ts";
4 import { good, bad, type Tool, mayBoards } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "model_grant",
8 description:
9 "Give a person a capped monthly model grant (`usdPerMonth`, 0 takes it back). Person admins.",
10 inputSchema: {
11 type: "object",
12 properties: {
13 username: { type: "string" },
14 usdPerMonth: { type: "number", description: "0.01–10000; 0 revokes." },
15 dryRun: { type: "boolean", description: "false to apply" },
16 },
17 required: ["username", "usdPerMonth"],
18 },
19 writes: true,
20 dryByDefault: true,
21 run(ctx, user, args) {
22 const usd = args["usdPerMonth"];
23 const username = args["username"];
24 if (typeof username !== "string" || typeof usd !== "number" || !Number.isFinite(usd))
25 return bad("invalid", "send a username and usdPerMonth in dollars");
26 if (!mayBoards(user)) return bad("forbidden", "a model grant is a forge admin's to give");
27 const dryRun = args["dryRun"] !== false;
28 if (dryRun)
29 return good({
30 username,
31 usdPerMonth: usd === 0 ? null : usd,
32 says: "This is the plan; nothing changed. Send dryRun: false to set it.",
33 });
34 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
35 return bad("forbidden", "a model grant needs an admin token");
36 const set = setAccountGrant(ctx.app, user, username, usd === 0 ? null : usd);
37 if (!set.ok)
38 return bad(
39 set.error === "invalid_amount" ? "invalid" : "forbidden",
40 set.error === "invalid_amount"
41 ? SPEND_WORDS.usdPerMonth
42 : "no such person, or not one a grant is for",
43 );
44 return good(set.value);
45 },
46 };
src/http/mcp/tools/new_app.ts added +68
@@ -0,0 +1,68 @@
1 import { appsRuntime } from "../../../apps/runtime.ts";
2 import { listActiveRuns } from "../../../core/runs.ts";
3 import { getUserByName } from "../../../core/users.ts";
4 import { repositoryLimitMessage } from "../../../actions/repos.ts";
5 import { newApp } from "../../../actions/apps.ts";
6 import { appLimitWords } from "../../../actions/app_limit.ts";
7 import { storageLimitMessage } from "../../../core/storage.ts";
8 import { good, bad, type Tool, str } from "../shared.ts";
9
10 export const tool: Tool = {
11 name: "new_app",
12 doc: "`{ name, description?, owner?, visibility?, agent?, pick? }`: make an app from a sentence; answers `{ repo, url, issue, agent, run }` (`run` is the first run's state, or `none`) and draws the `ui://forge/app` card: name, production URL, first issue, agent, first run; refused as `POST /api/v1/apps` is (unverified, app_limit, no_such_agent, name_taken)",
13 description:
14 "Make an app from a sentence: a repository, production started, and with `description` a first issue.",
15 inputSchema: {
16 type: "object",
17 properties: {
18 name: { type: "string", maxLength: 100 },
19 description: { type: "string", maxLength: 4000, description: "The first issue." },
20 owner: { type: "string", maxLength: 39 },
21 visibility: { type: "string", enum: ["public", "private"], description: "Default private." },
22 agent: { type: "string", maxLength: 39, description: "Agent for the first issue." },
23 pick: { type: "boolean", description: "Pick the agent from the text." },
24 },
25 required: ["name"],
26 },
27 writes: true,
28 async run(ctx, user, args) {
29 const text = (k: string) => (typeof args[k] === "string" ? (args[k] as string) : undefined);
30 const visibility = args["visibility"];
31 const owner = text("owner");
32 const description = text("description");
33 const agent = text("agent");
34 const made = await newApp(ctx.app, user, {
35 name: str(args["name"]),
36 ...(owner === undefined ? {} : { owner }),
37 ...(description === undefined ? {} : { description }),
38 ...(visibility === "public" || visibility === "private" ? { visibility } : {}),
39 ...(agent === undefined ? {} : { agent }),
40 ...(args["pick"] === true ? { pickAgent: true } : {}),
41 });
42 if (!made.ok) {
43 switch (made.error) {
44 case "unverified":
45 return bad("unverified", "verify your email first");
46 case "app_limit": {
47 const who = getUserByName(ctx.app.db, owner || user.username);
48 return bad("app_limit", who ? appLimitWords(ctx.app, who.id) : "App limit reached.");
49 }
50 case "repository_limit":
51 return bad("repository_limit", repositoryLimitMessage(ctx.app, user));
52 case "storage_limit":
53 return bad("storage_limit", storageLimitMessage(ctx.app, user.id));
54 default:
55 return bad(made.error, made.error);
56 }
57 }
58 const { repo, app: record, issue } = made.value;
59 const first = listActiveRuns(ctx.app.db, [repo.id])[0];
60 return good({
61 repo: `${repo.ownerName}/${repo.name}`,
62 url: appsRuntime(ctx.app)?.urlFor(record, "production") ?? null,
63 issue: issue === undefined ? null : issue.number,
64 agent: issue?.assigneeName ?? null,
65 run: first?.state ?? "none",
66 });
67 },
68 };
src/http/mcp/tools/next_steps.ts added +15
@@ -0,0 +1,15 @@
1 import { nextStepsFor } from "../../../core/next-steps.ts";
2 import { good, type Tool } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "next_steps",
6 doc: "on a spawned forge, your first three steps and whether each is done; empty elsewhere or for anyone but its owner",
7 description:
8 "On a spawned forge, its owner's first three steps and whether each is done. Empty elsewhere.",
9 inputSchema: { type: "object", properties: {} },
10 writes: false,
11 async run(ctx, user) {
12 const steps = await nextStepsFor(ctx.app, user);
13 return good({ steps: steps ?? [] });
14 },
15 };
src/http/mcp/tools/now.ts added +105
@@ -0,0 +1,105 @@
1 import { mayViewId } from "../../../core/sealed.ts";
2 import { getIssueById, listOpenAgentPullsVisibleTo } from "../../../core/issues.ts";
3 import { waitsForPerson } from "../../../crew/review.ts";
4 import { getPull } from "../../../core/pulls.ts";
5 import { ownerFactRepoIds } from "../../../core/repos.ts";
6 import { plannerDeclines } from "../../../crew/autonomy.ts";
7 import { listActiveRuns } from "../../../core/runs.ts";
8 import { getUsersByIds } from "../../../core/users.ts";
9 import { appsOf } from "../../../web/studio/asks.ts";
10 import { mayReadRuns, runForReader } from "../../../actions/runs.ts";
11 import { placementWords } from "../../../checks/placement.ts";
12 import { good, type Tool } from "../shared.ts";
13
14 export const tool: Tool = {
15 name: "now",
16 doc: "what the crew is doing right now: who, on what, since when, at what cost; a planner that declined the direction and waits for you comes first (`run: null`, its words in `says`)",
17 description:
18 "What the crew is doing across your apps: who, on what, since when, cost; what waits on your review.",
19 inputSchema: { type: "object", properties: {} },
20 writes: false,
21 run(ctx, user) {
22 const { repos } = appsOf(ctx, user);
23 const byId = new Map(repos.map((r) => [r.id, r]));
24 // A private app's runs are for those who may enter it (`mayReadRuns`).
25 // A run on a sealed report is for those who may see the report.
26 const runs = listActiveRuns(
27 ctx.app.db,
28 repos.filter((r) => mayReadRuns(ctx.app, user, r)).map((r) => r.id),
29 ).filter((r) =>
30 mayViewId(ctx.app.db, byId.get(r.repoId)!, r.issueId, user),
31 );
32 const actors = getUsersByIds(
33 ctx.app.db,
34 runs.map((r) => r.actorId).filter((id): id is string => id !== null),
35 );
36 // A planner that declined the direction is the crew waiting on you:
37 // it rides the same list, as a row with no run.
38 // Owner facts only: a stranger's public app never shows its planner here.
39 const owned = ownerFactRepoIds(ctx.app.db, repos, user);
40 const waiting = plannerDeclines(
41 ctx.app.db,
42 repos.filter((r) => owned.has(r.id)),
43 ).map((d) => ({
44 run: null,
45 app: `${d.repo.ownerName}/${d.repo.name}`,
46 who: "the planner",
47 on: `#${d.issue.number} ${d.issue.title}`,
48 why: "declined",
49 state: "waiting for you",
50 says: d.words,
51 since: d.at,
52 costUsd: 0,
53 }));
54 // An agent's green pull that waits for a person's review (#597): no
55 // reviewer on the crew, or one that lapsed — with its age, never silent.
56 const ownRepos = repos.filter((r) => owned.has(r.id));
57 const reviewWaits = listOpenAgentPullsVisibleTo(ctx.app.db, user, 200)
58 .filter((i) => ownRepos.some((r) => r.id === i.repoId))
59 .flatMap((issue) => {
60 const pull = getPull(ctx.app.db, issue.repoId, issue.number);
61 const repo = byId.get(issue.repoId);
62 const wait = pull && repo ? waitsForPerson(ctx.app.db, repo.id, pull) : undefined;
63 return wait === undefined || repo === undefined
64 ? []
65 : [
66 {
67 run: null,
68 app: `${repo.ownerName}/${repo.name}`,
69 who: "you",
70 on: `#${issue.number} ${issue.title}`,
71 why: "review",
72 state: "waiting for your review",
73 says: wait.why === "lapsed" ? "the reviewer did not answer" : "no reviewer on the crew",
74 since: wait.since,
75 costUsd: 0,
76 },
77 ];
78 });
79 return good([
80 ...waiting,
81 ...reviewWaits,
82 ...runs.map((run) => {
83 const repo = byId.get(run.repoId)!;
84 // As this caller may read it (`runForReader`): cost and node are
85 // the owner's facts.
86 const r = runForReader(ctx.app, user, repo)(run);
87 const issue =
88 r.issueId === null
89 ? undefined
90 : getIssueById(ctx.app.db, r.issueId);
91 return {
92 run: r.id,
93 app: `${repo.ownerName}/${repo.name}`,
94 who: (r.actorId && actors.get(r.actorId)?.username) || "the crew",
95 on: issue === undefined ? null : `#${issue.number} ${issue.title}`,
96 why: r.reason,
97 state: r.state,
98 since: r.startedAt ?? r.createdAt,
99 costUsd: r.costUsd,
100 ...(placementWords(r) === "" ? {} : { ran: placementWords(r) }),
101 };
102 }),
103 ]);
104 },
105 };
src/http/mcp/tools/org_teams.ts added +46
@@ -0,0 +1,46 @@
1 import { addTeamMember, createTeam, deleteTeam } from "../../../actions/orgs.ts";
2 import { getOrg, listTeams, type Permission } from "../../../core/orgs.ts";
3 import { good, bad, type Tool } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "org_teams",
7 doc: "`{ org, action, team, user?, permission? }` (admin-scope token, an org owner): `create` a team (`permission` read, write or admin; read by default), `add_member` a `user`, or `remove` the team; the same as POST/DELETE `/api/v1/orgs/:org/teams`",
8 description: "An organization's teams: create, add a member, remove.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 org: { type: "string", maxLength: 64 },
13 action: { type: "string", enum: ["create", "add_member", "remove"] },
14 team: { type: "string", maxLength: 64 },
15 user: { type: "string", maxLength: 64, description: "add_member" },
16 permission: { type: "string", enum: ["read", "write", "admin"], description: "create" },
17 },
18 required: ["org", "action", "team"],
19 },
20 writes: true,
21 needsAdminScope: true,
22 run(ctx, user, args) {
23 const org = getOrg(ctx.app.db, typeof args["org"] === "string" ? args["org"] : "");
24 if (!org) return bad("not_found", "no such organization");
25 const name = typeof args["team"] === "string" ? args["team"] : "";
26 const action = args["action"];
27 if (action === "create") {
28 const p = args["permission"];
29 const permission: Permission = p === "write" || p === "admin" ? p : "read";
30 const made = createTeam(ctx.app, user, org, name, permission);
31 return made.ok ? good(made.value) : bad(made.error, made.error);
32 }
33 const wanted = name.toLowerCase();
34 const team = listTeams(ctx.app.db, org.id).find((t) => t.name.toLowerCase() === wanted);
35 if (!team) return bad("not_found", "no such team");
36 if (action === "add_member") {
37 const added = addTeamMember(ctx.app, user, org, team, typeof args["user"] === "string" ? args["user"] : "");
38 return added.ok ? good(added.value) : bad(added.error, added.error);
39 }
40 if (action === "remove") {
41 const gone = deleteTeam(ctx.app, user, org, team);
42 return gone.ok ? good({ removed: team.name }) : bad(gone.error, gone.error);
43 }
44 return bad("invalid", "action is create, add_member or remove");
45 },
46 };
src/http/mcp/tools/origin_apps.ts added +59
@@ -0,0 +1,59 @@
1 import { scopeAllows } from "../../../core/tokens.ts";
2 import { deleteOriginApp, listOriginApps, registerOriginApp } from "../../../actions/origins.ts";
3 import { connectMessage } from "../../api.ts";
4 import { good, bad, type Tool, str } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "origin_apps",
8 doc: "`{ action, id?, host?, kind?, clientId?, clientSecret? }`: `list` your OAuth apps for connecting origins, `add` one (a `host`, or `kind` github), or `remove` one by `id`; the API's rules and errors, an admin-scope token; a secret is never read back, only `hasSecret`",
9 description:
10 "Your origin OAuth apps: list, add, remove.",
11 inputSchema: {
12 type: "object",
13 properties: {
14 action: { type: "string" },
15 id: { type: "string" },
16 host: { type: "string" },
17 kind: { type: "string" },
18 clientId: { type: "string" },
19 clientSecret: { type: "string" },
20 },
21 },
22 writes: true,
23 async run(ctx, user, args) {
24 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
25 return bad("forbidden", "origin apps need an admin-scope token");
26 const action = args["action"];
27 // Never the secret: an app is shown as `hasSecret`, and errors are codes.
28 const shown = (a: ReturnType<typeof listOriginApps>[number]) => {
29 const { userId: _owner, ...rest } = a;
30 return rest;
31 };
32 if (action === "list")
33 return good({ items: listOriginApps(ctx.app, user).map(shown), next_cursor: null });
34 if (action === "add") {
35 const kind = str(args["kind"]);
36 const clientId = str(args["clientId"]);
37 if (kind !== "" && kind !== "github" && kind !== "forgejo" && kind !== "gitea")
38 return bad("invalid", "kind is github, forgejo or gitea");
39 if (clientId === "") return bad("invalid", "`clientId` is required");
40 const host = str(args["host"]) || kind;
41 if (host === "") return bad("invalid", "`host` or `kind` is required");
42 const secret = args["clientSecret"];
43 const saved = await registerOriginApp(ctx.app, user, {
44 host,
45 ...(kind === "" ? {} : { kind }),
46 clientId,
47 ...(typeof secret === "string" ? { clientSecret: secret } : {}),
48 });
49 return saved.ok ? good(shown(saved.value)) : bad(saved.error, connectMessage(saved.error));
50 }
51 if (action === "remove") {
52 const id = str(args["id"]);
53 if (id === "") return bad("invalid", "`id` is required");
54 const removed = deleteOriginApp(ctx.app, user, id);
55 return removed.ok ? good({}) : bad("not_found", "no such origin app");
56 }
57 return bad("invalid", "`action` is list, add or remove");
58 },
59 };
src/http/mcp/tools/origins.ts added +45
@@ -0,0 +1,45 @@
1 import { listRemoteRepos, listUserOrigins, syncOriginNow } from "../../../actions/origins.ts";
2 import { originJson, remoteRepoJson } from "../../api.ts";
3 import { ORIGIN_UNREACHABLE } from "../../../actions/origin_app_words.ts";
4 import { scopeAllows } from "../../../core/tokens.ts";
5 import { good, bad, type Tool } from "../shared.ts";
6
7 export const tool: Tool = {
8 name: "origins",
9 doc: "`{ action, id? }`: `list` your connected origins, `repos` the repositories one holds (`id`; `mirroredAs` says which are already here), or `sync` one now; the same as `GET /api/v1/user/origins`, `…/:id/repos` and `POST …/:id/sync`, for you only, an admin token as there; never a credential in an answer",
10 description:
11 "Your connected origins: list, repos of one, or sync it.",
12 inputSchema: {
13 type: "object",
14 properties: {
15 action: { type: "string", enum: ["list", "repos", "sync"] },
16 id: { type: "string" },
17 },
18 required: ["action"],
19 },
20 writes: true,
21 readsWithout: "id",
22 async run(ctx, user, args) {
23 const action = args["action"];
24 const id = args["id"];
25 if (action !== "list" && action !== "repos" && action !== "sync")
26 return bad("bad_action", "`action` is list, repos or sync");
27 if (action !== "list" && (typeof id !== "string" || id === ""))
28 return bad("invalid", "send the origin's `id`, from list");
29 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
30 return bad("forbidden", "origins spend your credential elsewhere and need an admin token");
31 if (action === "list")
32 return good(listUserOrigins(ctx.app, user).map(originJson));
33 const origin = id as string;
34 if (action === "sync") {
35 const synced = await syncOriginNow(ctx.app, user, origin);
36 return synced.ok ? good(synced.value) : bad("not_found", "no such origin");
37 }
38 const listed = await listRemoteRepos(ctx.app, user, origin);
39 if (!listed.ok)
40 return listed.error === "unreachable"
41 ? bad("origin_unreachable", ORIGIN_UNREACHABLE)
42 : bad("not_found", "no such origin");
43 return good(listed.value.map((row) => remoteRepoJson(ctx, row)));
44 },
45 };
src/http/mcp/tools/outsiders.ts added +45
@@ -0,0 +1,45 @@
1 import { scopeAllows } from "../../../core/tokens.ts";
2 import { DIRECTION_NOTE, directionNote, OUTSIDERS_NOTE } from "../../../crew/direct.ts";
3 import { setCrewOutsiders } from "../../../actions/repos.ts";
4 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "outsiders",
8 doc: "`{ repo, on?, dryRun? }`: whether people who cannot write may direct the crew; with `on`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent",
9 description:
10 "Whether people who cannot write an app may direct its crew (off by default). No `on` reads it.",
11 inputSchema: {
12 type: "object",
13 properties: {
14 repo: REPO_ARG,
15 on: { type: "boolean" },
16 dryRun: {
17 type: "boolean",
18 description: "false to apply",
19 },
20 },
21 required: ["repo"],
22 },
23 writes: true,
24 dryByDefault: true,
25 async run(ctx, user, args) {
26 const repo = repoArg(ctx, user, args);
27 if (isAnswer(repo)) return repo;
28 if (typeof args["on"] !== "boolean")
29 return good({ on: repo.crewOutsiders, says: directionNote(repo) });
30 const dryRun = args["dryRun"] !== false;
31 if (!dryRun && !scopeAllows(ctx.auth.scope ?? "ro", "admin"))
32 return bad("forbidden", "setting who directs the crew needs an admin token");
33 const set = await setCrewOutsiders(ctx.app, user, repo, args["on"], dryRun);
34 if (!set.ok)
35 return bad("forbidden", "only an admin of this app sets who directs its crew");
36 return good({
37 ...set.value,
38 says: dryRun
39 ? `This is the plan; nothing changed. Outsiders ${set.value.was ? "may" : "may not"} trigger the crew now; send dryRun: false to ${set.value.on ? "let them" : "stop them"}.`
40 : set.value.on
41 ? OUTSIDERS_NOTE
42 : DIRECTION_NOTE,
43 });
44 },
45 };
src/http/mcp/tools/plan.ts added +81
@@ -0,0 +1,81 @@
1 import { openPlan, parsePlan, PLAN_MAX, proposePlan } from "../../../actions/plan.ts";
2 import { good, bad, type Tool, APP_ARG, planRepo, isAnswer } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "plan",
6 doc: "`{ app, issues }`: open up to 12 linked issues, all or none (see below)",
7 description: `Open and link small issues, all or none (max ${PLAN_MAX}); each has \`pillar\` or \`later: true\` with \`why\`.`,
8 inputSchema: {
9 type: "object",
10 properties: {
11 app: APP_ARG,
12 title: { type: "string", maxLength: 256 },
13 why: { type: "string", maxLength: 65536 },
14 issues: {
15 type: "array",
16 minItems: 1,
17 maxItems: PLAN_MAX,
18 items: {
19 type: "object",
20 properties: {
21 key: {
22 type: "string",
23 },
24 title: { type: "string" },
25 body: { type: "string" },
26 blockedBy: {
27 type: "array",
28 items: { type: ["string", "integer"] },
29 },
30 pillar: {
31 type: "string",
32 description: 'Pillar, "P0".',
33 },
34 later: {
35 type: "boolean",
36 description: "No pillar; needs why.",
37 },
38 why: {
39 type: "string",
40 description: "With later: why.",
41 },
42 },
43 required: ["key", "title"],
44 },
45 },
46 },
47 required: ["app", "issues"],
48 },
49 writes: true,
50 run(ctx, user, args) {
51 const repo = planRepo(ctx, user, args);
52 if (isAnswer(repo)) return repo;
53 const items = parsePlan(args["issues"]);
54 if (!items.ok) return bad(items.error.error, items.error.message);
55 if (repo.plansNeedNod) {
56 const proposed = proposePlan(ctx.app, user, repo, items.value, {
57 title: args["title"],
58 why: args["why"],
59 });
60 if (!proposed.ok)
61 return bad(proposed.error.error, proposed.error.message);
62 return good({
63 says: `Proposed plan ${proposed.value.id}: nothing is opened until a person nods.`,
64 ...proposed.value,
65 });
66 }
67 const planned = openPlan(ctx.app, user, repo, items.value);
68 if (!planned.ok) return bad(planned.error.error, planned.error.message);
69 const { opened, ready, later, gate } = planned.value;
70 const waits =
71 gate === undefined
72 ? ""
73 : ` ${later.map((n) => `#${n}`).join(", ")} wait${later.length === 1 ? "s" : ""} behind the Later gate #${gate}.`;
74 return good({
75 says: `Opened ${opened.length} issue${opened.length === 1 ? "" : "s"}: ${opened.map((o) => `${o.key} is #${o.number}`).join(", ")}.${waits}`,
76 opened,
77 ready,
78 later,
79 });
80 },
81 };
src/http/mcp/tools/preview.ts added +23
@@ -0,0 +1,23 @@
1 import { appsRuntime } from "../../../apps/runtime.ts";
2 import { previewOf } from "../../../apps/preview_view.ts";
3 import { good, bad, type Tool } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "preview",
7 doc: "`{ url }`: what the forge pill says of an app host's URL (production or a pull's preview): `{ app, environment, pull: { number, title } \\| null, check: \"green\" \\| \"red\" \\| \"running\" \\| null, firstFailure, logUrl, working: [names] }`; `firstFailure` is the check's first failing test or its summary, `logUrl` is set only for someone who may read the log (a writer), `working` names the agents with a run going; a URL that is not one of this forge's app hosts is `invalid`, an app you cannot read is `not_found` and a pull you cannot see is `no_such_pull`, as `repo` and `pull` say",
8 description:
9 "An app URL's pull, check, first failure, log.",
10 inputSchema: {
11 type: "object",
12 properties: { url: { type: "string" } },
13 required: ["url"],
14 },
15 writes: false,
16 run(ctx, user, args) {
17 const seen = previewOf(ctx.app, appsRuntime(ctx.app)?.options.domain, user, ctx.url.origin, args["url"]);
18 if (seen.ok) return good(seen.value);
19 if (seen.error === "foreign") return bad("invalid", "`url` is one of this forge's app hosts");
20 if (seen.error === "no_such_pull") return bad("no_such_pull", `no pull #${String(seen.number)}`);
21 return bad("not_found", "no such app or repository (owner/name)");
22 },
23 };
src/http/mcp/tools/probe.ts added +41
@@ -0,0 +1,41 @@
1 import { appsRuntime } from "../../../apps/runtime.ts";
2 import { getAppByRepo } from "../../../core/apps.ts";
3 import { canWrite } from "../../../core/repos.ts";
4 import { probeApp } from "../../../contract/probes.ts";
5 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
6
7 export const tool: Tool = {
8 name: "probe",
9 doc: "knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract",
10 description:
11 "Try every door of a running app as nobody, a user and an admin against its contract.json; read-only.",
12 inputSchema: {
13 type: "object",
14 properties: { repo: REPO_ARG },
15 required: ["repo"],
16 },
17 writes: false,
18 async run(ctx, user, args) {
19 const repo = repoArg(ctx, user, args);
20 if (isAnswer(repo)) return repo;
21 if (!canWrite(ctx.app.db, repo, user))
22 return bad("forbidden", "you need write access to knock");
23 const record = getAppByRepo(ctx.app.db, repo.id);
24 const runtime = appsRuntime(ctx.app);
25 if (record === undefined || runtime === undefined)
26 return bad("not_an_app", "nothing is serving that app here");
27 const row = await probeApp(ctx.app, runtime, record);
28 if (row === undefined)
29 return bad(
30 "no_contract",
31 "that app has no sound contract to compare with",
32 );
33 return good({
34 verdict: row.verdict,
35 commit: row.commitOid,
36 said: row.said,
37 asked: row.knocks.filter((k) => k.verdict !== "unprobed").length,
38 unprobed: row.knocks.filter((k) => k.verdict === "unprobed").length,
39 });
40 },
41 };
src/http/mcp/tools/prompts.ts added +54
@@ -0,0 +1,54 @@
1 import { addPromptSource, listPromptSources, removePromptSource, runPromptSource } from "../../../actions/prompts.ts";
2 import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "prompts",
6 doc: "`{ repo, action, kind?, token?, at?, id? }`: list, add, run or remove an app's prompt sources (Instagram), as the `…/prompts` API does; the token goes in once and is never shown; listed only for an admin-scope token, and only the repository's admin may use it",
7 description:
8 "An app's prompt sources (Instagram): list, add (`kind`, `token`, `at`), run or remove (`id`). Repository admin token.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 repo: REPO_ARG,
13 action: { type: "string", enum: ["list", "add", "run", "remove"] },
14 kind: { type: "string", description: "add: instagram" },
15 token: { type: "string", description: "add: sealed, never shown again" },
16 at: { type: "string", description: "add: HH:MM UTC" },
17 id: { type: "string", description: "run, remove" },
18 },
19 required: ["repo", "action"],
20 },
21 writes: true,
22 needsAdminScope: true,
23 async run(ctx, user, args) {
24 const repo = repoArg(ctx, user, args);
25 if (isAnswer(repo)) return repo;
26 const refuse = (error: string) =>
27 error === "not_found"
28 ? bad("not_found", "no such app or repository (owner/name)")
29 : bad(error, error.replaceAll("_", " "));
30 const action = args["action"];
31 if (action === "list") {
32 const listed = listPromptSources(ctx.app, user, repo);
33 return listed.ok ? good({ items: listed.value }) : refuse(listed.error);
34 }
35 if (action === "add") {
36 const added = addPromptSource(ctx.app, user, repo, {
37 kind: str(args["kind"]),
38 token: str(args["token"]),
39 at: str(args["at"]),
40 });
41 return added.ok ? good(added.value) : refuse(added.error);
42 }
43 const id = str(args["id"]);
44 if (action === "run") {
45 const ran = await runPromptSource(ctx.app, user, repo, id);
46 return ran.ok ? good(ran.value) : refuse(ran.error);
47 }
48 if (action === "remove") {
49 const removed = removePromptSource(ctx.app, user, repo, id);
50 return removed.ok ? good({ removed: true }) : refuse(removed.error);
51 }
52 return bad("bad_action", "`action` is list, add, run or remove");
53 },
54 };
src/http/mcp/tools/proposal_tokens.ts added +39
@@ -0,0 +1,39 @@
1 import { listProposals, mintProposal, repoByName, revokeProposal } from "../../../actions/proposal_tokens.ts";
2 import { good, bad, type Tool, REPO_ARG } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "proposal_tokens",
6 description:
7 "A child's propose-only credential: `mint` (`child`, `origin`), `list`, `revoke` (`id`). Writers.",
8 inputSchema: {
9 type: "object",
10 properties: {
11 repo: REPO_ARG,
12 verb: { type: "string", enum: ["mint", "list", "revoke"] },
13 child: { type: "string", maxLength: 40 },
14 origin: { type: "string", maxLength: 200 },
15 childRepo: { type: "string", maxLength: 200 },
16 id: { type: "string", maxLength: 80 },
17 },
18 required: ["repo", "verb"],
19 },
20 writes: true,
21 run(ctx, user, args) {
22 const repo = repoByName(ctx.app, args["repo"]);
23 const verb = args["verb"];
24 const done =
25 verb === "mint"
26 ? mintProposal(ctx.app, user, repo, {
27 child: args["child"],
28 origin: args["origin"],
29 childRepo: args["childRepo"],
30 })
31 : verb === "list"
32 ? listProposals(ctx.app, user, repo)
33 : verb === "revoke"
34 ? revokeProposal(ctx.app, user, repo, args["id"])
35 : undefined;
36 if (done === undefined) return bad("invalid", "verb is mint, list or revoke");
37 return done.ok ? good(done.value ?? { ok: true }) : bad(done.error.code, done.error.message);
38 },
39 };
src/http/mcp/tools/propose_to_parent_forge.ts added +36
@@ -0,0 +1,36 @@
1 import { FAMILY_WORDS } from "../../../actions/family_words.ts";
2 import { proposeToParentForge } from "../../../actions/upstream.ts";
3 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "propose_to_parent_forge",
7 doc: "`{ repo }`: offer this forge's landed change to its parent as a pull on `child/<name>/…`; the same tip answers with that pull",
8 description:
9 "Offer this forge's landed change to its parent as a pull on `child/<name>/…`",
10 inputSchema: {
11 type: "object",
12 properties: { repo: REPO_ARG },
13 required: ["repo"],
14 },
15 writes: true,
16 async run(ctx, user, args) {
17 const repo = repoArg(ctx, user, args);
18 if (isAnswer(repo)) return repo;
19 const made = await proposeToParentForge(ctx.app, user, repo);
20 if (made.ok)
21 return good({
22 says: made.value.opened
23 ? `Proposed to the parent forge as #${made.value.number}.`
24 : `Already proposed to the parent forge as #${made.value.number}.`,
25 opened: made.value.opened,
26 proposal: made.value,
27 });
28 const e = made.error;
29 if (e === "no_parent_forge") return bad(e, "this forge has no parent forge to propose to");
30 if (e === "nothing_to_propose") return bad(e, FAMILY_WORDS.nothing_to_propose);
31 if (typeof e === "string") return bad(e, "you need write access to propose from here");
32 return e.kind === "unreachable"
33 ? bad("parent_unreachable", "parent unreachable: nothing was proposed")
34 : bad("refused", `the parent forge did not take the proposal: ${e.reason}`);
35 },
36 };
src/http/mcp/tools/pull.ts added +68
@@ -0,0 +1,68 @@
1 import { mayView } from "../../../core/sealed.ts";
2 import { pullProposalOf } from "../../../core/proposal_token.ts";
3 import { resolveOid } from "../../../git/merge.ts";
4 import { getPull, pullDir, pullMergeState } from "../../../core/pulls.ts";
5 import { previewEnv } from "../../../apps/data.ts";
6 import { checkStale } from "../../../checks/stale.ts";
7 import { checkSummary } from "../../../core/runs.ts";
8 import { appView } from "../../../actions/apps.ts";
9 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
10
11 export const tool: Tool = {
12 name: "pull",
13 doc: "`{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment",
14 description:
15 "Read a pull: title, state, head, base; open: `mergeable`, `conflicts`, `proposal`, `checkStale`, `check`.",
16 inputSchema: {
17 type: "object",
18 properties: {
19 repo: REPO_ARG,
20 number: { type: "integer", minimum: 1 },
21 },
22 required: ["repo", "number"],
23 },
24 writes: false,
25 async run(ctx, user, args) {
26 const repo = repoArg(ctx, user, args);
27 if (isAnswer(repo)) return repo;
28 const n = args["number"];
29 const pull =
30 typeof n === "number" && Number.isInteger(n) && n > 0
31 ? getPull(ctx.app.db, repo.id, n)
32 : undefined;
33 if (pull === undefined || !mayView(ctx.app.db, repo, pull, user))
34 return bad("no_such_pull", `no pull #${String(n)}`);
35 const dir = await pullDir(ctx.app.db, ctx.app.reposRoot, repo, pull);
36 const state = await pullMergeState(dir, pull);
37 const check = checkSummary(ctx.app.db, pull.id, pull.headOid);
38 const stale =
39 pull.state === "open" &&
40 pull.mergedAt === null &&
41 checkStale(check.run, await resolveOid(dir, `refs/heads/${pull.baseBranch}`));
42 const answer = good({
43 number: pull.number,
44 title: pull.title,
45 state: pull.mergedAt !== null ? "merged" : pull.state,
46 base: pull.baseBranch,
47 head: pull.headBranch,
48 proposal: pullProposalOf(ctx.app.db, pull.id) ?? null,
49 check: { state: check.state, baseOid: check.run?.baseOid ?? "" },
50 checkStale: stale,
51 ...(state ?? {}),
52 });
53 // What the card needs and the text does not say: where the pull lives
54 // and its preview, only when that environment exists — the pull page's
55 // door, `previewCard`, reads the same `appView`.
56 const view = appView(ctx.app, user, repo);
57 const env = view.ok
58 ? view.value.environments.find((e) => e.env === previewEnv(pull.number))
59 : undefined;
60 return {
61 ...answer,
62 card: {
63 repo: `${repo.ownerName}/${repo.name}`,
64 ...(env === undefined ? {} : { preview: env.url }),
65 },
66 };
67 },
68 };
src/http/mcp/tools/pulls.ts added +51
@@ -0,0 +1,51 @@
1 import { sealedScope } from "../../../core/sealed.ts";
2 import { reviewSummariesFor } from "../../../core/reviews.ts";
3 import { listPulls, pullDir, pullMergeStates } from "../../../core/pulls.ts";
4 import { checkSummary } from "../../../core/runs.ts";
5 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
6
7 export const tool: Tool = {
8 name: "pulls",
9 doc: "`{ repo, state?, limit? }`: a repository's pulls, newest first, one brief line each: `number`, `title`, `author`, `head`, `check` (state), `approvals`, `changesRequested`, `mergeable` (null once closed); `state` open (default) or closed, `limit` 1-50 (default 20); no bodies, no diffs",
10 description:
11 "A repository's pulls, newest first. `state` open (default) or closed; `limit` 1-50 (default 20).",
12 inputSchema: {
13 type: "object",
14 properties: {
15 repo: REPO_ARG,
16 state: { type: "string", enum: ["open", "closed"] },
17 limit: { type: "integer", minimum: 1, maximum: 50 },
18 },
19 required: ["repo"],
20 },
21 writes: false,
22 async run(ctx, user, args) {
23 const repo = repoArg(ctx, user, args);
24 if (isAnswer(repo)) return repo;
25 const state = args["state"] ?? "open";
26 if (state !== "open" && state !== "closed")
27 return bad("invalid", "`state` is open or closed");
28 const limit = args["limit"] ?? 20;
29 if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 50)
30 return bad("invalid", "`limit` is a whole number from 1 to 50");
31 const db = ctx.app.db;
32 const pulls = listPulls(db, repo.id, { state, limit, ...sealedScope(db, repo, user) });
33 const reviews = reviewSummariesFor(db, pulls);
34 const dirs = await Promise.all(
35 pulls.map((p) => pullDir(db, ctx.app.reposRoot, repo, p)),
36 );
37 const merge = await pullMergeStates(pulls.map((pull, i) => ({ dir: dirs[i]!, pull })));
38 return good({
39 pulls: pulls.map((p, i) => ({
40 number: p.number,
41 title: p.title,
42 author: p.authorName,
43 head: p.headBranch,
44 check: checkSummary(db, p.id, p.headOid).state,
45 approvals: reviews.get(p.id)?.approvals ?? 0,
46 changesRequested: reviews.get(p.id)?.changesRequested ?? 0,
47 mergeable: merge[i]?.mergeable ?? null,
48 })),
49 });
50 },
51 };
src/http/mcp/tools/quotas.ts added +43
@@ -0,0 +1,43 @@
1 import { changeQuotas, readQuotas } from "../../../actions/quotas.ts";
2 import { QUOTA_KEYS } from "../../../settings/quotas.ts";
3 import { scopeAllows } from "../../../core/tokens.ts";
4 import { good, bad, type Tool } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "quotas",
8 description:
9 "Per-account repo and app quotas (number or null; unenforced). With `key`, `value` (\"\" clears).",
10 inputSchema: {
11 type: "object",
12 properties: {
13 key: { type: "string", enum: [...QUOTA_KEYS] },
14 value: { type: "string", maxLength: 16 },
15 dryRun: { type: "boolean", description: "false to apply" },
16 },
17 },
18 writes: true,
19 dryByDefault: true,
20 run(ctx, user, args) {
21 const read = readQuotas(ctx.app, user);
22 if (!read.ok) return bad("not_found", "no such tool here");
23 const key = typeof args["key"] === "string" ? args["key"] : undefined;
24 if (key === undefined) return good(read.value);
25 const value = typeof args["value"] === "string" ? args["value"] : "";
26 if (!(QUOTA_KEYS as readonly string[]).includes(key))
27 return bad("no_such_quota", `no quota ${key}`);
28 const now = read.value.quotas[key as (typeof QUOTA_KEYS)[number]];
29 if (args["dryRun"] !== false)
30 return good({
31 says: `Would set ${key} from ${now ?? "no limit"} to ${value || "no limit"}. Send dryRun: false to change it.`,
32 });
33 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
34 return bad("forbidden", "changing the forge's quotas needs an admin-scope token");
35 const changed = changeQuotas(ctx.app, user, { [key]: value });
36 if (!changed.ok)
37 return bad(changed.error, "a quota is a positive whole number, or empty for no limit");
38 return good({
39 says: `${key}: ${now ?? "no limit"} → ${changed.value[key as (typeof QUOTA_KEYS)[number]] ?? "no limit"}.`,
40 quotas: changed.value,
41 });
42 },
43 };
src/http/mcp/tools/read_file.ts added +59
@@ -0,0 +1,59 @@
1 import { repoDir } from "../../../git/repos.ts";
2 import { runGit } from "../../../git/env.ts";
3 import { isSafeRef, isSafeTreePath } from "../../../git/guard.ts";
4 import { blobForRender, listTree } from "../../../git/read.ts";
5 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
6
7 export const tool: Tool = {
8 name: "read_file",
9 doc: "a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents",
10 description:
11 "Read a file or list a directory at a ref (default branch); over 5 MB answers tooLarge.",
12 inputSchema: {
13 type: "object",
14 properties: {
15 repo: REPO_ARG,
16 path: { type: "string", maxLength: 1024 },
17 ref: { type: "string", maxLength: 256 },
18 },
19 required: ["repo", "path"],
20 },
21 writes: false,
22 async run(ctx, user, args) {
23 const repo = repoArg(ctx, user, args);
24 if (isAnswer(repo)) return repo;
25 const rawRef = args["ref"];
26 const rawPath = args["path"];
27 if (
28 (rawRef !== undefined && typeof rawRef !== "string") ||
29 typeof rawPath !== "string"
30 )
31 return bad("invalid", "path and ref are strings");
32 const ref = rawRef || repo.defaultBranch;
33 if (!isSafeRef(ref) || (rawPath !== "" && !isSafeTreePath(rawPath)))
34 return bad("not_found", "no such path at that ref");
35 const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name);
36 const spec = rawPath === "" ? ref : `${ref}:${rawPath}`;
37 const type = await runGit(["cat-file", "-t", spec], { cwd: dir });
38 if (rawPath === "" || (type.ok && type.value === "tree")) {
39 const tree = await listTree(dir, ref, rawPath);
40 if (!tree.ok) return bad("not_found", "no such path at that ref");
41 return good({ type: "dir", ref, path: rawPath, entries: tree.value });
42 }
43 const blob = await blobForRender(dir, ref, rawPath, 5_000_000);
44 if (blob.kind === "missing") return bad("not_found", "no such path at that ref");
45 if (blob.kind === "too_large")
46 return good({ type: "file", tooLarge: true, size: blob.size });
47 const binary = blob.kind === "binary";
48 return good({
49 type: "file",
50 ref,
51 path: rawPath,
52 size: blob.size,
53 encoding: binary ? "base64" : "utf-8",
54 content: binary
55 ? Buffer.from(blob.body).toString("base64")
56 : new TextDecoder().decode(blob.body),
57 });
58 },
59 };
src/http/mcp/tools/repo.ts added +35
@@ -0,0 +1,35 @@
1 import { provenanceFacts } from "../../../core/provenance.ts";
2 import { forkParentFor } from "../../../core/repos.ts";
3 import { audienceFor } from "../../../core/audience.ts";
4 import { upstreamFacts } from "../../../actions/upstream_follow.ts";
5 import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
6
7 export const tool: Tool = {
8 name: "repo",
9 doc: "`{ 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`",
10 description:
11 "One repository you can read, as GET /api/v1/repos/:owner/:name.",
12 inputSchema: {
13 type: "object",
14 properties: { repo: REPO_ARG },
15 required: ["repo"],
16 },
17 writes: false,
18 run(ctx, user, args) {
19 const repo = repoArg(ctx, user, args);
20 if (isAnswer(repo)) return repo;
21 return good({
22 repo: `${repo.ownerName}/${repo.name}`,
23 description: repo.description,
24 visibility: repo.visibility,
25 autonomy: repo.autonomy,
26 audience: audienceFor(ctx.app.db, repo, user),
27 provenance: provenanceFacts(
28 ctx.app.db,
29 repo,
30 forkParentFor(ctx.app.db, repo, user),
31 ),
32 upstream: upstreamFacts(ctx.app.db, repo, user),
33 });
34 },
35 };
src/http/mcp/tools/repo_labels.ts added +63
@@ -0,0 +1,63 @@
1 import { listLabels } from "../../../core/issues.ts";
2 import { scopeAllows } from "../../../core/tokens.ts";
3 import { createLabel, deleteLabel } from "../../../actions/repos.ts";
4 import { LABEL_WORDS } from "../../../actions/label_words.ts";
5 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
6
7 export const tool: Tool = {
8 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 description:
11 "Repo labels: list (default), create, remove (admin token).",
12 inputSchema: {
13 type: "object",
14 properties: {
15 repo: REPO_ARG,
16 action: { type: "string" },
17 name: { type: "string" },
18 color: { type: "string" },
19 id: { type: "string" },
20 },
21 required: ["repo"],
22 },
23 writes: true,
24 // Listing, named or by default, is a read: a read-only token may.
25 readsWhen: (args) => args["action"] === undefined || args["action"] === "list",
26 run(ctx, user, args) {
27 const repo = repoArg(ctx, user, args);
28 if (isAnswer(repo)) return repo;
29 const action = args["action"] ?? "list";
30 if (action === "list") return good({ items: listLabels(ctx.app.db, repo.id) });
31 if (action !== "create" && action !== "remove")
32 return bad("invalid", "action is list, create or remove");
33 // The API asks for an admin token on both routes; so does this.
34 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
35 return bad("forbidden", "changing labels needs an admin token");
36 if (action === "create") {
37 const name = args["name"];
38 const color = args["color"] ?? "6b7280";
39 if (typeof name !== "string" || name.trim() === "" || name.length > 64)
40 return bad("invalid", "name is one to 64 characters");
41 if (typeof color !== "string" || !/^[0-9a-fA-F]{6}$/.test(color))
42 return bad("invalid", "color is six hex digits, like 6b7280");
43 const made = createLabel(ctx.app, user, repo, name, color);
44 if (!made.ok)
45 return bad(
46 made.error,
47 made.error === "taken"
48 ? "A label with that name already exists."
49 : made.error === "invalid"
50 ? LABEL_WORDS.shape
51 : "no such app or repository (owner/name)",
52 );
53 return good({ says: `Created the label ${made.value.name}.`, label: made.value });
54 }
55 const id = args["id"];
56 if (typeof id !== "string" || id === "" || id.length > 64)
57 return bad("invalid", "id is the label's id, from list");
58 const gone = deleteLabel(ctx.app, user, repo, id);
59 return gone.ok
60 ? good({ says: "Removed the label.", removed: id })
61 : bad("not_found", "no such label, or you are not this repository's admin");
62 },
63 };
src/http/mcp/tools/repo_settings.ts added +40
@@ -0,0 +1,40 @@
1 import { updateRepoSettings } from "../../../actions/repos.ts";
2 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "repo_settings",
6 doc: "`{ repo, description?, visibility? }` (admin token): change a repository's description and visibility as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility }`; a visibility other than public or private is `invalid`",
7 description:
8 "Change a repository's description or visibility, as PATCH /api/v1/repos/:owner/:name (admin token).",
9 inputSchema: {
10 type: "object",
11 properties: {
12 repo: REPO_ARG,
13 description: { type: "string", maxLength: 2000 },
14 visibility: { type: "string", enum: ["public", "private"] },
15 },
16 required: ["repo"],
17 },
18 writes: true,
19 needsAdminScope: true,
20 async run(ctx, user, args) {
21 const repo = repoArg(ctx, user, args);
22 if (isAnswer(repo)) return repo;
23 const { description, visibility } = args;
24 if (description !== undefined && typeof description !== "string")
25 return bad("invalid", "description is text");
26 if (visibility !== undefined && visibility !== "public" && visibility !== "private")
27 return bad("invalid", "visibility is public or private");
28 const updated = await updateRepoSettings(ctx.app, user, repo, {
29 ...(visibility !== undefined ? { visibility } : {}),
30 ...(description !== undefined ? { description } : {}),
31 });
32 if (!updated.ok)
33 return bad(updated.error === "not_found" ? "not_found" : "forbidden", updated.error);
34 return good({
35 repo: `${updated.value.ownerName}/${updated.value.name}`,
36 description: updated.value.description,
37 visibility: updated.value.visibility,
38 });
39 },
40 };
src/http/mcp/tools/reports.ts added +69
@@ -0,0 +1,69 @@
1 import { REPORT_ALREADY_DECIDED } from "../../../actions/origin_app_words.ts";
2 import { originError } from "../../../web/pages/thread.ts";
3 import { acceptReport, dismissReport, waitingReports } from "../../../actions/reports.ts";
4 import { type Answer, good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "reports",
8 doc: "`{ 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",
9 description:
10 "Outsiders' reports; `id`+`action`: accept or dismiss.",
11 inputSchema: {
12 type: "object",
13 properties: {
14 repo: REPO_ARG,
15 id: { type: "string", maxLength: 64 },
16 action: { type: "string", enum: ["accept", "dismiss"] },
17 },
18 required: ["repo"],
19 },
20 writes: true,
21 async run(ctx, user, args) {
22 const repo = repoArg(ctx, user, args);
23 if (isAnswer(repo)) return repo;
24 const { id, action } = args;
25 if (action !== undefined && action !== "accept" && action !== "dismiss")
26 return bad("invalid", "action is accept or dismiss");
27 if (id !== undefined && (typeof id !== "string" || id === "" || id.length > 64))
28 return bad("invalid", "id is a report's id, as listed");
29 if ((id === undefined) !== (action === undefined))
30 return bad("invalid", "give both `id` and `action`, or neither to list");
31 const denied = (error: string): Answer =>
32 error === "decided"
33 ? bad("decided", REPORT_ALREADY_DECIDED)
34 : error === "not_found"
35 ? bad("not_found", "no such report here")
36 : bad(
37 error,
38 originError(error) ??
39 (error === "unauthorized" || error === "forbidden"
40 ? "reports are for a person who writes this repository"
41 : "refused"),
42 );
43 if (id === undefined) {
44 const waiting = waitingReports(ctx.app, user, repo);
45 if (!waiting.ok) return denied(waiting.error);
46 return good({
47 says:
48 waiting.value.length === 0
49 ? "No reports are waiting."
50 : `${waiting.value.length} report${waiting.value.length === 1 ? "" : "s"} waiting; their words are an outsider's, unchecked.`,
51 items: waiting.value.map((r) => ({ ...r, from: "outside" })),
52 });
53 }
54 if (action === "dismiss") {
55 const done = dismissReport(ctx.app, user, repo, id as string);
56 if (!done.ok) return denied(done.error);
57 return good({ id, state: "dismissed", says: "Dismissed the report." });
58 }
59 const done = await acceptReport(ctx.app, user, repo, id as string);
60 if (!done.ok) return denied(done.error);
61 return good({
62 id,
63 state: "accepted",
64 number: done.value.number,
65 url: `${ctx.url.origin}/${repo.ownerName}/${repo.name}/issues/${done.value.number}`,
66 says: `Accepted the report as issue #${done.value.number}, marked as from outside.`,
67 });
68 },
69 };
src/http/mcp/tools/returns.ts added +29
@@ -0,0 +1,29 @@
1 import { readReturns } from "../../../actions/returns.ts";
2 import { good, bad, type Tool } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "returns",
6 doc: "`{ repo?, days? }`: what the crew's tokens bought over the last `days` (1–90, default 7): dollars, runs, changes landed, cost per change; `repo` narrows it to one app of yours",
7 description:
8 "What crew tokens bought over `days` (1–90, default 7): dollars, runs, landed, cost/change.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 repo: { type: "string" },
13 days: { type: "integer", minimum: 1, maximum: 90 },
14 },
15 },
16 writes: false,
17 run(ctx, user, args) {
18 const repo = args["repo"];
19 const days = args["days"];
20 const read = readReturns(ctx.app, user, {
21 ...(typeof repo === "string" ? { repo } : {}),
22 ...(typeof days === "number" ? { days } : {}),
23 });
24 if (read.ok) return good(read.value);
25 return read.error === "bad_days"
26 ? bad("invalid_days", "days is a whole number from 1 to 90")
27 : bad("not_found", "no such app of yours (owner/name)");
28 },
29 };
src/http/mcp/tools/review.ts added +64
@@ -0,0 +1,64 @@
1 import { mayView } from "../../../core/sealed.ts";
2 import { submitReview } from "../../../actions/pulls.ts";
3 import { originError } from "../../../web/pages/thread.ts";
4 import { reviewWords } from "../../../actions/review_words.ts";
5 import { getPull } from "../../../core/pulls.ts";
6 import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
7
8 export const tool: Tool = {
9 name: "review",
10 doc: "`{ repo, number, verdict, reason? }`: approve a pull (`approve`) or request changes (`changes`, with a `reason`), as the page does; a writer's or the crew's, and never your own pull",
11 description:
12 "Approve a pull, or request changes with a `reason` (required for changes); your own pull is refused.",
13 inputSchema: {
14 type: "object",
15 properties: {
16 repo: REPO_ARG,
17 number: { type: "integer", minimum: 1 },
18 verdict: { type: "string", enum: ["approve", "changes"] },
19 reason: { type: "string", maxLength: 65536 },
20 },
21 required: ["repo", "number", "verdict"],
22 },
23 writes: true,
24 async run(ctx, user, args) {
25 const repo = repoArg(ctx, user, args);
26 if (isAnswer(repo)) return repo;
27 const verdict = args["verdict"];
28 if (verdict !== "approve" && verdict !== "changes")
29 return bad("invalid", "verdict is approve or changes");
30 const reason = str(args["reason"]);
31 if (verdict === "changes" && reason === "")
32 return bad("invalid", "say what to change (reason)");
33 const n = args["number"];
34 const pull =
35 typeof n === "number" && Number.isInteger(n) && n > 0
36 ? getPull(ctx.app.db, repo.id, n)
37 : undefined;
38 if (pull === undefined || !mayView(ctx.app.db, repo, pull, user))
39 return bad("no_such_pull", `no pull #${String(n)}`);
40 const state = verdict === "approve" ? "approved" : "changes_requested";
41 const sent = await submitReview(ctx.app, user, repo, pull, {
42 state,
43 ...(reason === "" ? {} : { body: reason }),
44 });
45 if (!sent.ok) {
46 const error = sent.error;
47 return bad(
48 "refused",
49 originError(error) ??
50 (error === "self_review"
51 ? reviewWords.selfReview
52 : error === "forbidden"
53 ? reviewWords.forbidden
54 : error === "body_too_long"
55 ? reviewWords.tooLong
56 : "That review could not be recorded."),
57 );
58 }
59 return good({
60 says: verdict === "approve" ? "Approved." : "Changes requested.",
61 url: `/${repo.ownerName}/${repo.name}/pulls/${pull.number}`,
62 });
63 },
64 };
src/http/mcp/tools/run_log.ts added +50
@@ -0,0 +1,50 @@
1 import { runLog } from "../../../actions/runs.ts";
2 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "run_log",
6 doc: "`{ repo, run, tail? }`: a run's log, or its last `tail` lines (1–2000), with the REST route's access: a hidden log or unreadable repository is not found",
7 description:
8 "A run's log, or its last `tail` lines, as GET /api/v1/repos/:owner/:name/runs/:id/log.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 repo: { ...REPO_ARG, maxLength: 140 },
13 run: { type: "string", maxLength: 80 },
14 tail: {
15 type: "integer",
16 minimum: 1,
17 maximum: 2000,
18 },
19 },
20 required: ["repo", "run"],
21 },
22 writes: false,
23 run(ctx, user, args) {
24 const tail = args["tail"];
25 if (
26 typeof args["run"] !== "string" ||
27 args["run"].length === 0 ||
28 args["run"].length > 80 ||
29 (tail !== undefined &&
30 (typeof tail !== "number" ||
31 !Number.isInteger(tail) ||
32 tail < 1 ||
33 tail > 2000))
34 )
35 return bad("invalid", "`run` is a run id; `tail` is 1 to 2000 lines");
36 const repo = repoArg(ctx, user, args);
37 if (isAnswer(repo)) return repo;
38 const found = runLog(ctx.app, user, repo, args["run"]);
39 // A hidden log is not found, as on the REST route.
40 if (!found.ok || found.value.logHidden)
41 return bad("not_found", "no such run here");
42 const all = found.value.lines;
43 const lines = tail === undefined ? all : all.slice(-tail);
44 return good({
45 run: found.value.run.id,
46 lines,
47 truncated: found.value.truncated || lines.length < all.length,
48 });
49 },
50 };
src/http/mcp/tools/runs.ts added +44
@@ -0,0 +1,44 @@
1 import { listRepoRuns } from "../../../actions/runs.ts";
2 import { getIssueById } from "../../../core/issues.ts";
3 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "runs",
7 doc: "`{ repo, limit? }`: the repository's recent runs, newest first (id, kind, state, issue or pull number, started, ended), 1–50, default 20; read as the runs page reads them",
8 description:
9 "A repository's runs, newest first: id, kind, state, the issue or pull number, started and ended.",
10 inputSchema: {
11 type: "object",
12 properties: {
13 repo: REPO_ARG,
14 limit: {
15 type: "integer",
16 minimum: 1,
17 maximum: 50,
18 description: "1–50, default 20.",
19 },
20 },
21 required: ["repo"],
22 },
23 writes: false,
24 run(ctx, user, args) {
25 const limit = args["limit"] ?? 20;
26 if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 50)
27 return bad("bad_limit", "limit is a whole number from 1 to 50");
28 const repo = repoArg(ctx, user, args);
29 if (isAnswer(repo)) return repo;
30 const runs = listRepoRuns(ctx.app, user, repo, { limit });
31 if (!runs.ok) return bad("not_found", "no such app or repository (owner/name)");
32 return good({
33 runs: runs.value.map((r) => ({
34 id: r.id,
35 kind: r.kind,
36 state: r.state,
37 number:
38 r.issueId === null ? null : (getIssueById(ctx.app.db, r.issueId)?.number ?? null),
39 startedAt: r.startedAt,
40 endedAt: r.finishedAt,
41 })),
42 });
43 },
44 };
src/http/mcp/tools/say.ts added +38
@@ -0,0 +1,38 @@
1 import { mayView } from "../../../core/sealed.ts";
2 import { addCommentThrough } from "../../../actions/issues.ts";
3 import { getIssue } from "../../../core/issues.ts";
4 import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
5
6 export const tool: Tool = {
7 name: "say",
8 doc: "speak on an issue or a pull; a working builder hears it as its next step",
9 description:
10 "Say something on an issue or pull. A working builder hears it next; otherwise the next run does.",
11 inputSchema: {
12 type: "object",
13 properties: {
14 repo: REPO_ARG,
15 number: { type: "integer", minimum: 1 },
16 text: { type: "string", maxLength: 8000 },
17 },
18 required: ["repo", "number", "text"],
19 },
20 writes: true,
21 async run(ctx, user, args) {
22 const repo = repoArg(ctx, user, args);
23 if (isAnswer(repo)) return repo;
24 const issue = getIssue(ctx.app.db, repo.id, Number(args["number"]));
25 if (issue === undefined || !mayView(ctx.app.db, repo, issue, user))
26 return bad("not_found", "no issue or pull by that number");
27 const said = await addCommentThrough(
28 ctx.app,
29 user,
30 repo,
31 issue,
32 str(args["text"]),
33 );
34 return said.ok
35 ? good({ says: `Said on #${issue.number}.` })
36 : bad("refused", "that could not be said here");
37 },
38 };

34 more files not shown.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs