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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3798-mcp-read-file-read-a-file-or-list-a-dire → main 1 commit fork 1 thread · 1 unresolved
4 files
changed +151
−0
docs/MCP.md +1
| @@ -34,6 +34,7 @@ for a tunnel and a forge that sleeps. | ||
| 34 | 34 | | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes | |
| 35 | 35 | | `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` | | |
| 36 | 36 | | `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 | | |
| 37 | 38 | | `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 | |
| 38 | 39 | | `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`) | | |
| 39 | 40 | | `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"; | ||
| 112 | 112 | import { createBranch, deleteBranch } from "../actions/branches.ts"; |
| 113 | 113 | import { listBranches } from "../git/branches.ts"; |
| 114 | 114 | 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"; | |
| 115 | 118 | import { CREATE_ERRORS, DELETE_ERRORS } from "../web/pages/branches.ts"; |
| 116 | 119 | import { askToLiveFor } from "../web/ask_to_live.ts"; |
| 117 | 120 | import { getAppByRepo } from "../core/apps.ts"; |
| @@ -810,6 +813,58 @@ const TOOLS: readonly Tool[] = [ | ||
| 810 | 813 | }); |
| 811 | 814 | }, |
| 812 | 815 | }, |
| 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); | |
| 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 | }, | |
| 813 | 868 | { |
| 814 | 869 | name: "asks", |
| 815 | 870 | 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", () => { | ||
| 176 | 176 | "pull", |
| 177 | 177 | "variant", |
| 178 | 178 | "edit", |
| 179 | "read_file", | |
| 179 | 180 | "asks", |
| 180 | 181 | "asks_act", |
| 181 | 182 | "asks_decide", |
new above
offline
Not blocking. This body repeats
contentsHandlerin 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 examplereadContents(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.