LUKE-338: read the plan's repository through GitHub's hosted MCP tools
Linear: LUKE-338
What
get_file_contents({ path }), the planning model's one source read (apps/web/server/hosted/repository-tools.ts).GET_FILE_CONTENTS_TOOL+runGetFileContents(binding, input), bound to{ userId, planId }in the same shape asrunUpdatePlan, so LUKE-336 can hand it to the planning model. The repository and commit come from the plan's row on every call. A resumed conversation reads the commit the plan started at. Arguments namingowner/repo/ref/sha, and paths with..,., empty segments,?,#,%or control characters, are refused before anything is sent.- The MCP client (
github-mcp.ts):@ai-sdk/mcp@2.0.48, the release that matches the pinnedai@7.0.97, so there is no secondprovider-utils. It connects tohttps://api.githubcopilot.com/mcp/x/repos/readonly, one client per call, and callsget_file_contentsalone, withshaset to the plan's commit. The credential travels in theAuthorizationheader and nowhere else. - Results are read into the tool's own shape:
- a directory comes back as
{ path, type, size }entries; - a file comes back as its text;
- either one names the path, repository, and commit, and is cut and marked
truncatedpast its bound (60k characters for a file, 500 entries for a directory). - Everything else is a
not-readresult that says nothing was read and why: not connected, access denied (revoked), not found (with GitHub's path suggestions), binary, over GitHub's 1 MB limit, rate limited, failed, or store unavailable. GitHub's own text is never forwarded. On a private repo, GitHub's large-file message includes adownload_urlwith a raw?token=, and a test checks that it never reaches a result.
- a directory comes back as
- Plan start resolves the commit on the server.
POST /api/plansnow takes{ name, repository: { owner, name } }. The service resolves the default branch to a commit through the account's GitHub connection (resolveRepositoryingithub-source.ts, using GitHub's spelling of owner and name) and refuses a client-suppliedbranchorcommit. The orchestrator approved this. Failures answer409/502with{ error: "github-unavailable", reason }(packages/hosted/src/github-wire.ts). GET /api/github/repositorieslists the repositories the connection can read (owner, name, private), newest push first, up to 3 pages, and marks the listtruncatedpast that. It exists for LUKE-337's picker.
Wired into the planning model (LUKE-336, #1733)
PLANNING_TOOLSinbrain-host/planning.tsnow offersget_file_contentsnext toupdate_plan, under the same plan binding.HOSTED_TOOL_SETpicks it up throughplanningToolSet().- A new
githubAccessseam onBrainHostSeamssuppliesGitHubAccessto the plan branch of the host'srunTool. In production that seam isGITHUB_ACCESS_WITHOUT_CONNECTIONS. hosted-planning.test.tschecks:- a planning turn is offered both tools;
- the model's call through
host.runToolreads the plan's private repo at the plan's commit; - a
shaargument is refused; - with no connection, the call answers
not-readwith the not-connected reason.
Not in this PR: the GitHub connection itself
Luke has no account-bound GitHub authorization that can read repositories today: GitHub sign-in asks for read:user user:email only. GitHubAccess (token(userId)) is the one seam the connection flow fills. Until it lands, production uses githubAccessWithoutConnections: every account answers not-connected, so reads and plan starts fail visibly as "connect GitHub" and nothing is claimed as read. The connection flow will be a follow-up PR ("LUKE-338 GitHub connection"). It waits on the user's choice between a GitHub App user authorization and reusing the OAuth App with repo scope, and on approval of the PRIVACY.md text.
Tests
Hermetic tests with a GitHub fake at the process boundary (tests/support/github-fake.ts). The fake serves GitHub's REST reads and the MCP endpoint (JSON-RPC, in the real server's result shapes) through FetchHttpClient.Fetch, the one fetch that both Effect's HttpClient and the MCP transport send through, so the real MCP client does the talking.
repository-tools.test.ts:- listing and file reads on a private repo;
- the commit stays pinned after the branch moves on and after a resume;
owner/repo/sha/refarguments and path escapes are refused;- another account's plan is refused, even when that account has its own connection;
- revoked access, a repository withdrawn from the connection, and no connection each give an explicit reason;
- binary, oversized, and missing files are reported explicitly;
- file and directory truncation;
- MCP 5xx and store failure;
- no token or raw download token appears in any result.
plans-app.test.ts: the commit is resolved on the server; a client-supplied branch or commit is refused; not-connected, not-found, empty-repository, and access-denied each start no plan.github-app.test.ts: the list, its truncation, and its refusals.- Wire and tool-input JSON Schema goldens were recorded with
LUKE_UPDATE_FIXTURES=1.planCreateRequestSchemalostbranch/commit. Thegithub-wireandget_file_contentsinput goldens are new.
./scripts/check.sh exits 0. There is no Mac or UI change.
š¤ Generated with Claude Code