Public: anyone can read this; the crew can change it.
MCP `person`: a person's public profile and the repositories the caller may see #4826
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4805-mcp-person-a-person-s-public-profile-and → main 9 commits fork 5 threads · 5 unresolved
39 files
changed +196
−36
docs/MCP.md +2 −1
| @@ -99,6 +99,7 @@ for a tunnel and a forge that sleeps. | ||
| 99 | 99 | | `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 | |
| 100 | 100 | | `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 | |
| 101 | 101 | | `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 | |
| 102 | | `person` | `{ username, limit?, cursor? }`: a person's public profile and the repositories you may see, as `GET /api/v1/users/:username` and its `/repos`; a private one you cannot read is never listed | | | |
| 102 | 103 | | `plan` | `{ app, issues }`: open up to 12 linked issues, all or none (see below) | yes | |
| 103 | 104 | | `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 | | |
| 104 | 105 | | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | | |
| @@ -111,7 +112,7 @@ for a tunnel and a forge that sleeps. | ||
| 111 | 112 | | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents | | |
| 112 | 113 | | `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` | | |
| 113 | 114 | | `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 | |
| 114 | | `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 | | |
| 115 | | `repo_settings` | `{ repo, description?, visibility? }` (admin): 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 | | |
| 115 | 116 | | `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 | |
| 116 | 117 | | `restore_drill` | no input: restore the newest snapshot as a drill and answer `{ verdict, failedStep?, says }`, worded as the Backups page's button; no store, a failure or a red drill is an answer, not an error; a forge admin's, with an admin token | yes | |
| 117 | 118 | | `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 | | |
src/http/mcp/tools/app_members.ts +1 −1
| @@ -7,7 +7,7 @@ export const tool: Tool = { | ||
| 7 | 7 | name: "app_members", |
| 8 | 8 | 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", |
| 9 | 9 | description: |
| 10 | "The people a private app names, who may enter it and its previews. With `members`, plans.", | |
| 10 | "People a private app names, who may enter it and its previews. With `members`, plans.", | |
| 11 | 11 | inputSchema: { |
| 12 | 12 | type: "object", |
| 13 | 13 | properties: { |
src/http/mcp/tools/asks_decide.ts +1 −1
| @@ -4,7 +4,7 @@ import { good, bad, type Tool } from "../shared.ts"; | ||
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "asks_decide", |
| 6 | 6 | description: |
| 7 | "Let the decider answer a hand-over (`item`: `parked:<id>`); only a sure set_aside clears it.", | |
| 7 | "Let the decider answer a hand-over (`item`: `parked:<id>`); only a sure set_aside clears.", | |
| 8 | 8 | inputSchema: { |
| 9 | 9 | type: "object", |
| 10 | 10 | properties: { item: { type: "string", maxLength: 80 } }, |
src/http/mcp/tools/asks_proposal.ts +1 −1
| @@ -4,7 +4,7 @@ import { good, bad, type Tool } from "../shared.ts"; | ||
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "asks_proposal", |
| 6 | 6 | description: |
| 7 | "Act on a wave proposal (`asks` row): `approve`, `redirect` (`words`), `later` (`again`). Owner.", | |
| 7 | "Act on a wave proposal: `approve`, `redirect` (`words`), `later` (`again`). Owner.", | |
| 8 | 8 | inputSchema: { |
| 9 | 9 | type: "object", |
| 10 | 10 | properties: { |
src/http/mcp/tools/attach_look.ts +1 −1
| @@ -7,7 +7,7 @@ export const tool: Tool = { | ||
| 7 | 7 | name: "attach_look", |
| 8 | 8 | doc: "`{ app, pull, width, says, image, dryRun? }`: attach a look to an open pull: a PNG or JPEG (at most 5 MB, base64 in `image`), `width` phone or desktop, one line in `says`; planned first (`dryRun` is true unless `false`)", |
| 9 | 9 | description: |
| 10 | "Attach a PNG or JPEG (base64 `image`, max 5 MB) to an open pull: `phone`/`desktop`, `says`.", | |
| 10 | "Attach a PNG/JPEG (base64 `image`, max 5 MB) to an open pull: `phone`/`desktop`, `says`.", | |
| 11 | 11 | inputSchema: { |
| 12 | 12 | type: "object", |
| 13 | 13 | properties: { |
src/http/mcp/tools/branches.ts +1 −1
| @@ -11,7 +11,7 @@ export const tool: Tool = { | ||
| 11 | 11 | name: "branches", |
| 12 | 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 | 13 | description: |
| 14 | "A repository's branches; `name`, `action` create (from `from`) or delete one, not the default.", | |
| 14 | "A repo's branches; `name`, `action` create (from `from`) or delete one, not the default.", | |
| 15 | 15 | inputSchema: { |
| 16 | 16 | type: "object", |
| 17 | 17 | properties: { |
src/http/mcp/tools/capacity.ts +1 −1
| @@ -7,7 +7,7 @@ export const tool: Tool = { | ||
| 7 | 7 | name: "capacity", |
| 8 | 8 | doc: "`{ key?, value?, dryRun? }` (forge admin): the forge's capacity and guards, live (crew and checks concurrency, check shards, cell egress, the new-account guard, the sign-in edge, queue depth, child forges; only the first six are settable by `key`); with `key` and `value` (`\"\"` restores the default), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token", |
| 9 | 9 | description: |
| 10 | "The forge's capacity and guards, live. With `key` and `value` (\"\" restores default), plans.", | |
| 10 | "Forge capacity and guards, live. With `key` and `value` (\"\" restores default), plans.", | |
| 11 | 11 | inputSchema: { |
| 12 | 12 | type: "object", |
| 13 | 13 | properties: { |
src/http/mcp/tools/claim_link.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "claim_link", |
| 6 | 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 | 7 | description: |
| 8 | "A new claim link for an unclaimed child forge, only with `reveal: true`; the old one stops.", | |
| 8 | "A new claim link for an unclaimed child forge, only with `reveal: true`; the old stops.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
src/http/mcp/tools/contribute.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "contribute", |
| 6 | 6 | doc: "for any arriving agent: what this forge is, its bottleneck, open “help wanted” issues, how to propose", |
| 7 | 7 | description: |
| 8 | "For an arriving agent: what this forge is, its bottleneck, help-wanted issues, how to propose.", | |
| 8 | "For a new agent: what this forge is, its bottleneck, help-wanted issues, how to propose.", | |
| 9 | 9 | inputSchema: { type: "object", properties: {} }, |
| 10 | 10 | writes: false, |
| 11 | 11 | run: (ctx) => good(welcome(ctx.app)), |
src/http/mcp/tools/family.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "family", |
| 6 | 6 | doc: "the forge that sent this one and, for a site admin, those it sent, with state and version", |
| 7 | 7 | description: |
| 8 | "The forge that sent this one and, for a site admin, those it sent, with state and version.", | |
| 8 | "The forge that sent this one and, for a site admin, those it sent, with state, version.", | |
| 9 | 9 | inputSchema: { type: "object", properties: {} }, |
| 10 | 10 | writes: false, |
| 11 | 11 | async run(ctx, user) { |
src/http/mcp/tools/feed.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "feed", |
| 6 | 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`; `quietRuns` counts agent runs of the last 24 h that pushed, opened or said nothing, which the timeline leaves out", |
| 7 | 7 | description: |
| 8 | "Your ranked home timeline across visible apps; `cursor` pages; saved filter (`feed_chips`) applies.", | |
| 8 | "Your ranked home timeline; `cursor` pages; saved filter (`feed_chips`) applies.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
src/http/mcp/tools/feed_chips.ts +1 −1
| @@ -6,7 +6,7 @@ export const tool: Tool = { | ||
| 6 | 6 | name: "feed_chips", |
| 7 | 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 | 8 | description: |
| 9 | "Feed chips you switched off in `feed` and `asks`. With `kind`, `value` and `hidden`, sets one.", | |
| 9 | "Feed chips you switched off. With `kind`, `value` and `hidden`, sets one.", | |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
src/http/mcp/tools/forge_proposals.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "forge_proposals", |
| 6 | 6 | doc: "`{ repo }`: what this forge proposed to its parent, newest first; `reachable` is false if the parent did not answer", |
| 7 | 7 | description: |
| 8 | "What this forge proposed to its parent, newest first; `reachable` false if it is silent.", | |
| 8 | "What this forge proposed to its parent; `reachable` false if it is silent.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { repo: REPO_ARG }, |
src/http/mcp/tools/forges.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "forges", |
| 6 | 6 | doc: "an admin's list of the forges sent from here: name, address, whom for, apps, claimed or not, last activity", |
| 7 | 7 | description: |
| 8 | "The forges sent from here (admin): name, address, apps, claimed, spendTodayUsd, ceilingUsd.", | |
| 8 | "Forges sent from here (admin): name, address, apps, claimed, spendTodayUsd, ceilingUsd.", | |
| 9 | 9 | inputSchema: { type: "object", properties: {} }, |
| 10 | 10 | writes: false, |
| 11 | 11 | async run(ctx, user) { |
src/http/mcp/tools/hold.ts +1 −1
| @@ -6,7 +6,7 @@ export const tool: Tool = { | ||
| 6 | 6 | name: "hold", |
| 7 | 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 | 8 | description: |
| 9 | "Hold a pull (`why`) or release it (`release: true`): held, nothing reviews or lands it. Writers.", | |
| 9 | "Hold a pull (`why`) or release it (`release: true`); held, none reviews or lands it. Writers.", | |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
src/http/mcp/tools/index.ts +1
| @@ -79,6 +79,7 @@ export { tool as origin_apps } from "./origin_apps.ts"; | ||
| 79 | 79 | export { tool as origins } from "./origins.ts"; |
| 80 | 80 | export { tool as outsiders } from "./outsiders.ts"; |
| 81 | 81 | export { tool as parent_unlink } from "./parent_unlink.ts"; |
| 82 | export { tool as person } from "./person.ts"; | |
| 82 | 83 | export { tool as plan } from "./plan.ts"; |
| 83 | 84 | export { tool as preview } from "./preview.ts"; |
| 84 | 85 | export { tool as probe } from "./probe.ts"; |
src/http/mcp/tools/migrate.ts +1 −1
| @@ -8,7 +8,7 @@ export const tool: Tool = { | ||
| 8 | 8 | name: "migrate", |
| 9 | 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 | 10 | description: |
| 11 | "Bring a repository in by public `url` (`name`, `crew`), history and all. With `job`, its state.", | |
| 11 | "Bring a repo in by public `url` (`name`, `crew`), history and all. With `job`, its state.", | |
| 12 | 12 | inputSchema: { |
| 13 | 13 | type: "object", |
| 14 | 14 | properties: { |
src/http/mcp/tools/milestones.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "milestones", |
| 6 | 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 | 7 | description: |
| 8 | "How far each thing asked for is. With `app`: top-level limbs, done/total, building, recent merges.", | |
| 8 | "How far each ask is. With `app`: top-level limbs, done/total, building, recent merges.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
src/http/mcp/tools/mint_invite.ts +1 −1
| @@ -7,7 +7,7 @@ export const tool: Tool = { | ||
| 7 | 7 | name: "mint_invite", |
| 8 | 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 | 9 | description: |
| 10 | "Mint a single-use invite link, shown once, expiring in `days` (1–30, default 7). Person admins.", | |
| 10 | "Mint a one-use invite link, shown once, expires in `days` (1–30, default 7). Person admins.", | |
| 11 | 11 | inputSchema: { |
| 12 | 12 | type: "object", |
| 13 | 13 | properties: { |
src/http/mcp/tools/model_grant.ts +1 −1
| @@ -6,7 +6,7 @@ import { good, bad, type Tool, mayBoards } from "../shared.ts"; | ||
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | name: "model_grant", |
| 8 | 8 | description: |
| 9 | "Give a person a capped monthly model grant (`usdPerMonth`, 0 takes it back). Person admins.", | |
| 9 | "Give a person a monthly model grant (`usdPerMonth`, 0 takes it back). Person admins.", | |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
src/http/mcp/tools/new_app.ts +1 −1
| @@ -12,7 +12,7 @@ export const tool: Tool = { | ||
| 12 | 12 | name: "new_app", |
| 13 | 13 | 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)", |
| 14 | 14 | description: |
| 15 | "Make an app from a sentence: a repository, production, and with `description` a first issue.", | |
| 15 | "Make an app from a sentence: a repo, production, and with `description` a first issue.", | |
| 16 | 16 | inputSchema: { |
| 17 | 17 | type: "object", |
| 18 | 18 | properties: { |
src/http/mcp/tools/now.ts +1 −1
| @@ -15,7 +15,7 @@ export const tool: Tool = { | ||
| 15 | 15 | name: "now", |
| 16 | 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 | 17 | description: |
| 18 | "What the crew is doing across your apps: who, on what, since when, cost; what awaits review.", | |
| 18 | "What the crew does across your apps: who, on what, since when, cost; what awaits review.", | |
| 19 | 19 | inputSchema: { type: "object", properties: {} }, |
| 20 | 20 | writes: false, |
| 21 | 21 | run(ctx, user) { |
src/http/mcp/tools/outsiders.ts +1 −1
| @@ -7,7 +7,7 @@ export const tool: Tool = { | ||
| 7 | 7 | name: "outsiders", |
| 8 | 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 | 9 | description: |
| 10 | "Whether people who cannot write an app may direct its crew (off by default). No `on` reads it.", | |
| 10 | "Whether non-writers may direct an app's crew (off by default). No `on` reads it.", | |
| 11 | 11 | inputSchema: { |
| 12 | 12 | type: "object", |
| 13 | 13 | properties: { |
src/http/mcp/tools/person.ts added +41
| @@ -0,0 +1,41 @@ | ||
| 1 | import { pageLimit } from "../../../core/issues.ts"; | |
| 2 | import { listReposByOwner } from "../../../core/repos.ts"; | |
| 3 | import { getUserByName, hiddenFrom } from "../../../core/users.ts"; | |
| 4 | import { paged, publicProfile } from "../../respond.ts"; | |
| 5 | import { good, bad, type Tool } from "../shared.ts"; | |
| 6 | ||
| 7 | export const tool: Tool = { | |
| 8 | name: "person", | |
| 9 | doc: "`{ username, limit?, cursor? }`: a person's public profile and the repositories you may see, as `GET /api/v1/users/:username` and its `/repos`; a private one you cannot read is never listed", | |
| 10 | description: "A person's profile and repos you may see.", | |
| 11 | inputSchema: { | |
| 12 | type: "object", | |
| 13 | properties: { | |
| 14 | username: { type: "string" }, | |
| 15 | limit: { type: "integer" }, | |
| 16 | cursor: { type: "string" }, | |
| 17 | }, | |
| 18 | required: ["username"], | |
| 19 | }, | |
| 20 | writes: false, | |
| 21 | run(ctx, user, args) { | |
| 22 | const { username, limit, cursor } = args; | |
| 23 | if (typeof username !== "string" || username === "" || username.length > 100) | |
| 24 | return bad("invalid", "`username` is 1 to 100 characters"); | |
| 25 | if (limit !== undefined && (typeof limit !== "number" || !Number.isFinite(limit))) | |
| 26 | return bad("invalid", "`limit` is a number"); | |
| 27 | if (cursor !== undefined && typeof cursor !== "string") | |
| 28 | return bad("invalid", "`cursor` is the one a previous answer gave"); | |
| 29 | const person = getUserByName(ctx.app.db, username); | |
| 30 | if (!person || hiddenFrom(person, user)) return bad("not_found", "no such person"); | |
| 31 | const size = pageLimit(limit as number | undefined); | |
| 32 | const items = listReposByOwner(ctx.app.db, person.username, user, { | |
| 33 | limit: size, | |
| 34 | ...(cursor ? { cursor } : {}), | |
| 35 | }); | |
| 36 | return good({ | |
| 37 | ...publicProfile(person), | |
| 38 | repos: paged(items, size, (r) => r.id), | |
| 39 | }); | |
| 40 | }, | |
| 41 | }; |
src/http/mcp/tools/proposal_tokens.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "proposal_tokens", |
| 6 | 6 | doc: "`{ repo, verb, child?, origin?, childRepo?, id? }`: give a child forge a propose-only credential: `mint` one (`child`, `origin`), `list` them, or `revoke` one by `id`; writers only", |
| 7 | 7 | description: |
| 8 | "A child's propose-only credential: `mint` (`child`, `origin`), `list`, `revoke` (`id`). Writers.", | |
| 8 | "A child's propose-only token: `mint` (`child`, `origin`), `list`, `revoke` (`id`). Writers.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
src/http/mcp/tools/pull.ts +1 −1
| @@ -13,7 +13,7 @@ export const tool: Tool = { | ||
| 13 | 13 | name: "pull", |
| 14 | 14 | 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 `next: { who, why }` (open only; who acts next: builder, reviewer, queue or person), 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", |
| 15 | 15 | description: |
| 16 | "A pull: title, state, head, base; open: `mergeable`, `conflicts`, `proposal`, `checkStale`, `check`.", | |
| 16 | "A pull: title, state, head, base; open: `mergeable`, `conflicts`, `checkStale`, `check`.", | |
| 17 | 17 | inputSchema: { |
| 18 | 18 | type: "object", |
| 19 | 19 | properties: { |
src/http/mcp/tools/repo_settings.ts +2 −2
| @@ -3,9 +3,9 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts" | ||
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| 5 | 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`", | |
| 6 | doc: "`{ repo, description?, visibility? }` (admin): 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 | 7 | description: |
| 8 | "Change a repository's description or visibility, as PATCH /api/v1/repos/:owner/:name (admin token).", | |
| 8 | "Change a repo's description or visibility: PATCH /api/v1/repos/:owner/:name (admin).", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
src/http/mcp/tools/returns.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "returns", |
| 6 | 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 | 7 | description: |
| 8 | "What crew tokens bought over `days` (1–90, default 7): dollars, runs, landed, cost/change.", | |
| 8 | "What crew tokens bought in `days` (1–90, default 7): dollars, runs, landed, cost/change.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
src/http/mcp/tools/say.ts +1 −1
| @@ -7,7 +7,7 @@ export const tool: Tool = { | ||
| 7 | 7 | name: "say", |
| 8 | 8 | doc: "speak on an issue or a pull; a working builder hears it as its next step", |
| 9 | 9 | description: |
| 10 | "Say something on an issue or pull. A working builder hears it next; otherwise the next run does.", | |
| 10 | "Say something on an issue or pull. A working builder hears it next, else the next run.", | |
| 11 | 11 | inputSchema: { |
| 12 | 12 | type: "object", |
| 13 | 13 | properties: { |
src/http/mcp/tools/seal.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "seal", |
| 6 | 6 | doc: "`{ 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", |
| 7 | 7 | description: |
| 8 | "Seal or unseal an issue or pull: a sealed one is seen only by the app's writers and crew.", | |
| 8 | "Seal or unseal an issue or pull: a sealed one is seen only by writers and crew.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
src/http/mcp/tools/sever.ts +1 −1
| @@ -6,7 +6,7 @@ export const tool: Tool = { | ||
| 6 | 6 | name: "sever", |
| 7 | 7 | doc: "`{ repo }`: cut a spawned forge's link to the forge its code came from; its `forge` repository's owner only; deletes nothing", |
| 8 | 8 | description: |
| 9 | "Cut a spawned forge's link to its parent (its `forge` repository's owner only). Deletes nothing.", | |
| 9 | "Unlink a spawned forge from its parent (owner of its `forge` repo only). Deletes nothing.", | |
outdated
Low: the trim dropped "Deletes nothing.", and that is the line an agent needs before calling an irreversible-sounding tool. |
||
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { repo: REPO_ARG }, |
src/http/mcp/tools/spawn.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "spawn", |
| 6 | 6 | doc: "`{ 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", |
| 7 | 7 | description: |
| 8 | "Spawn a child forge with the named apps and the crew's briefs. Plans first.", | |
| 8 | "Spawn a child forge with named apps and the crew's briefs. Plans first.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
src/http/mcp/tools/spend.ts +1 −1
| @@ -4,7 +4,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts" | ||
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "spend", |
| 6 | 6 | description: |
| 7 | "Report what your session spent outside the forge's runs: who, model, tokens or dollars, issue.", | |
| 7 | "Report what your session spent outside forge runs: who, model, tokens or dollars, issue.", | |
| 8 | 8 | inputSchema: { |
| 9 | 9 | type: "object", |
| 10 | 10 | properties: { |
src/http/mcp/tools/stop.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "stop", |
| 6 | 6 | doc: "stop one run by its id from `now`", |
| 7 | 7 | description: |
| 8 | "Stop one of the crew's runs, by its id from `now`. Its pushes stay; nothing is re-queued.", | |
| 8 | "Stop a crew run by its id from `now`. Its pushes stay; nothing is re-queued.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { repo: REPO_ARG, run: { type: "string" } }, |
src/http/mcp/tools/usage.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "usage", |
| 6 | 6 | doc: "`{ 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", |
| 7 | 7 | description: |
| 8 | "What the crew's runs on an app spent over `days` (1–90, default 7), by agent and issue. Writers.", | |
| 8 | "What crew runs on an app spent in `days` (1–90, default 7), by agent, issue. Writers.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
src/http/mcp/tools/variant.ts +1 −1
| @@ -8,7 +8,7 @@ export const tool: Tool = { | ||
| 8 | 8 | name: "variant", |
| 9 | 9 | doc: "`{ repo, number, answer, reason? }`: like or dislike one pull among its variants (`answer` like or dislike); a later vote replaces the earlier; a dislike closes the pull with `reason`", |
| 10 | 10 | description: |
| 11 | "Like or dislike a pull among its variants; a later vote replaces it; dislike closes it (`reason`).", | |
| 11 | "Like or dislike a pull among its variants; a later vote replaces; dislike closes it.", | |
| 12 | 12 | inputSchema: { |
| 13 | 13 | type: "object", |
| 14 | 14 | properties: { |
test/mcp-person.test.ts added +116
| @@ -0,0 +1,116 @@ | ||
| 1 | /** | |
| 2 | * The `person` tool answers what /api/v1/users/:username and its /repos answer | |
| 3 | * the same caller: a stranger sees public repositories only, a team member the | |
| 4 | * private one they were given, and an unknown name is not found. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; | |
| 13 | import { createToken } from "../src/core/tokens.ts"; | |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | ||
| 17 | describe("MCP person", () => { | |
| 18 | let dataDir: string; | |
| 19 | let app: App; | |
| 20 | let servers: Servers; | |
| 21 | let aliceRo: string; | |
| 22 | let bobRo: string; | |
| 23 | let carolRo: string; | |
| 24 | let id = 1; | |
| 25 | ||
| 26 | const tool = async (token: string, args: unknown) => { | |
| 27 | const res = await fetch(`${servers.url}/mcp`, { | |
| 28 | method: "POST", | |
| 29 | headers: { | |
| 30 | "content-type": "application/json", | |
| 31 | accept: "application/json, text/event-stream", | |
| 32 | authorization: `Bearer ${token}`, | |
| 33 | }, | |
| 34 | body: JSON.stringify({ | |
| 35 | jsonrpc: "2.0", | |
| 36 | id: id++, | |
| 37 | method: "tools/call", | |
| 38 | params: { name: "person", arguments: args }, | |
| 39 | }), | |
| 40 | }); | |
| 41 | const body: any = await res.json(); | |
| 42 | const text: string = body.result.content[0].text; | |
| 43 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 44 | }; | |
| 45 | ||
| 46 | beforeAll(async () => { | |
| 47 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-person-")); | |
| 48 | app = createApp({ dataDir, port: 0 }); | |
| 49 | servers = await serve(app); | |
| 50 | const mk = async (username: string, first = false) => { | |
| 51 | const u = await createUser(app.db, { | |
| 52 | origin: "seed", | |
| 53 | trusted: true, | |
| 54 | username, | |
| 55 | email: `${username}@forge.local`, | |
| 56 | password: "a-good-passphrase", | |
| 57 | adminIfFirst: first, | |
| 58 | }); | |
| 59 | if (!u.ok) throw new Error(u.error); | |
| 60 | return u.value; | |
| 61 | }; | |
| 62 | const alice = await mk("alice", true); | |
| 63 | const bob = await mk("bob"); | |
| 64 | const carol = await mk("carol"); | |
| 65 | aliceRo = createToken(app.db, alice.id, "ro", "ro").token; | |
| 66 | bobRo = createToken(app.db, bob.id, "ro", "ro").token; | |
| 67 | carolRo = createToken(app.db, carol.id, "ro", "ro").token; | |
| 68 | const dave = await mk("dave"); | |
| 69 | app.db.query("UPDATE user SET suspended_at = ? WHERE id = ?").run(new Date().toISOString(), dave.id); | |
| 70 | const org = createOrg(app.db, "guild", alice.id); | |
| 71 | if (!org.ok) throw new Error(org.error); | |
| 72 | const team = createTeam(app.db, org.value.id, "makers", "read"); | |
| 73 | if (!team.ok) throw new Error(team.error); | |
| 74 | addTeamMember(app.db, team.value, bob.id); | |
| 75 | for (const [name, visibility] of [["open", "public"], ["hidden", "private"]] as const) { | |
| 76 | const made = await createRepo(app, alice, { name, visibility, owner: "guild" }); | |
| 77 | if (!made.ok) throw new Error(String(made.error)); | |
| 78 | } | |
| 79 | }, 30_000); | |
| 80 | ||
| 81 | afterAll(async () => { | |
| 82 | servers.stop(); | |
| 83 | app.close(); | |
| 84 | await rm(dataDir, { recursive: true, force: true }); | |
| 85 | }); | |
| 86 | ||
| 87 | test("a stranger sees the profile and only public repositories", async () => { | |
| 88 | const r = await tool(carolRo, { username: "guild" }); | |
| 89 | expect(r.value.username).toBe("guild"); | |
| 90 | expect(r.value.repos.items.map((x: any) => x.name)).toEqual(["open"]); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a member also sees the private repository they were given", async () => { | |
| 94 | const r = await tool(bobRo, { username: "guild" }); | |
| 95 | expect(r.value.repos.items.map((x: any) => x.name).sort()).toEqual(["hidden", "open"]); | |
| 96 | }); | |
| 97 | ||
| 98 | test("limit pages with a cursor", async () => { | |
| 99 | const first = await tool(bobRo, { username: "guild", limit: 1 }); | |
| 100 | expect(first.value.repos.items.length).toBe(1); | |
| 101 | const next = await tool(bobRo, { username: "guild", limit: 1, cursor: first.value.repos.next_cursor }); | |
| 102 | expect(next.value.repos.items.length).toBe(1); | |
outdated
Add the hidden-account case beside this test (suspend a user, then check the call as a non-admin and as alice the admin), so the |
||
| 103 | expect(next.value.repos.items[0].name).not.toBe(first.value.repos.items[0].name); | |
| 104 | }); | |
| 105 | ||
| 106 | test("a suspended person is not found to a non-admin, and still found by an admin", async () => { | |
| 107 | expect((await tool(carolRo, { username: "dave" })).error).toContain("no such person"); | |
| 108 | expect((await tool(aliceRo, { username: "dave" })).value.username).toBe("dave"); | |
| 109 | }); | |
| 110 | ||
| 111 | test("an unknown name is not found, and bad arguments are refused", async () => { | |
| 112 | expect((await tool(carolRo, { username: "nobody" })).error).toContain("no such person"); | |
| 113 | expect((await tool(carolRo, {})).error).toBeDefined(); | |
| 114 | expect((await tool(carolRo, { username: 5 })).error).toBeDefined(); | |
| 115 | }); | |
| 116 | }); |
test/mcp.test.ts +1
| @@ -251,6 +251,7 @@ describe("the forge over MCP", () => { | ||
| 251 | 251 | "app_members", |
| 252 | 252 | "egress_grants", |
| 253 | 253 | "explore", |
| 254 | "person", | |
| 254 | 255 | "search", |
| 255 | 256 | "feed", |
| 256 | 257 | "feed_chips", |
new above
offline
Low: this trim removes
checkStalefrom the description, but the tool still returns it (line 50). An agent readingtools/listloses the one hint that the check may be against an old base. If the budget allows, trim elsewhere and keep the field name, for example "A pull: title, state, head, base; open:mergeable,conflicts,checkStale,check."