LUKE-338: read the plan's repository through GitHub's hosted MCP tools

Merged
CH
25d ago
+2357-159

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 as runUpdatePlan, 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 naming owner/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 pinned ai@7.0.97, so there is no second provider-utils. It connects to https://api.githubcopilot.com/mcp/x/repos/readonly, one client per call, and calls get_file_contents alone, with sha set to the plan's commit. The credential travels in the Authorization header 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 truncated past its bound (60k characters for a file, 500 entries for a directory).
    • Everything else is a not-read result 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 a download_url with a raw ?token=, and a test checks that it never reaches a result.
  • Plan start resolves the commit on the server. POST /api/plans now takes { name, repository: { owner, name } }. The service resolves the default branch to a commit through the account's GitHub connection (resolveRepository in github-source.ts, using GitHub's spelling of owner and name) and refuses a client-supplied branch or commit. The orchestrator approved this. Failures answer 409/502 with { error: "github-unavailable", reason } (packages/hosted/src/github-wire.ts).
  • GET /api/github/repositories lists the repositories the connection can read (owner, name, private), newest push first, up to 3 pages, and marks the list truncated past that. It exists for LUKE-337's picker.

Wired into the planning model (LUKE-336, #1733)

  • PLANNING_TOOLS in brain-host/planning.ts now offers get_file_contents next to update_plan, under the same plan binding. HOSTED_TOOL_SET picks it up through planningToolSet().
  • A new githubAccess seam on BrainHostSeams supplies GitHubAccess to the plan branch of the host's runTool. In production that seam is GITHUB_ACCESS_WITHOUT_CONNECTIONS.
  • hosted-planning.test.ts checks:
    • a planning turn is offered both tools;
    • the model's call through host.runTool reads the plan's private repo at the plan's commit;
    • a sha argument is refused;
    • with no connection, the call answers not-read with 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/ref arguments 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. planCreateRequestSchema lost branch/commit. The github-wire and get_file_contents input goldens are new.

./scripts/check.sh exits 0. There is no Mac or UI change.

šŸ¤– Generated with Claude Code


Open workspace in Conductor