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

MCP `read_file`: read a file or list a directory at a ref, with the same rules as GET …/contents #3810

Merged opened this

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

Merged

scout/forge:scout/3798-mcp-read-file-read-a-file-or-list-a-dire → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/3798-mcp-read-file-read-a-file-or-list-a-dire was merged into main as dadc1da.

Review

Sign in to review this change.
4 files changed +151 −0
docs/MCP.md +1
@@ -34,6 +34,7 @@ for a tunnel and a forge that sleeps.
3434 | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes |
3535 | `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` | |
3636 | `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 |
37 | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents | no |
3738 | `seal` | `{ repo, number, sealed }`: seal a security report (and the pull that fixes it) so only writers and the crew see it, or unseal it for every reader; a writer's, recorded | yes |
3839 | `now` | what the crew is doing right now: who, on what, since when, at what cost; a planner that declined the direction and waits for you comes first (`run: null`, its words in `says`) | |
3940 | `stop` | stop one run by its id from `now` | yes |
src/http/mcp.ts +55
@@ -112,6 +112,9 @@ import { pillarView } from "../core/pillars.ts";
112112 import { createBranch, deleteBranch } from "../actions/branches.ts";
113113 import { listBranches } from "../git/branches.ts";
114114 import { repoDir } from "../git/repos.ts";
115 import { runGit } from "../git/env.ts";
116 import { isSafeRef, isSafeTreePath } from "../git/guard.ts";
117 import { blobForRender, listTree } from "../git/read.ts";
115118 import { CREATE_ERRORS, DELETE_ERRORS } from "../web/pages/branches.ts";
116119 import { askToLiveFor } from "../web/ask_to_live.ts";
117120 import { getAppByRepo } from "../core/apps.ts";
@@ -810,6 +813,58 @@ const TOOLS: readonly Tool[] = [
810813 });
811814 },
812815 },
816 {
817 name: "read_file",
818 description:
819 "Read a file, or list a directory, of a repository at a ref (default branch); a file over 5 MB answers tooLarge with its size, a binary one base64. The same as GET /api/v1/repos/:owner/:name/contents.",
820 inputSchema: {
821 type: "object",
822 properties: {
823 repo: REPO_ARG,
824 path: { type: "string", maxLength: 1024 },
825 ref: { type: "string", maxLength: 256 },
826 },
827 required: ["repo", "path"],
828 },
829 writes: false,
830 async run(ctx, user, args) {
831 const repo = repoArg(ctx, user, args);
832 if (isAnswer(repo)) return repo;
833 const rawRef = args["ref"];
834 const rawPath = args["path"];
835 if (
836 (rawRef !== undefined && typeof rawRef !== "string") ||
837 typeof rawPath !== "string"
838 )
839 return bad("invalid", "path and ref are strings");
840 const ref = rawRef || repo.defaultBranch;
841 if (!isSafeRef(ref) || (rawPath !== "" && !isSafeTreePath(rawPath)))
842 return bad("not_found", "no such path at that ref");
843 const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name);

Not blocking. This body repeats contentsHandler in src/http/api.ts line for line, so "the same rules as GET …/contents" holds only as long as the two copies stay in step. A later follow-up could pull it into one plain-data function, for example readContents(dir, ref, path) in src/git/, which returns dir | file | tooLarge | missing. Both doors would then map that result: 404/413 for REST, a tool answer for MCP. The issue limited the files this pull could touch, so it's fine to leave for now.

844 const spec = rawPath === "" ? ref : `${ref}:${rawPath}`;
845 const type = await runGit(["cat-file", "-t", spec], { cwd: dir });
846 if (rawPath === "" || (type.ok && type.value === "tree")) {
847 const tree = await listTree(dir, ref, rawPath);
848 if (!tree.ok) return bad("not_found", "no such path at that ref");
849 return good({ type: "dir", ref, path: rawPath, entries: tree.value });
850 }
851 const blob = await blobForRender(dir, ref, rawPath, 5_000_000);
852 if (blob.kind === "missing") return bad("not_found", "no such path at that ref");
853 if (blob.kind === "too_large")
854 return good({ type: "file", tooLarge: true, size: blob.size });
855 const binary = blob.kind === "binary";
856 return good({
857 type: "file",
858 ref,
859 path: rawPath,
860 size: blob.size,
861 encoding: binary ? "base64" : "utf-8",
862 content: binary
863 ? Buffer.from(blob.body).toString("base64")
864 : new TextDecoder().decode(blob.body),
865 });
866 },
867 },
813868 {
814869 name: "asks",
815870 description:
test/mcp-read-file.test.ts added +94
@@ -0,0 +1,94 @@
1 /** MCP `read_file`: a file or a directory at a ref, as GET …/contents answers; a private repository is not found; bad arguments are refused. */
2 import { afterAll, beforeAll, 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 { commitToBranch } from "../src/actions/repos.ts";
8 import { createRepo } from "../src/core/repos.ts";
9 import { createToken } from "../src/core/tokens.ts";
10 import { createUser } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13
14 quietLogs();
15
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 const tok: Record<string, string> = {};
20
21 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
22 if (!r.ok) throw new Error(JSON.stringify(r.error));
23 return r.value;
24 };
25
26 async function read(token: string, args: unknown): Promise<{ error?: string; value?: any }> {
27 const res = await fetch(`${servers.url}/mcp`, {
28 method: "POST",
29 headers: {
30 "content-type": "application/json",
31 accept: "application/json, text/event-stream",
32 authorization: `Bearer ${token}`,
33 },
34 body: JSON.stringify({
35 jsonrpc: "2.0",
36 id: 1,
37 method: "tools/call",
38 params: { name: "read_file", arguments: args },
39 }),
40 });
41 const body = (await res.json()) as any;
42 const text: string = body.result.content[0].text;
43 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
44 }
45
46 beforeAll(async () => {
47 dataDir = await mkdtemp(join(tmpdir(), "forge-read-file-mcp-"));
48 app = createApp({ dataDir, port: 0 });
49 servers = await serve(app);
50 const make = (username: string) =>
51 createUser(app.db, {
52 origin: "seed",
53 trusted: true,
54 username,
55 email: `${username}@forge.local`,
56 password: "a-good-passphrase",
57 });
58 const owner = ok(await make("ada"));
59 const stranger = ok(await make("rita"));
60 const pub = ok(await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "pub", visibility: "public" }));
61 const priv = ok(await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "priv", visibility: "private" }));
62 for (const r of [pub, priv])
63 ok(await commitToBranch(app, owner, r, { message: "seed", files: [{ path: "src/a.txt", content: "hello\n" }] }));
64 tok["owner"] = createToken(app.db, owner.id, "o", "ro").token;
65 tok["stranger"] = createToken(app.db, stranger.id, "s", "ro").token;
66 }, 30_000);
67
68 afterAll(async () => {
69 servers?.stop();
70 app?.close();
71 await rm(dataDir, { recursive: true, force: true });
72 });
73
74 test("a file and a directory are answered as the contents route does", async () => {
75 const file = (await read(tok["stranger"]!, { repo: "ada/pub", path: "src/a.txt" })).value;
76 expect(file).toMatchObject({ type: "file", path: "src/a.txt", encoding: "utf-8", content: "hello\n", size: 6 });
77 const dir = (await read(tok["stranger"]!, { repo: "ada/pub", path: "src", ref: "main" })).value;
78 expect(dir.type).toBe("dir");
79 expect(JSON.stringify(dir.entries)).toContain("a.txt");
80 expect((await read(tok["stranger"]!, { repo: "ada/pub", path: "" })).value.type).toBe("dir");
81 });
82
83 test("a private repository is not found to a caller who cannot read it", async () => {
84 expect((await read(tok["owner"]!, { repo: "ada/priv", path: "src/a.txt" })).value.content).toBe("hello\n");
85 expect((await read(tok["stranger"]!, { repo: "ada/priv", path: "src/a.txt" })).error).toContain("not_found");
86 });
87
88 test("bad arguments are refused", async () => {
89 const t = tok["owner"]!;
90 expect((await read(t, { repo: "ada/pub", path: "../etc/passwd" })).error).toContain("not_found");
91 expect((await read(t, { repo: "ada/pub", path: "src/a.txt", ref: "--upload-pack=x" })).error).toContain("not_found");
92 expect((await read(t, { repo: "ada/pub", path: 7 })).error).toContain("invalid");
93 expect((await read(t, { repo: "ada/pub", path: "nope.txt" })).error).toContain("not_found");
94 });
test/mcp.test.ts +1
@@ -176,6 +176,7 @@ describe("the forge over MCP", () => {
176176 "pull",
177177 "variant",
178178 "edit",
179 "read_file",
179180 "asks",
180181 "asks_act",
181182 "asks_decide",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs