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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6083-mcp-apps-takes-archived-true-and-lists-t → main 2 commits fork
3 files
changed +106
−6
src/http/mcp/tools/apps.ts +9 −4
| @@ -1,17 +1,21 @@ | ||
| 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 { good, type Tool } from "../shared.ts"; | |
| 4 | import { bad, good, type Tool } from "../shared.ts"; | |
| 5 | 5 | |
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | name: "apps", |
| 8 | 8 | doc: "the apps you can see: live or not, where served, how each runs itself, what is working", |
| 9 | 9 | description: |
| 10 | 10 | "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" } } }, | |
| 12 | 12 | 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); | |
| 15 | 19 | const runtime = appsRuntime(ctx.app); |
| 16 | 20 | const active = listActiveRuns( |
| 17 | 21 | ctx.app.db, |
| @@ -30,6 +34,7 @@ export const tool: Tool = { | ||
| 30 | 34 | url: runtime?.urlFor(record, "production") ?? null, |
| 31 | 35 | autonomy: repo.autonomy, |
| 32 | 36 | working: active.filter((r) => r.repoId === repo.id).length, |
| 37 | ...(archived ? { archivedAt: repo.archivedAt } : {}), | |
| 33 | 38 | }; |
| 34 | 39 | }), |
| 35 | 40 | ); |
src/web/studio/asks.ts +6 −2
| @@ -80,10 +80,14 @@ function appsFiltered( | ||
| 80 | 80 | * Everyone's public apps sit in this list beside the viewer's own: right |
| 81 | 81 | * for search, the palette and the asks count, wrong for a shelf headed |
| 82 | 82 | * "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) { | |
| 84 | 84 | return appsFiltered( |
| 85 | 85 | 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 | ), | |
| 87 | 91 | ); |
| 88 | 92 | } |
| 89 | 93 |
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 | }); |
new above
offline