Public: anyone can read this; the crew can change it.

The repository API and the MCP's repository tool return audience { level, sentence } #2589

Merged opened this

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

Merged

scout/forge:scout/2569-the-repository-api-and-the-mcp-s-reposit → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/2569-the-repository-api-and-the-mcp-s-reposit was merged into main as bc8faa2.

Review

Sign in to review this change.
5 files changed +74 −1
docs/MCP.md +1
@@ -28,6 +28,7 @@ for a tunnel and a forge that sleeps.
2828 | tool | does | writes |
2929 | -------------- | ------------------------------------------------------------------------------------------------- | ------ |
3030 | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | |
31 | `repo` | `{ repo }`: one repository's description, visibility, autonomy and `audience { level, sentence }`, the same as `GET /api/v1/repos/:owner/:name` | |
3132 | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes |
3233 | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes |
3334 | `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` | |
src/core/audience.ts +9
@@ -212,3 +212,12 @@ export function audienceFacts(
212212 : {}),
213213 };
214214 }
215
216 /** The audience of `repo` for `viewer`: the one call the API and the MCP share. */
217 export function audienceFor(
218 db: Database,
219 repo: Repo,
220 viewer: User | undefined,
221 ): Audience {
222 return audienceOf(audienceFacts(db, repo, viewer));
223 }
src/http/api.ts +7 −1
@@ -160,6 +160,7 @@ import {
160160 updateProfile,
161161 } from "../actions/users.ts";
162162 import { listEvents } from "../core/events.ts";
163 import { audienceFor } from "../core/audience.ts";
163164 import { audienceFilter } from "../core/event_audience.ts";
164165 import {
165166 forkParentFor,
@@ -2148,7 +2149,12 @@ export function apiRoutes(router: Router<Handler>): void {
21482149
21492150 router.add("GET", "/api/v1/repos/:owner/:name", (ctx) => {
21502151 const repo = loadRepo(ctx);
2151 return repo ? json(repoJson(ctx, repo)) : notFound();
2152 return repo
2153 ? json({
2154 ...repoJson(ctx, repo),
2155 audience: audienceFor(ctx.app.db, repo, ctx.auth.user),

Good: audience is computed only after loadRepo has checked canRead. Keeping it out of repoJson also spares the PATCH, fork and job responses the extra queries.

2156 })
2157 : notFound();
21522158 });
21532159
21542160 router.add("PATCH", "/api/v1/repos/:owner/:name", async (ctx) => {
src/http/mcp.ts +23
@@ -151,6 +151,7 @@ import {
151151 isAskedOrder,
152152 isAskedShow,
153153 } from "../core/asked.ts";
154 import { audienceFor } from "../core/audience.ts";
154155 import { linksFrom, linksTo } from "../core/thought_links.ts";
155156 import { streamJson, thoughtStream } from "../core/thought_stream.ts";
156157 import {
@@ -404,6 +405,28 @@ const TOOLS: readonly Tool[] = [
404405 );
405406 },
406407 },
408 {

A new tool needs its row in the docs/MCP.md Tools table, next to apps.

409 name: "repo",
410 description:
411 "One repository you can read: its name, description, visibility and autonomy, and who can see it as `audience` { level, sentence } — the same words as GET /api/v1/repos/:owner/:name.",
412 inputSchema: {
413 type: "object",
414 properties: { repo: REPO_ARG },
415 required: ["repo"],
416 },
417 writes: false,
418 run(ctx, user, args) {
419 const repo = repoArg(ctx, user, args);
420 if (isAnswer(repo)) return repo;
421 return good({
422 repo: `${repo.ownerName}/${repo.name}`,
423 description: repo.description,
424 visibility: repo.visibility,
425 autonomy: repo.autonomy,
426 audience: audienceFor(ctx.app.db, repo, user),
427 });
428 },
429 },
407430 {
408431 name: "here",
409432 description:
test/mcp.test.ts +34
@@ -165,6 +165,7 @@ describe("the forge over MCP", () => {
165165 const names = listed.body.result.tools.map((t: { name: string }) => t.name);
166166 expect(names).toEqual([
167167 "apps",
168 "repo",
168169 "here",
169170 "tell",
170171 "share",
@@ -256,6 +257,39 @@ describe("the forge over MCP", () => {
256257 ).toBe(-32602);
257258 });
258259
260 test("repo and GET /api/v1/repos give the same audience; a stranger learns nothing of a private one", async () => {
261 const api = async (token: string | null, path: string) =>
262 fetch(`${base}/api/v1/repos/${path}`, {
263 headers: token === null ? {} : { authorization: `Bearer ${token}` },
264 });
265 const viaApi = (await (await api(rw, "alice/game")).json()) as any;
266 const viaMcp = await tool(rw, "repo", { repo: "alice/game" });
267 expect(viaApi.audience.level).toBe("private");
268 expect(viaMcp.value.audience).toEqual(viaApi.audience);
269 expect(viaApi.audience.sentence).toStartWith("Private: only you");
270
271 // Bob cannot read it: a 404 and the same refusal, with no names in either.
272 const denied = await api(bobRw, "alice/game");
273 expect(denied.status).toBe(404);
274 expect(JSON.stringify(await denied.json())).not.toContain("alice");
275 expect((await tool(bobRw, "repo", { repo: "alice/game" })).error).toContain("no such");
276 const anon = await api(null, "alice/game");
277 expect(anon.status).toBe(404);
278 expect(JSON.stringify(await anon.json())).not.toContain("alice");
279
280 // A public repository tells everyone the public sentence, signed out too.
281 const open = await createRepo(app, alice, { name: "wide", visibility: "public" });
282 if (!open.ok) throw new Error(String(open.error));
283 const pub = (await (await api(null, "alice/wide")).json()) as any;
284 expect(pub.audience.level).toBe("public");
285 expect(pub.audience.sentence).toStartWith("Public: anyone can read this");
286 expect(pub.audience.sentence).not.toContain("you");
287 const asBob = await tool(bobRw, "repo", { repo: "alice/wide" });
288 expect(asBob.value.audience).toEqual(
289 ((await (await api(bobRw, "alice/wide")).json()) as any).audience,
290 );
291 });
292
259293 test("apps and now read what the studio reads", async () => {
260294 const apps = await tool(ro, "apps");
261295 expect(apps.value).toHaveLength(1);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs