Public: anyone can read this; the crew can change it.
MCP `apps` (or a `pulls` action) reads an adapter's recorded pull runs, as the API does #10119
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10102-mcp-apps-or-a-pulls-action-reads-an-adap → main 3 commits fork
3 files
changed +125
−4
docs/ADAPTERS.md +3 −1
| @@ -29,7 +29,9 @@ default 20). Each says when (`at`), how long (`durationMs`), `ok` or `failed` | ||
| 29 | 29 | `written`, `skipped` and `failed`, the `cursor` after it (null after a failed |
| 30 | 30 | pull) and whether it was a `repeat`. Whoever may read the repository may read |
| 31 | 31 | them; anyone else gets 404. The rows come through `pullRunLines` in |
| 32 | `src/apps/pull.ts`, so a page or MCP can read them the same way. | |
| 32 | `src/apps/pull.ts`, so a page or MCP can read them the same way. MCP `apps` with | |
| 33 | `{ repo, pulls: true, limit? }` answers the same `{ items, next_cursor }` through | |
| 34 | the same function; a stranger, or a repository with no app, gets `not_found`. | |
| 33 | 35 | |
| 34 | 36 | The rest of the forge's app rules apply to an adapter as they apply to any |
| 35 | 37 | other app: see [](APPS.md). |
src/http/mcp/tools/apps.ts +30 −3
| @@ -1,16 +1,43 @@ | ||
| 1 | 1 | import { appsRuntime, envsFor } from "../../../apps/runtime.ts"; |
| 2 | 2 | import { listActiveRuns } from "../../../core/runs.ts"; |
| 3 | 3 | import { appsOf } from "../../../web/studio/asks.ts"; |
| 4 | import { bad, good, type Tool } from "../shared.ts"; | |
| 4 | import { PULL_RUNS_DEFAULT, PULL_RUNS_MAX, pullRunLines } from "../../../apps/pull.ts"; | |
| 5 | import { getAppByRepo } from "../../../core/apps.ts"; | |
| 6 | import { bad, good, isAnswer, repoArg, type Tool } from "../shared.ts"; | |
| 5 | 7 | |
| 6 | 8 | export const tool: Tool = { |
| 7 | 9 | name: "apps", |
| 8 | doc: "`{ archived? }`: the apps you can see: live or not, where served, how each runs itself, what is working", | |
| 10 | doc: "`{ archived? }`: the apps you can see: live or not, where served, how each runs itself, what is working. `{ repo, pulls: true, limit? }`: an adapter's recorded pull runs, newest first, as `GET …/app/pulls` (`limit` 1-100); a reader's", | |
| 9 | 11 | description: |
| 10 | 12 | "Apps you can see: live or not, where served, how each runs, what works.", |
| 11 | inputSchema: { type: "object", properties: { archived: { type: "boolean" } } }, | |
| 13 | inputSchema: { | |
| 14 | type: "object", | |
| 15 | properties: { | |
| 16 | archived: { type: "boolean" }, | |
| 17 | repo: { type: "string" }, | |
| 18 | pulls: { type: "boolean" }, | |
| 19 | limit: { type: "integer" }, | |
| 20 | }, | |
| 21 | }, | |
| 12 | 22 | writes: false, |
| 13 | 23 | async run(ctx, user, args) { |
| 24 | if (args["pulls"] !== undefined) { | |
| 25 | if (args["pulls"] !== true) return bad("invalid", "`pulls` is true"); | |
| 26 | const repo = repoArg(ctx, user, args); | |
| 27 | if (isAnswer(repo)) return repo; | |
| 28 | const raw = args["limit"]; | |
| 29 | if ( | |
| 30 | raw !== undefined && | |
| 31 | !(typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= PULL_RUNS_MAX) | |
| 32 | ) | |
| 33 | return bad("invalid", `limit is a whole number from 1 to ${PULL_RUNS_MAX}`); | |
| 34 | const record = getAppByRepo(ctx.app.db, repo.id); | |
| 35 | if (record === undefined) return bad("not_found", "no such app or repository (owner/name)"); | |
| 36 | return good({ | |
| 37 | items: pullRunLines(ctx.app.db, record.id, typeof raw === "number" ? raw : PULL_RUNS_DEFAULT), | |
| 38 | next_cursor: null, | |
| 39 | }); | |
| 40 | } | |
| 14 | 41 | const flag = args["archived"]; |
| 15 | 42 | if (flag !== undefined && typeof flag !== "boolean") |
| 16 | 43 | return bad("invalid", "`archived` is true or false"); |
test/mcp-app-pulls.test.ts added +92
| @@ -0,0 +1,92 @@ | ||
| 1 | /** | |
| 2 | * `GET …/app/pulls` and MCP `apps` (#10102): an adapter's recorded pull runs, newest first, | |
| 3 | * for readers of the repository; a stranger to a private repository gets 404. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { newApp } from "../src/actions/apps.ts"; | |
| 11 | import { recordPull } from "../src/apps/pull.ts"; | |
| 12 | import { getRepo } from "../src/core/repos.ts"; | |
| 13 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 16 | import { promote } from "./helpers/members.ts"; | |
| 17 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 18 | ||
| 19 | describe("app pulls API", () => { | |
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | let aliceRo: string; | |
| 24 | let bobRo: string; | |
| 25 | ||
| 26 | const get = (path: string, token?: string) => | |
| 27 | fetch(`${servers.url}${path}`, token ? { headers: { authorization: `Bearer ${token}` } } : {}); | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | dataDir = await mkdtemp(join(tmpdir(), "forge-app-pulls-api-")); | |
| 31 | app = createApp({ dataDir, port: 0 }); | |
| 32 | servers = await serve(app); | |
| 33 | await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" }); | |
| 34 | const b = await createUser(app.db, { | |
| 35 | origin: "seed", | |
| 36 | trusted: true, | |
| 37 | username: "bob", | |
| 38 | email: "[email protected]", | |
| 39 | password: "a-good-passphrase", | |
| 40 | }); | |
| 41 | if (!b.ok) throw new Error(b.error); | |
| 42 | promote(app.db, b.value); | |
| 43 | const alice = getUserByName(app.db, "alice")!; | |
| 44 | for (const [name, visibility] of [["hello", "public"], ["secret", "private"]] as const) { | |
| 45 | const made = await newApp(app, alice, { name, visibility } as never); | |
| 46 | if (!made.ok) throw new Error(String(made.error)); | |
| 47 | const repo = getRepo(app.db, "alice", name)!; | |
| 48 | const appId = (app.db.query("SELECT id FROM app WHERE repo_id = ?1").get(repo.id) as { id: string }).id; | |
| 49 | recordPull(app.db, appId, { ok: true, pulled: 3, skipped: 1, errors: 0, cursor: "c1", durationMs: 42 }); | |
| 50 | recordPull(app.db, appId, { ok: false, reason: "status 500 is not 2xx", durationMs: 7 }); | |
| 51 | } | |
| 52 | aliceRo = tokenFor(app, "alice", "ro"); | |
| 53 | bobRo = tokenFor(app, "bob", "ro"); | |
| 54 | }, 30_000); | |
| 55 | ||
| 56 | afterAll(async () => { | |
| 57 | servers.stop(); | |
| 58 | app.close(); | |
| 59 | await rm(dataDir, { recursive: true, force: true }); | |
| 60 | }); | |
| 61 | ||
| 62 | const mcp = async (token: string, args: unknown) => { | |
| 63 | const res = await fetch(`${servers.url}/mcp`, { | |
| 64 | method: "POST", | |
| 65 | headers: { | |
| 66 | "content-type": "application/json", | |
| 67 | accept: "application/json, text/event-stream", | |
| 68 | authorization: `Bearer ${token}`, | |
| 69 | }, | |
| 70 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "apps", arguments: args } }), | |
| 71 | }); | |
| 72 | const r: any = ((await res.json()) as any).result; | |
| 73 | const text: string = r.content[0].text; | |
| 74 | return r.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 75 | }; | |
| 76 | ||
| 77 | test("MCP answers the same rows as the API, through the same read", async () => { | |
| 78 | const api: any = await (await get("/api/v1/repos/alice/secret/app/pulls", aliceRo)).json(); | |
| 79 | const m = await mcp(aliceRo, { repo: "alice/secret", pulls: true }); | |
| 80 | expect(m.value).toEqual(api); | |
| 81 | expect(m.value.items[0]).toMatchObject({ state: "failed", error: "status 500 is not 2xx" }); | |
| 82 | const one = await mcp(aliceRo, { repo: "alice/secret", pulls: true, limit: 1 }); | |
| 83 | expect(one.value.items).toHaveLength(1); | |
| 84 | expect((await mcp(aliceRo, { repo: "alice/secret", pulls: true, limit: 101 })).error).toContain("limit"); | |
| 85 | }); | |
| 86 | ||
| 87 | test("MCP: a stranger and a missing app get not_found", async () => { | |
| 88 | expect((await mcp(bobRo, { repo: "alice/secret", pulls: true })).error).toContain("no such app"); | |
| 89 | expect((await mcp(aliceRo, { repo: "alice/nothing", pulls: true })).error).toContain("no such app"); | |
| 90 | expect((await mcp(bobRo, { repo: "alice/hello", pulls: true })).value.items).toHaveLength(2); | |
| 91 | }); | |
| 92 | }); |