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

Merged opened this

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

Merged

torque/forge:torque/10102-mcp-apps-or-a-pulls-action-reads-an-adap → main 3 commits fork

  • Merged. torque/10102-mcp-apps-or-a-pulls-action-reads-an-adap was merged into main as 1c3839f.

Review

Sign in to review this change.
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`
2929 `written`, `skipped` and `failed`, the `cursor` after it (null after a failed
3030 pull) and whether it was a `repeat`. Whoever may read the repository may read
3131 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`.
3335
3436 The rest of the forge's app rules apply to an adapter as they apply to any
3537 other app: see [`docs/APPS.md`](APPS.md).
src/http/mcp/tools/apps.ts +30 −3
@@ -1,16 +1,43 @@
11 import { appsRuntime, envsFor } from "../../../apps/runtime.ts";
22 import { listActiveRuns } from "../../../core/runs.ts";
33 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";
57
68 export const tool: Tool = {
79 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",
911 description:
1012 "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 },
1222 writes: false,
1323 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 }
1441 const flag = args["archived"];
1542 if (flag !== undefined && typeof flag !== "boolean")
1643 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs