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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/5924-drain-the-199-open-pulls-split-src-http → main 7 commits fork 1 thread · 1 unresolved
134 files
changed +5823
−5114
CLAUDE.md +5
| @@ -161,6 +161,8 @@ src/ | ||
| 161 | 161 | scope floors, pagination, `securityFloor` — the headers every |
| 162 | 162 | response leaves with), api (every /api/v1 route), server |
| 163 | 163 | (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) | |
| 164 | 166 | design/ shared design tokens: themes (palettes both surfaces render) |
| 165 | 167 | origins/ other forges as one Provider interface: discover (version-endpoint |
| 166 | 168 | 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. | ||
| 254 | 256 | Describe it in OpenAPI as one file, `contract/openapi/routes/<operationId>.ts`, |
| 255 | 257 | exporting `route`; then `bun scripts/openapi-routes.ts` writes its line in the |
| 256 | 258 | 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. | |
| 257 | 262 | - **Add a TUI screen:** one file under `tui/screens/`, exporting |
| 258 | 263 | `(f: Session, ctx: Ctx, …) => Screen`. Compose `ui/widgets`; take navigation |
| 259 | 264 | 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. | ||
| 25 | 25 | |
| 26 | 26 | ## Tools |
| 27 | 27 | |
| 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` | | | |
| 92 | 66 | | `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`. | |
| 129 | 144 | |
| 130 | 145 | ### Room for a new tool |
| 131 | 146 | |
| @@ -207,7 +222,7 @@ can call `apps` and is refused `tell`. | ||
| 207 | 222 | `src/http/oauth.ts`, `src/core/oauth.ts`, `src/web/pages/oauth.ts`, |
| 208 | 223 | `test/oauth-flow.test.ts`. |
| 209 | 224 | |
| 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`. | |
| 211 | 226 | |
| 212 | 227 | ## `plan` |
| 213 | 228 |
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 filesrc/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/claim_link.ts added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | import { reissueDoor } from "../../../spawn/door.ts"; | |
| 2 | import { good, bad, type Tool, str, REVEAL_ARG, LINK_KEPT, LINK_REVEALED } from "../shared.ts"; | |
| 3 | ||
| 4 | export const tool: Tool = { | |
| 5 | name: "claim_link", | |
| 6 | doc: "`{ name }`: a new claim link for a child you spawned that nobody has claimed; the old one stops working. An admin's", | |
| 7 | description: | |
| 8 | "A new claim link for an unclaimed child forge, only with `reveal: true`; the old one stops at once.", | |
| 9 | inputSchema: { | |
| 10 | type: "object", | |
| 11 | properties: { | |
| 12 | name: { type: "string" }, | |
| 13 | reveal: REVEAL_ARG, | |
| 14 | }, | |
| 15 | required: ["name"], | |
| 16 | }, | |
| 17 | writes: true, | |
| 18 | async run(ctx, user, args) { | |
| 19 | if (args["reveal"] !== true) | |
| 20 | return good({ says: `Nothing changed. ${LINK_KEPT}`, reissued: false }); | |
| 21 | const door = await reissueDoor(ctx, user, str(args["name"])); | |
| 22 | if (door.status >= 400) | |
| 23 | return bad(String(door.body["error"]), String(door.body["message"])); | |
| 24 | return good({ says: LINK_REVEALED, ...door.body }); | |
| 25 | }, | |
| 26 | }; |
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/parent_unlink.ts added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | import { scopeAllows } from "../../../core/tokens.ts"; | |
| 2 | import { LAST_WAY_IN, parentIssuerOf, unlinkParent } from "../../../core/federation.ts"; | |
| 3 | import { good, bad, type Tool } from "../shared.ts"; | |
| 4 | ||
| 5 | export const tool: Tool = { | |
| 6 | name: "parent_unlink", | |
| 7 | doc: "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", | |
| 8 | description: "Unlink parent-forge sign-in.", | |
| 9 | inputSchema: { type: "object", properties: {} }, | |
| 10 | writes: true, | |
| 11 | run(ctx, user) { | |
| 12 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "unlinking needs an admin token"); | |
| 13 | const issuer = parentIssuerOf(ctx.app.config); | |
| 14 | const done = issuer === undefined ? undefined : unlinkParent(ctx.app.db, user.id, issuer); | |
| 15 | if (done === undefined || (done.ok && done.value === "none")) return good({ says: "Nothing to unlink." }); | |
| 16 | if (!done.ok) return bad("last_way_in", LAST_WAY_IN); | |
| 17 | ctx.app.bus.append({ kind: "account.parent_unlinked", actorId: user.id }); | |
| 18 | return good({ says: "Unlinked." }); | |
| 19 | }, | |
| 20 | }; |
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.
new above
offline
Non-blocking: because
docis optional, a new tool file can land with no row in docs/MCP.md, and nothing goes red. 21 tools have nodoctoday (asks*, capacity, flow, quotas, …), the same as on main, so this is not a regression. A follow-up could makedocrequired and give those 21 rows, so the generated table stays complete by construction.