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

MCP `apps` takes `archived: true` and lists the apps put away, as /apps?archived=1 does #7363

Merged opened this

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

Merged

scout/forge:scout/6083-mcp-apps-takes-archived-true-and-lists-t → main 2 commits fork

  • Merged. scout/6083-mcp-apps-takes-archived-true-and-lists-t was merged into main as 76354c0.

Review

Sign in to review this change.
3 files changed +106 −6
src/http/mcp/tools/apps.ts +9 −4
@@ -1,17 +1,21 @@
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 { good, type Tool } from "../shared.ts";
4 import { bad, good, type Tool } from "../shared.ts";
55
66 export const tool: Tool = {
77 name: "apps",
88 doc: "the apps you can see: live or not, where served, how each runs itself, what is working",
99 description:
1010 "Apps you can see: live or not, where served, how each runs, what works.",
11 inputSchema: { type: "object", properties: {} },
11 inputSchema: { type: "object", properties: { archived: { type: "boolean" } } },
1212 writes: false,
13 async run(ctx, user) {
14 const { repos, records } = appsOf(ctx, user);
13 async run(ctx, user, args) {
14 const flag = args["archived"];
15 if (flag !== undefined && typeof flag !== "boolean")
16 return bad("invalid", "`archived` is true or false");
17 const archived = flag === true;
18 const { repos, records } = appsOf(ctx, user, archived);
1519 const runtime = appsRuntime(ctx.app);
1620 const active = listActiveRuns(
1721 ctx.app.db,
@@ -30,6 +34,7 @@ export const tool: Tool = {
3034 url: runtime?.urlFor(record, "production") ?? null,
3135 autonomy: repo.autonomy,
3236 working: active.filter((r) => r.repoId === repo.id).length,
37 ...(archived ? { archivedAt: repo.archivedAt } : {}),
3338 };
3439 }),
3540 );
src/web/studio/asks.ts +6 −2
@@ -80,10 +80,14 @@ function appsFiltered(
8080 * Everyone's public apps sit in this list beside the viewer's own: right
8181 * for search, the palette and the asks count, wrong for a shelf headed
8282 * "your apps" — that reading is `writableAppsOf`. */
83 export function appsOf(ctx: Ctx, user: User | undefined) {
83 export function appsOf(ctx: Ctx, user: User | undefined, archived = false) {
8484 return appsFiltered(
8585 ctx,
86 listReposVisibleTo(ctx.app.db, user, { limit: 200, withoutArchived: true }),
86 listReposVisibleTo(
87 ctx.app.db,
88 user,
89 archived ? { limit: 200, archivedOnly: true } : { limit: 200, withoutArchived: true },
90 ),
8791 );
8892 }
8993
test/mcp-apps-archived.test.ts added +91
@@ -0,0 +1,91 @@
1 /** MCP `apps` with `archived: true` lists the apps put away, as /apps?archived=1 does. */
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 { newApp } from "../src/actions/apps.ts";
8 import { getRepo } from "../src/core/repos.ts";
9 import { createUser, getUserByName } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { seedAdmin } from "./helpers/admin.ts";
12 import { promote } from "./helpers/members.ts";
13 import { tokenFor } from "./helpers/tokens.ts";
14
15 describe("MCP apps archived", () => {
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 let alice: string;
20 let bob: string;
21 let id = 1;
22
23 const call = async (token: string, args: unknown) => {
24 const res = await fetch(`${servers.url}/mcp`, {
25 method: "POST",
26 headers: {
27 "content-type": "application/json",
28 accept: "application/json, text/event-stream",
29 authorization: `Bearer ${token}`,
30 },
31 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "apps", arguments: args } }),
32 });
33 const body: any = await res.json();
34 const text: string = body.result.content[0].text;
35 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) as any[] };
36 };
37 const names = (r: { value?: any[] }) => (r.value ?? []).map((a) => a.app).sort();
38
39 beforeAll(async () => {
40 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-apps-archived-"));
41 app = createApp({ dataDir, port: 0 });
42 servers = await serve(app);
43 await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" });
44 const b = await createUser(app.db, {
45 origin: "seed",
46 trusted: true,
47 username: "bob",
48 email: "[email protected]",
49 password: "a-good-passphrase",
50 });
51 if (!b.ok) throw new Error(b.error);
52 promote(app.db, b.value);
53 const a = getUserByName(app.db, "alice")!;
54 for (const [name, visibility] of [["kept", "public"], ["shelved", "public"], ["vault", "private"]] as const) {
55 const made = await newApp(app, a, { name, visibility } as never);
56 if (!made.ok) throw new Error(String(made.error));
57 }
58 alice = tokenFor(app, "alice", "rw");
59 bob = tokenFor(app, "bob", "rw");
60 }, 30_000);
61
62 afterAll(async () => {
63 servers.stop();
64 app.close();
65 await rm(dataDir, { recursive: true, force: true });
66 });
67
68 test("an archived app leaves the list and shows under archived: true", async () => {
69 expect(names(await call(alice, {}))).toEqual(["alice/kept", "alice/shelved", "alice/vault"]);
70 const at = new Date().toISOString();
71 for (const n of ["shelved", "vault"])
72 app.db.query("UPDATE repo SET archived_at = ?2 WHERE id = ?1").run(getRepo(app.db, "alice", n)!.id, at);
73 expect(names(await call(alice, {}))).toEqual(["alice/kept"]);
74 const gone = await call(alice, { archived: true });
75 expect(names(gone)).toEqual(["alice/shelved", "alice/vault"]);
76 expect(gone.value![0].archivedAt).toBe(at);
77 expect((await call(alice, { archived: false })).value![0].archivedAt).toBeUndefined();
78 });
79
80 test("a stranger sees the public one only, never the private one", async () => {
81 expect(names(await call(bob, { archived: true }))).toEqual(["alice/shelved"]);
82 expect(names(await call(bob, {}))).toEqual(["alice/kept"]);
83 });
84
85 test("another type is refused", async () => {
86 for (const archived of ["true", 1, null, {}]) {
87 const r = await call(alice, { archived });
88 expect(r.error).toMatch(/archived/);
89 }
90 });
91 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs