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

Merged opened this

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

Merged

scout/forge:scout/5828-mcp-preview-for-a-preview-url-the-app-th → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/5828-mcp-preview-for-a-preview-url-the-app-th was merged into main as 93c6105.

Review

Sign in to review this change.
5 files changed +275 −0
docs/MCP.md +1
@@ -37,6 +37,7 @@ for a tunnel and a forge that sleeps.
3737 | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes |
3838 | `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 | |
3939 | `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 | |
4041 | `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 |
4142 | `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 |
4243 | `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);

Medium (cost per call): runLog reads up to 512 KB of the log (readRunLog) just to compute logHidden, and the lines are thrown away. Use mayReadLogOfRun(app, user, repo, run.id) from src/actions/runs.ts. It is the same gate without the read: if (mayReadLogOfRun(app, user, repo, run.id)) logUrl = ….

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 (src/apps/crew.ts:52-56) scopes this with workingAgentRuns(db, repo.id, pull issueId) and filters by mayViewId(db, repo, r.issueId, user). Do the same, and add a test where a run on a sealed issue is not named for bob.

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 {
194194 import { plannerDeclines } from "../crew/autonomy.ts";
195195 import { checkStale } from "../checks/stale.ts";
196196 import { checkSummary, listActiveRuns } from "../core/runs.ts";
197 import { previewOf } from "../apps/preview_view.ts";
197198 import { scopeAllows } from "../core/tokens.ts";
198199 import { addSshKey, deleteSshKey } from "../actions/users.ts";
199200 import { listKeys, publicKey } from "../core/keys.ts";
@@ -1009,6 +1010,24 @@ const TOOLS: readonly Tool[] = [
10091010 };
10101011 },
10111012 },
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 },
10121031 {
10131032 name: "pulls",
10141033 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 working is only ever asserted as []. Add one with a running agent run: visible on this pull, hidden on a sealed issue, and absent when it belongs to another pull.

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", () => {
181181 "say",
182182 "issue",
183183 "pull",
184 "preview",
184185 "pulls",
185186 "variant",
186187 "edit",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs