Public: anyone can read this; the crew can change it.
MCP `repo_settings`: change a repository's description, website and visibility, with the same rules as the API #5029
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4905-mcp-repo-settings-change-a-repository-s → main 1 commit fork 2 threads · 2 unresolved
3 files
changed +129
−1
docs/MCP.md +1
| @@ -30,6 +30,7 @@ for a tunnel and a forge that sleeps. | ||
| 30 | 30 | | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | | |
| 31 | 31 | | `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 | |
| 32 | 32 | | `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` | | |
| 33 | | `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 | | |
| 33 | 34 | | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes | |
| 34 | 35 | | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes | |
| 35 | 36 | | `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 | | |
src/http/mcp.ts +37 −1
| @@ -168,7 +168,7 @@ import { | ||
| 168 | 168 | directionNote, |
| 169 | 169 | OUTSIDERS_NOTE, |
| 170 | 170 | } from "../crew/direct.ts"; |
| 171 | import { setArchived, setCrewOutsiders, setDecisionsBy, repositoryLimitMessage } from "../actions/repos.ts"; | |
| 171 | import { setArchived, setCrewOutsiders, updateRepoSettings, setDecisionsBy, repositoryLimitMessage } from "../actions/repos.ts"; | |
| 172 | 172 | import { |
| 173 | 173 | deleteAppEnvVar, |
| 174 | 174 | listAppEnv, |
| @@ -569,6 +569,42 @@ const TOOLS: readonly Tool[] = [ | ||
| 569 | 569 | }); |
| 570 | 570 | }, |
| 571 | 571 | }, |
| 572 | { | |
| 573 | name: "repo_settings", | |
| 574 | description: | |
| 575 | "Change a repository's description or visibility, as PATCH /api/v1/repos/:owner/:name (admin token).", | |
| 576 | inputSchema: { | |
| 577 | type: "object", | |
| 578 | properties: { | |
| 579 | repo: REPO_ARG, | |
| 580 | description: { type: "string", maxLength: 2000 }, | |
| 581 | visibility: { type: "string", enum: ["public", "private"] }, | |
| 582 | }, | |
| 583 | required: ["repo"], | |
| 584 | }, | |
| 585 | writes: true, | |
| 586 | needsAdminScope: true, | |
| 587 | async run(ctx, user, args) { | |
| 588 | const repo = repoArg(ctx, user, args); | |
| 589 | if (isAnswer(repo)) return repo; | |
| 590 | const { description, visibility } = args; | |
| 591 | if (description !== undefined && typeof description !== "string") | |
| 592 | return bad("invalid", "description is text"); | |
| 593 | if (visibility !== undefined && visibility !== "public" && visibility !== "private") | |
| 594 | return bad("invalid", "visibility is public or private"); | |
| 595 | const updated = await updateRepoSettings(ctx.app, user, repo, { | |
| 596 | ...(visibility !== undefined ? { visibility } : {}), | |
| 597 | ...(description !== undefined ? { description } : {}), | |
| 598 | }); | |
| 599 | if (!updated.ok) | |
| 600 | return bad(updated.error === "not_found" ? "not_found" : "forbidden", updated.error); | |
| 601 | return good({ | |
| 602 | repo: `${updated.value.ownerName}/${updated.value.name}`, | |
| 603 | description: updated.value.description, | |
| 604 | visibility: updated.value.visibility, | |
| 605 | }); | |
| 606 | }, | |
| 607 | }, | |
| 572 | 608 | { |
| 573 | 609 | name: "here", |
| 574 | 610 | description: |
test/mcp-reposet.test.ts added +91
| @@ -0,0 +1,91 @@ | ||
| 1 | /** The `repo_settings` tool is PATCH /api/v1/repos/:owner/:name over MCP, for the same callers. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; | |
| 8 | import { createRepo } from "../src/core/repos.ts"; | |
| 9 | import { createToken } from "../src/core/tokens.ts"; | |
| 10 | import { createUser } from "../src/core/users.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { promote } from "./helpers/members.ts"; | |
| 13 | ||
| 14 | describe("MCP repo_settings", () => { | |
| 15 | let dataDir: string; | |
| 16 | let app: App; | |
| 17 | let servers: Servers; | |
| 18 | let admin: string; | |
| 19 | let writer: string; | |
| 20 | let id = 1; | |
| 21 | ||
| 22 | const call = async (token: string, name: string, args: unknown) => { | |
| 23 | const res = await fetch(`${servers.url}/mcp`, { | |
| 24 | method: "POST", | |
| 25 | headers: { | |
| 26 | "content-type": "application/json", | |
| 27 | accept: "application/json, text/event-stream", | |
| 28 | authorization: `Bearer ${token}`, | |
| 29 | }, | |
| 30 | body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name, arguments: args } }), | |
| 31 | }); | |
| 32 | const body = (await res.json()) as any; | |
| 33 | return { text: body.result.content[0].text as string, isError: body.result.isError === true }; | |
| 34 | }; | |
| 35 | ||
| 36 | beforeAll(async () => { | |
| 37 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-reposet-")); | |
| 38 | app = createApp({ dataDir, port: 0 }); | |
| 39 | servers = await serve(app); | |
| 40 | const mk = async (username: string) => { | |
| 41 | const u = await createUser(app.db, { origin: "seed", trusted: true, | |
| 42 | username, email: `${username}@forge.local`, password: "a-good-passphrase", | |
| 43 | adminIfFirst: username === "alice" }); | |
| 44 | if (!u.ok) throw new Error(u.error); | |
| 45 | return u.value; | |
| 46 | }; | |
| 47 | const a = await mk("alice"); | |
| 48 | const b = await mk("bob"); | |
| 49 | promote(app.db, a, b); | |
| 50 | admin = createToken(app.db, a.id, "admin", "admin").token; | |
| 51 | writer = createToken(app.db, b.id, "admin", "admin").token; | |
| 52 | const org = createOrg(app.db, "acme", a.id); | |
| 53 | if (!org.ok) throw new Error(org.error); | |
| 54 | const team = createTeam(app.db, org.value.id, "Writers", "write"); | |
| 55 | if (!team.ok) throw new Error(team.error); | |
| 56 | addTeamMember(app.db, team.value, b.id); | |
| 57 | const made = await createRepo(app.db, app.reposRoot, { | |
| 58 | ownerId: org.value.id, name: "game", visibility: "public", | |
| 59 | }); | |
| 60 | if (!made.ok) throw new Error(String(made.error)); | |
| 61 | }, 30_000); | |
| 62 | ||
| 63 | afterAll(async () => { | |
| 64 | servers.stop(); | |
| 65 | app.close(); | |
| 66 | await rm(dataDir, { recursive: true, force: true }); | |
| 67 | }); | |
| 68 | ||
| 69 | test("an admin changes description and visibility and reads them back", async () => { | |
| 70 | const set = await call(admin, "repo_settings", { | |
| 71 | repo: "acme/game", description: "tidy", visibility: "private", | |
| 72 | }); | |
| 73 | expect(set.isError).toBe(false); | |
| 74 | expect(JSON.parse(set.text)).toMatchObject({ description: "tidy", visibility: "private" }); | |
| 75 | const read = JSON.parse((await call(admin, "repo", { repo: "acme/game" })).text); | |
| 76 | expect(read).toMatchObject({ description: "tidy", visibility: "private" }); | |
| 77 | }); | |
| 78 | ||
| 79 | test("a writer who is not admin is refused", async () => { | |
| 80 | const set = await call(writer, "repo_settings", { repo: "acme/game", description: "mine" }); | |
| 81 | expect(set.isError).toBe(true); | |
Low: |
||
| 82 | const read = JSON.parse((await call(admin, "repo", { repo: "acme/game" })).text); | |
| 83 | expect(read.description).toBe("tidy"); | |
| 84 | }); | |
| 85 | ||
| 86 | test("an invalid visibility is refused", async () => { | |
| 87 | const set = await call(admin, "repo_settings", { repo: "acme/game", visibility: "secret" }); | |
| 88 | expect(set.isError).toBe(true); | |
| 89 | expect(set.text).toContain("visibility is public or private"); | |
| 90 | }); | |
| 91 | }); |
new above
offline
Low:
maxLength: 2000is only advertised in the schema. The handler never enforces it, and neither the API nor the core caps the description, so a client that ignores the schema can store a longer one. This matches PATCH, so it is not a regression. Either drop the bound or check[...description].length > 2000->invalidhere, and the API should get the same cap in a later pull.