Public: anyone can read this; the crew can change it.
MCP `preview`: for a preview URL, the app, the pull it previews, its check state and its log link, as the forge pill shows #5854
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5828-mcp-preview-for-a-preview-url-the-app-th → main 3 commits fork 3 threads · 3 unresolved
docs/MCP.md +1
| @@ -37,6 +37,7 @@ for a tunnel and a forge that sleeps. | ||
| 37 | 37 | | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes | |
| 38 | 38 | | `issue` | `{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, comments (author, authorIsAgent, body, at) and blockedBy/blocks, as `GET …/issues/:number`; a sealed one you cannot see is not found | | |
| 39 | 39 | | `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`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment | | |
| 40 | | `preview` | `{ url }`: what the forge pill says of an app host's URL (production or a pull's preview): `{ app, environment, pull: { number, title } \| null, check: "green" \| "red" \| "running" \| null, firstFailure, logUrl, working: [names] }`; `firstFailure` is the check's first failing test or its summary, `logUrl` is set only for someone who may read the log (a writer), `working` names the agents with a run going; a URL that is not one of this forge's app hosts is `invalid`, an app you cannot read is `not_found` and a pull you cannot see is `no_such_pull`, as `repo` and `pull` say | | | |
| 40 | 41 | | `edit` | a new title or body for an issue or a pull: its author's, or a writer's; an agent edits only what it wrote | yes | |
| 41 | 42 | | `close` | `{ repo, number, state?, reason?, into? }`: close an issue or a pull (`state` closed by default) or reopen it (`open`); the same rules and refusals as `PATCH …/issues/:number`: an agent closes only what the rules give it and reopens nothing; answers `state` and `closeReason` | yes | |
| 42 | 43 | | `pulls` | `{ repo, state?, limit? }`: a repository's pulls, newest first, one brief line each: `number`, `title`, `author`, `head`, `check` (state), `approvals`, `changesRequested`, `mergeable` (null once closed); `state` open (default) or closed, `limit` 1-50 (default 20); no bodies, no diffs | no | |
src/apps/preview_view.ts added +80
| @@ -0,0 +1,80 @@ | ||
| 1 | /** | |
| 2 | * What the forge pill tells a person, for an agent: given an app host's URL, | |
| 3 | * the app, the environment, the pull a preview serves, the state of that | |
| 4 | * pull's check, its first failure and its log, and who is working on it. | |
| 5 | * Plain data in, plain data out; the MCP `preview` tool is the door. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { App } from "../app.ts"; | |
| 9 | import { runLog } from "../actions/runs.ts"; | |
| 10 | ||
| 11 | import { getPull } from "../core/pulls.ts"; | |
| 12 | import { checkSummary, workingAgentRuns } from "../core/runs.ts"; | |
| 13 | import { mayView, mayViewId } from "../core/sealed.ts"; | |
| 14 | import { canRead, getRepoById } from "../core/repos.ts"; | |
| 15 | import { getUserById, type User } from "../core/users.ts"; | |
| 16 | import { resolveAppHostname } from "./proxy.ts"; | |
| 17 | ||
| 18 | export interface PreviewView { | |
| 19 | readonly app: string; | |
| 20 | readonly environment: string; | |
| 21 | readonly pull: { readonly number: number; readonly title: string } | null; | |
| 22 | readonly check: "green" | "red" | "running" | null; | |
| 23 | readonly firstFailure: string | null; | |
| 24 | readonly logUrl: string | null; | |
| 25 | readonly working: readonly string[]; | |
| 26 | } | |
| 27 | ||
| 28 | export type PreviewRefusal = "foreign" | "no_such_app" | "no_such_pull"; | |
| 29 | ||
| 30 | export function previewOf( | |
| 31 | app: App, | |
| 32 | domain: string | undefined, | |
| 33 | user: User, | |
| 34 | origin: string, | |
| 35 | raw: unknown, | |
| 36 | ): { ok: true; value: PreviewView } | { ok: false; error: PreviewRefusal; number?: number } { | |
| 37 | const url = typeof raw === "string" && raw.length <= 2048 ? URL.parse(raw) : null; | |
| 38 | if (domain === undefined || url === null || (url.protocol !== "http:" && url.protocol !== "https:")) | |
| 39 | return { ok: false, error: "foreign" }; | |
| 40 | const target = resolveAppHostname(app, domain, url.host); | |
| 41 | if (target === undefined) return { ok: false, error: "foreign" }; | |
| 42 | const db = app.db; | |
| 43 | if (target === "unknown" || target.nested) return { ok: false, error: "no_such_app" }; | |
| 44 | const repo = getRepoById(db, target.app.repoId); | |
| 45 | if (repo === undefined || !canRead(db, repo, user)) return { ok: false, error: "no_such_app" }; | |
| 46 | const name = `${repo.ownerName}/${repo.name}`; | |
| 47 | const number = /^pull-(\d+)$/.exec(target.env)?.[1]; | |
| 48 | let pull: PreviewView["pull"] = null; | |
| 49 | let check: PreviewView["check"] = null; | |
| 50 | let firstFailure: string | null = null; | |
| 51 | let logUrl: string | null = null; | |
| 52 | let pullIssueId: string | undefined; | |
| 53 | if (number !== undefined) { | |
| 54 | const found = getPull(db, repo.id, Number(number)); | |
| 55 | if (found === undefined || !mayView(db, repo, found, user)) | |
| 56 | return { ok: false, error: "no_such_pull", number: Number(number) }; | |
| 57 | pullIssueId = found.id; | |
| 58 | pull = { number: found.number, title: found.title }; | |
| 59 | const { state, run } = checkSummary(db, found.id, found.headOid); | |
| 60 | check = | |
| 61 | state === "ok" ? "green" : state === "failed" ? "red" : state === "running" || state === "queued" ? "running" : null; | |
| 62 | if (run !== null) { | |
| 63 | if (state === "failed") firstFailure = run.checkDetail?.names[0] ?? (run.summary || null); | |
| 64 | const log = runLog(app, user, repo, run.id); | |
| 65 | if (log.ok && !log.value.logHidden) logUrl = `${origin}/${name}/runs/${run.id}`; | |
| 66 | } | |
| 67 | } | |
outdated
High: every active run on the repo is named, including runs on sealed issues, so a reader learns that an agent is working on a hidden report. The pill ( |
||
| 68 | const working = new Set<string>(); | |
| 69 | // As the pill: this pull's runs on a preview, the app's on production, and | |
| 70 | // never a run on an issue the viewer may not see. | |
| 71 | for (const run of workingAgentRuns(db, repo.id, pullIssueId)) { | |
| 72 | if (!mayViewId(db, repo, run.issueId, user)) continue; | |
| 73 | const who = run.actorId === null ? undefined : getUserById(db, run.actorId); | |
| 74 | if (who !== undefined) working.add(who.username); | |
| 75 | } | |
| 76 | return { | |
| 77 | ok: true, | |
| 78 | value: { app: name, environment: target.env, pull, check, firstFailure, logUrl, working: [...working] }, | |
| 79 | }; | |
| 80 | } |
src/http/mcp.ts +19
| @@ -194,6 +194,7 @@ import { | ||
| 194 | 194 | import { plannerDeclines } from "../crew/autonomy.ts"; |
| 195 | 195 | import { checkStale } from "../checks/stale.ts"; |
| 196 | 196 | import { checkSummary, listActiveRuns } from "../core/runs.ts"; |
| 197 | import { previewOf } from "../apps/preview_view.ts"; | |
| 197 | 198 | import { scopeAllows } from "../core/tokens.ts"; |
| 198 | 199 | import { addSshKey, deleteSshKey } from "../actions/users.ts"; |
| 199 | 200 | import { listKeys, publicKey } from "../core/keys.ts"; |
| @@ -1009,6 +1010,24 @@ const TOOLS: readonly Tool[] = [ | ||
| 1009 | 1010 | }; |
| 1010 | 1011 | }, |
| 1011 | 1012 | }, |
| 1013 | { | |
| 1014 | name: "preview", | |
| 1015 | description: | |
| 1016 | "An app URL's pull, check, first failure, log.", | |
| 1017 | inputSchema: { | |
| 1018 | type: "object", | |
| 1019 | properties: { url: { type: "string" } }, | |
| 1020 | required: ["url"], | |
| 1021 | }, | |
| 1022 | writes: false, | |
| 1023 | run(ctx, user, args) { | |
| 1024 | const seen = previewOf(ctx.app, appsRuntime(ctx.app)?.options.domain, user, ctx.url.origin, args["url"]); | |
| 1025 | if (seen.ok) return good(seen.value); | |
| 1026 | if (seen.error === "foreign") return bad("invalid", "`url` is one of this forge's app hosts"); | |
| 1027 | if (seen.error === "no_such_pull") return bad("no_such_pull", `no pull #${String(seen.number)}`); | |
| 1028 | return bad("not_found", "no such app or repository (owner/name)"); | |
| 1029 | }, | |
| 1030 | }, | |
| 1012 | 1031 | { |
| 1013 | 1032 | name: "pulls", |
| 1014 | 1033 | description: |
test/mcp-preview.test.ts added +174
| @@ -0,0 +1,174 @@ | ||
| 1 | /** | |
| 2 | * The `preview` tool tells an agent what the forge pill tells a person: the | |
| 3 | * app, the environment, the pull, its check, its first failure and its log. | |
| 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 { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 11 | import { newApp } from "../src/actions/apps.ts"; | |
| 12 | import { openPull } from "../src/actions/pulls.ts"; | |
| 13 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 14 | import { getRepo } from "../src/core/repos.ts"; | |
| 15 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 16 | import { getPull } from "../src/core/pulls.ts"; | |
| 17 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 18 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 19 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 20 | import { promote } from "./helpers/members.ts"; | |
| 21 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 22 | ||
| 23 | describe("MCP preview", () => { | |
| 24 | let dataDir: string; | |
| 25 | let app: App; | |
| 26 | let servers: Servers; | |
| 27 | let runtime: AppsRuntime; | |
| 28 | let aliceRw: string; | |
| 29 | let bobRw: string; | |
| 30 | let id = 1; | |
| 31 | const host = (label: string) => `http://${label}.apps.test`; | |
| 32 | ||
| 33 | const call = async (token: string, name: string, args: unknown) => { | |
| 34 | const res = await fetch(`${servers.url}/mcp`, { | |
| 35 | method: "POST", | |
| 36 | headers: { | |
| 37 | "content-type": "application/json", | |
| 38 | accept: "application/json, text/event-stream", | |
| 39 | authorization: `Bearer ${token}`, | |
| 40 | }, | |
| 41 | body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name, arguments: args } }), | |
| 42 | }); | |
| 43 | const body: any = await res.json(); | |
| 44 | const text: string = body.result.content[0].text; | |
| 45 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 46 | }; | |
| 47 | const tool = (token: string, args: unknown) => call(token, "preview", args); | |
| 48 | ||
| 49 | const makePull = async (alice: any, repo: any, branch: string) => { | |
| 50 | const committed = await commitToBranch(app, alice, repo, { | |
| 51 | branch, | |
| 52 | base: repo.defaultBranch, | |
| 53 | message: branch, | |
| 54 | files: [{ path: `${branch}.md`, content: "x\n" }], | |
| 55 | }); | |
| 56 | if (!committed.ok) throw new Error(JSON.stringify(committed.error)); | |
| 57 | const opened = await openPull(app, alice, repo, { title: `Pull ${branch}`, baseBranch: repo.defaultBranch, headBranch: branch }); | |
| 58 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 59 | return opened.value.number; | |
| 60 | }; | |
| 61 | ||
| 62 | let redPull = 0; | |
| 63 | let sealedPull = 0; | |
| 64 | ||
| 65 | beforeAll(async () => { | |
| 66 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-preview-")); | |
| 67 | app = createApp({ dataDir, port: 0 }); | |
| 68 | servers = await serve(app); | |
| 69 | runtime = new AppsRuntime(app, { domain: "apps.test", scheme: "http", publicPort: servers.port, forgeUrl: servers.url }); | |
| 70 | await runtime.start(); | |
| 71 | await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" }); | |
| 72 | const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" }); | |
| 73 | if (!b.ok) throw new Error(b.error); | |
| 74 | promote(app.db, b.value); | |
| 75 | const alice = getUserByName(app.db, "alice")!; | |
| 76 | const made = await newApp(app, alice, { name: "hello", visibility: "public" }); | |
| 77 | if (!made.ok) throw new Error(String(made.error)); | |
| 78 | const repo = getRepo(app.db, "alice", "hello")!; | |
| 79 | redPull = await makePull(alice, repo, "red"); | |
| 80 | sealedPull = await makePull(alice, repo, "secret"); | |
| 81 | const pull = getPull(app.db, repo.id, redPull)!; | |
| 82 | const run = createRun(app.db, { repoId: repo.id, kind: "check", issueId: pull.id, commitOid: pull.headOid }); | |
| 83 | claimRun(app.db, run.id); | |
| 84 | finishRun(app.db, run.id, { | |
| 85 | state: "failed", | |
| 86 | summary: "1 test failed", | |
| 87 | checkDetail: { passed: 3, failed: 1, names: ["adds two numbers"] }, | |
| 88 | }); | |
| 89 | const secret = getPull(app.db, repo.id, sealedPull)!; | |
| 90 | app.db.query("UPDATE issue SET sealed = 1 WHERE id = ?1").run(secret.id); | |
| 91 | aliceRw = tokenFor(app, "alice", "rw"); | |
| 92 | bobRw = tokenFor(app, "bob", "rw"); | |
| 93 | }, 30_000); | |
| 94 | ||
| 95 | afterAll(async () => { | |
| 96 | await runtime.stop(); | |
| 97 | servers.stop(); | |
| 98 | app.close(); | |
| 99 | await rm(dataDir, { recursive: true, force: true }); | |
| 100 | }); | |
| 101 | ||
| 102 | test("a production URL names the app and no pull", async () => { | |
No test sets up an active run, so |
||
| 103 | const r = await tool(bobRw, { url: `${host("hello")}/some/page?x=1` }); | |
| 104 | expect(r.value).toEqual({ | |
| 105 | app: "alice/hello", | |
| 106 | environment: "production", | |
| 107 | pull: null, | |
| 108 | check: null, | |
| 109 | firstFailure: null, | |
| 110 | logUrl: null, | |
| 111 | working: [], | |
| 112 | }); | |
| 113 | }); | |
| 114 | ||
| 115 | test("a preview URL with a red check says the pull, the failure and the log", async () => { | |
| 116 | const r = await tool(bobRw, { url: host(`hello--${redPull}`) }); | |
| 117 | expect(r.value.environment).toBe(`pull-${redPull}`); | |
| 118 | expect(r.value.pull).toEqual({ number: redPull, title: "Pull red" }); | |
| 119 | expect(r.value.check).toBe("red"); | |
| 120 | expect(r.value.firstFailure).toBe("adds two numbers"); | |
| 121 | // The log is a writer's: a reader gets the failure but not the link. | |
| 122 | expect(r.value.logUrl).toBeNull(); | |
| 123 | const owner = await tool(aliceRw, { url: host(`hello--${redPull}`) }); | |
| 124 | expect(owner.value.logUrl).toMatch(/\/alice\/hello\/runs\/[^/]+$/); | |
| 125 | }); | |
| 126 | ||
| 127 | test("working names the agents on this pull, not on another, and not on a sealed issue for bob", async () => { | |
| 128 | const repo = getRepo(app.db, "alice", "hello")!; | |
| 129 | const agent = await createUser(app.db, { origin: "seed", trusted: true, username: "worker", email: "[email protected]", password: "a-good-passphrase" }); | |
| 130 | if (!agent.ok) throw new Error(agent.error); | |
| 131 | const start = (issueId: string) => { | |
| 132 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", issueId, actorId: agent.value.id }); | |
| 133 | claimRun(app.db, run.id); | |
| 134 | }; | |
| 135 | const ids = (n: number) => getPull(app.db, repo.id, n)!.id; | |
| 136 | start(ids(sealedPull)); | |
| 137 | // Only a run on a sealed issue is active: bob learns of none, alice does. | |
| 138 | expect((await tool(bobRw, { url: host("hello") })).value.working).toEqual([]); | |
| 139 | expect((await tool(aliceRw, { url: host("hello") })).value.working).toEqual(["worker"]); | |
| 140 | expect((await tool(bobRw, { url: host(`hello--${redPull}`) })).value.working).toEqual([]); | |
| 141 | expect((await tool(aliceRw, { url: host(`hello--${redPull}`) })).value.working).toEqual([]); | |
| 142 | start(ids(redPull)); | |
| 143 | expect((await tool(bobRw, { url: host(`hello--${redPull}`) })).value.working).toEqual(["worker"]); | |
| 144 | expect((await tool(bobRw, { url: host("hello") })).value.working).toEqual(["worker"]); | |
| 145 | expect((await tool(aliceRw, { url: host(`hello--${sealedPull}`) })).value.working).toEqual(["worker"]); | |
| 146 | }); | |
| 147 | ||
| 148 | test("a host that is not an app host of this forge is refused", async () => { | |
| 149 | for (const url of ["https://example.com/", "not a url", "http://nothing.apps.test/", "ftp://hello.apps.test/"]) { | |
| 150 | const r = await tool(bobRw, { url }); | |
| 151 | expect(r.error).toBeDefined(); | |
| 152 | } | |
| 153 | expect((await tool(bobRw, { url: "https://example.com/" })).error).toContain("`url` is one of this forge's app hosts"); | |
| 154 | }); | |
| 155 | ||
| 156 | test("a pull the caller cannot see is 'no pull', as the pull tool says", async () => { | |
| 157 | const seen = await tool(aliceRw, { url: host(`hello--${sealedPull}`) }); | |
| 158 | expect(seen.value.pull.number).toBe(sealedPull); | |
| 159 | const hidden = await tool(bobRw, { url: host(`hello--${sealedPull}`) }); | |
| 160 | expect(hidden.error).toContain(`no pull #${sealedPull}`); | |
| 161 | const viaPull = await call(bobRw, "pull", { repo: "alice/hello", number: sealedPull }); | |
| 162 | expect(viaPull.error).toBe(hidden.error); | |
| 163 | }); | |
| 164 | ||
| 165 | test("it is listed in tools/list", async () => { | |
| 166 | const res = await fetch(`${servers.url}/mcp`, { | |
| 167 | method: "POST", | |
| 168 | headers: { "content-type": "application/json", accept: "application/json", authorization: `Bearer ${bobRw}` }, | |
| 169 | body: JSON.stringify({ jsonrpc: "2.0", id: 999, method: "tools/list" }), | |
| 170 | }); | |
| 171 | const body: any = await res.json(); | |
| 172 | expect(body.result.tools.some((t: any) => t.name === "preview")).toBe(true); | |
| 173 | }); | |
| 174 | }); |
test/mcp.test.ts +1
| @@ -181,6 +181,7 @@ describe("the forge over MCP", () => { | ||
| 181 | 181 | "say", |
| 182 | 182 | "issue", |
| 183 | 183 | "pull", |
| 184 | "preview", | |
| 184 | 185 | "pulls", |
| 185 | 186 | "variant", |
| 186 | 187 | "edit", |
Medium (cost per call):
runLogreads up to 512 KB of the log (readRunLog) just to computelogHidden, and the lines are thrown away. UsemayReadLogOfRun(app, user, repo, run.id)fromsrc/actions/runs.ts. It is the same gate without the read:if (mayReadLogOfRun(app, user, repo, run.id)) logUrl = ….