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

Merged opened this

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

Merged

scout/forge:scout/4805-mcp-person-a-person-s-public-profile-and → main 9 commits fork 5 threads · 5 unresolved

  • Merged. scout/4805-mcp-person-a-person-s-public-profile-and was merged into main as 732cc0c.

Review

Sign in to review this change.
39 files changed +196 −36
docs/MCP.md +2 −1
@@ -99,6 +99,7 @@ for a tunnel and a forge that sleeps.
9999 | `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 |
100100 | `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 |
101101 | `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 | |
102103 | `plan` | `{ app, issues }`: open up to 12 linked issues, all or none (see below) | yes |
103104 | `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 | |
104105 | `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.
111112 | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents | |
112113 | `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` | |
113114 | `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 |
115116 | `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 |
116117 | `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 |
117118 | `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 = {
77 name: "app_members",
88 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",
99 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.",
1111 inputSchema: {
1212 type: "object",
1313 properties: {
src/http/mcp/tools/asks_decide.ts +1 −1
@@ -4,7 +4,7 @@ import { good, bad, type Tool } from "../shared.ts";
44 export const tool: Tool = {
55 name: "asks_decide",
66 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.",
88 inputSchema: {
99 type: "object",
1010 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";
44 export const tool: Tool = {
55 name: "asks_proposal",
66 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.",
88 inputSchema: {
99 type: "object",
1010 properties: {
src/http/mcp/tools/attach_look.ts +1 −1
@@ -7,7 +7,7 @@ export const tool: Tool = {
77 name: "attach_look",
88 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`)",
99 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`.",
1111 inputSchema: {
1212 type: "object",
1313 properties: {
src/http/mcp/tools/branches.ts +1 −1
@@ -11,7 +11,7 @@ export const tool: Tool = {
1111 name: "branches",
1212 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",
1313 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.",
1515 inputSchema: {
1616 type: "object",
1717 properties: {
src/http/mcp/tools/capacity.ts +1 −1
@@ -7,7 +7,7 @@ export const tool: Tool = {
77 name: "capacity",
88 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",
99 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.",
1111 inputSchema: {
1212 type: "object",
1313 properties: {
src/http/mcp/tools/contribute.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "contribute",
66 doc: "for any arriving agent: what this forge is, its bottleneck, open “help wanted” issues, how to propose",
77 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.",
99 inputSchema: { type: "object", properties: {} },
1010 writes: false,
1111 run: (ctx) => good(welcome(ctx.app)),
src/http/mcp/tools/family.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "family",
66 doc: "the forge that sent this one and, for a site admin, those it sent, with state and version",
77 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.",
99 inputSchema: { type: "object", properties: {} },
1010 writes: false,
1111 async run(ctx, user) {
src/http/mcp/tools/feed.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "feed",
66 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",
77 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.",
99 inputSchema: {
1010 type: "object",
1111 properties: {
src/http/mcp/tools/feed_chips.ts +1 −1
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "feed_chips",
77 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",
88 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.",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
src/http/mcp/tools/forge_proposals.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "forge_proposals",
66 doc: "`{ repo }`: what this forge proposed to its parent, newest first; `reachable` is false if the parent did not answer",
77 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.",
99 inputSchema: {
1010 type: "object",
1111 properties: { repo: REPO_ARG },
src/http/mcp/tools/forges.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "forges",
66 doc: "an admin's list of the forges sent from here: name, address, whom for, apps, claimed or not, last activity",
77 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.",
99 inputSchema: { type: "object", properties: {} },
1010 writes: false,
1111 async run(ctx, user) {
src/http/mcp/tools/hold.ts +1 −1
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "hold",
77 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`",
88 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.",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
src/http/mcp/tools/index.ts +1
@@ -79,6 +79,7 @@ export { tool as origin_apps } from "./origin_apps.ts";
7979 export { tool as origins } from "./origins.ts";
8080 export { tool as outsiders } from "./outsiders.ts";
8181 export { tool as parent_unlink } from "./parent_unlink.ts";
82 export { tool as person } from "./person.ts";
8283 export { tool as plan } from "./plan.ts";
8384 export { tool as preview } from "./preview.ts";
8485 export { tool as probe } from "./probe.ts";
src/http/mcp/tools/migrate.ts +1 −1
@@ -8,7 +8,7 @@ export const tool: Tool = {
88 name: "migrate",
99 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`",
1010 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.",
1212 inputSchema: {
1313 type: "object",
1414 properties: {
src/http/mcp/tools/milestones.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "milestones",
66 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)",
77 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.",
99 inputSchema: {
1010 type: "object",
1111 properties: {
src/http/mcp/tools/mint_invite.ts +1 −1
@@ -7,7 +7,7 @@ export const tool: Tool = {
77 name: "mint_invite",
88 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",
99 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.",
1111 inputSchema: {
1212 type: "object",
1313 properties: {
src/http/mcp/tools/model_grant.ts +1 −1
@@ -6,7 +6,7 @@ import { good, bad, type Tool, mayBoards } from "../shared.ts";
66 export const tool: Tool = {
77 name: "model_grant",
88 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.",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
src/http/mcp/tools/new_app.ts +1 −1
@@ -12,7 +12,7 @@ export const tool: Tool = {
1212 name: "new_app",
1313 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)",
1414 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.",
1616 inputSchema: {
1717 type: "object",
1818 properties: {
src/http/mcp/tools/now.ts +1 −1
@@ -15,7 +15,7 @@ export const tool: Tool = {
1515 name: "now",
1616 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`)",
1717 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.",
1919 inputSchema: { type: "object", properties: {} },
2020 writes: false,
2121 run(ctx, user) {
src/http/mcp/tools/outsiders.ts +1 −1
@@ -7,7 +7,7 @@ export const tool: Tool = {
77 name: "outsiders",
88 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",
99 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.",
1111 inputSchema: {
1212 type: "object",
1313 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 = {
55 name: "proposal_tokens",
66 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",
77 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.",
99 inputSchema: {
1010 type: "object",
1111 properties: {
src/http/mcp/tools/pull.ts +1 −1
@@ -13,7 +13,7 @@ export const tool: Tool = {
1313 name: "pull",
1414 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",
1515 description:
outdated

Low: this trim removes checkStale from the description, but the tool still returns it (line 50). An agent reading tools/list loses 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."

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`.",
1717 inputSchema: {
1818 type: "object",
1919 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"
33
44 export const tool: Tool = {
55 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`",
77 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).",
99 inputSchema: {
1010 type: "object",
1111 properties: {
src/http/mcp/tools/returns.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "returns",
66 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",
77 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.",
99 inputSchema: {
1010 type: "object",
1111 properties: {
src/http/mcp/tools/say.ts +1 −1
@@ -7,7 +7,7 @@ export const tool: Tool = {
77 name: "say",
88 doc: "speak on an issue or a pull; a working builder hears it as its next step",
99 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.",
1111 inputSchema: {
1212 type: "object",
1313 properties: {
src/http/mcp/tools/seal.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "seal",
66 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",
77 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.",
99 inputSchema: {
1010 type: "object",
1111 properties: {
src/http/mcp/tools/sever.ts +1 −1
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "sever",
77 doc: "`{ repo }`: cut a spawned forge's link to the forge its code came from; its `forge` repository's owner only; deletes nothing",
88 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. doc still says it, but tools/list no longer does. If bytes free up later, put it back before trimming any other description.

1010 inputSchema: {
1111 type: "object",
1212 properties: { repo: REPO_ARG },
src/http/mcp/tools/share.ts +1 −1
@@ -7,7 +7,7 @@ export const tool: Tool = {
77 name: "share",
88 doc: "`{ item, to? }`: share a feed item (`item` is its id); with no `to` it goes to the app's crew and owners, else to up to 10 named usernames' followers; a daily budget each; answers `{ sent }`",
99 description:
10 "Share a feed item: no `to` tells the app's crew and owners, else followers; daily budget.",
10 "Share a feed item: no `to` tells the app's crew and owners, else followers; budgeted.",
1111 inputSchema: {
1212 type: "object",
1313 properties: {
src/http/mcp/tools/spawn.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "spawn",
66 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",
77 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.",
99 inputSchema: {
1010 type: "object",
1111 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"
44 export const tool: Tool = {
55 name: "spend",
66 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.",
88 inputSchema: {
99 type: "object",
1010 properties: {
src/http/mcp/tools/stop.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "stop",
66 doc: "stop one run by its id from `now`",
77 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.",
99 inputSchema: {
1010 type: "object",
1111 properties: { repo: REPO_ARG, run: { type: "string" } },
src/http/mcp/tools/usage.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "usage",
66 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",
77 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.",
99 inputSchema: {
1010 type: "object",
1111 properties: {
src/http/mcp/tools/variant.ts +1 −1
@@ -8,7 +8,7 @@ export const tool: Tool = {
88 name: "variant",
99 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`",
1010 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.",
1212 inputSchema: {
1313 type: "object",
1414 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 hiddenFrom check at mcp.ts:2374 is covered.

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", () => {
251251 "app_members",
252252 "egress_grants",
253253 "explore",
254 "person",
254255 "search",
255256 "feed",
256257 "feed_chips",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs