Public: anyone can read this; the crew can change it.
The repository API and the MCP's repository tool return audience { level, sentence } #2589
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2569-the-repository-api-and-the-mcp-s-reposit → main 2 commits fork 3 threads · 3 unresolved
5 files
changed +74
−1
docs/MCP.md +1
| @@ -28,6 +28,7 @@ for a tunnel and a forge that sleeps. | ||
| 28 | 28 | | tool | does | writes | |
| 29 | 29 | | -------------- | ------------------------------------------------------------------------------------------------- | ------ | |
| 30 | 30 | | `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` | | | |
| 31 | 32 | | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes | |
| 32 | 33 | | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes | |
| 33 | 34 | | `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( | ||
| 212 | 212 | : {}), |
| 213 | 213 | }; |
| 214 | 214 | } |
| 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 { | ||
| 160 | 160 | updateProfile, |
| 161 | 161 | } from "../actions/users.ts"; |
| 162 | 162 | import { listEvents } from "../core/events.ts"; |
| 163 | import { audienceFor } from "../core/audience.ts"; | |
| 163 | 164 | import { audienceFilter } from "../core/event_audience.ts"; |
| 164 | 165 | import { |
| 165 | 166 | forkParentFor, |
| @@ -2148,7 +2149,12 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2148 | 2149 | |
| 2149 | 2150 | router.add("GET", "/api/v1/repos/:owner/:name", (ctx) => { |
| 2150 | 2151 | 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), | |
| 2156 | }) | |
| 2157 | : notFound(); | |
| 2152 | 2158 | }); |
| 2153 | 2159 | |
| 2154 | 2160 | router.add("PATCH", "/api/v1/repos/:owner/:name", async (ctx) => { |
src/http/mcp.ts +23
| @@ -151,6 +151,7 @@ import { | ||
| 151 | 151 | isAskedOrder, |
| 152 | 152 | isAskedShow, |
| 153 | 153 | } from "../core/asked.ts"; |
| 154 | import { audienceFor } from "../core/audience.ts"; | |
| 154 | 155 | import { linksFrom, linksTo } from "../core/thought_links.ts"; |
| 155 | 156 | import { streamJson, thoughtStream } from "../core/thought_stream.ts"; |
| 156 | 157 | import { |
| @@ -404,6 +405,28 @@ const TOOLS: readonly Tool[] = [ | ||
| 404 | 405 | ); |
| 405 | 406 | }, |
| 406 | 407 | }, |
| 408 | { | |
A new tool needs its row in the |
||
| 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 | }, | |
| 407 | 430 | { |
| 408 | 431 | name: "here", |
| 409 | 432 | description: |
test/mcp.test.ts +34
| @@ -165,6 +165,7 @@ describe("the forge over MCP", () => { | ||
| 165 | 165 | const names = listed.body.result.tools.map((t: { name: string }) => t.name); |
| 166 | 166 | expect(names).toEqual([ |
| 167 | 167 | "apps", |
| 168 | "repo", | |
| 168 | 169 | "here", |
| 169 | 170 | "tell", |
| 170 | 171 | "share", |
| @@ -256,6 +257,39 @@ describe("the forge over MCP", () => { | ||
| 256 | 257 | ).toBe(-32602); |
| 257 | 258 | }); |
| 258 | 259 | |
| 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 | ||
| 259 | 293 | test("apps and now read what the studio reads", async () => { |
| 260 | 294 | const apps = await tool(ro, "apps"); |
| 261 | 295 | expect(apps.value).toHaveLength(1); |
new above
offline
Good:
audienceis computed only afterloadRepohas checkedcanRead. Keeping it out ofrepoJsonalso spares the PATCH, fork and job responses the extra queries.