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

Merged opened this

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

Merged

scout/forge:scout/4905-mcp-repo-settings-change-a-repository-s → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/4905-mcp-repo-settings-change-a-repository-s was merged into main as 527ab71.

Review

Sign in to review this change.
3 files changed +129 −1
docs/MCP.md +1
@@ -30,6 +30,7 @@ for a tunnel and a forge that sleeps.
3030 | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | |
3131 | `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 |
3232 | `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 |
3334 | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes |
3435 | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes |
3536 | `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 {
168168 directionNote,
169169 OUTSIDERS_NOTE,
170170 } 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";
172172 import {
173173 deleteAppEnvVar,
174174 listAppEnv,
@@ -569,6 +569,42 @@ const TOOLS: readonly Tool[] = [
569569 });
570570 },
571571 },
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 },

Low: maxLength: 2000 is 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 -> invalid here, and the API should get the same cap in a later pull.

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 },
572608 {
573609 name: "here",
574610 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: isError would also be true if the tool were missing or failed for some unrelated reason. Also assert why it was refused (e.g. expect(set.text).toContain("forbidden")) so the test checks the admin rule specifically.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs