Commit2cc8e18
fix(mcp): use canonical repo owner/name for on-disk reads after CI lookup
fix(mcp): use canonical repo owner/name for on-disk reads after CI lookup Case-insensitive DB resolution alone was necessary but not sufficient: getBlob/repoExists/computeHealthScore still received the caller's raw casing, which builds an on-disk path (<owner>/<name>.git). On the case-sensitive Linux server, ccantynz/vapron would resolve the DB row but 404 on disk against Vapron.git. resolveReadableRepo now returns the canonical owner+name from the row, and the read/health tools use those for every filesystem op. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 file changed+21−82cc8e1889dc29ecce27bbdc51ce8fa1d30a25bdb
1 changed file+21−8
Modifiedsrc/lib/mcp-tools.ts+21−8View fileUnifiedSplit
@@ -118,18 +118,29 @@ const argNumber = (
118118 *
119119 * Privacy-preserving: an unknown repo AND a private repo the caller can't
120120 * read both throw the same ERR_METHOD_NOT_FOUND, so we never confirm a
121 * private repo's existence to a non-collaborator. Returns {id, isPrivate}.
121 * private repo's existence to a non-collaborator.
122 *
123 * Returns the CANONICAL owner + name from the DB row (not the caller's
124 * casing). Callers MUST use these for any on-disk git op (getBlob, repoExists,
125 * getRepoPath, computeHealthScore): the bare repo lives at
126 * `<owner>/<name>.git` on a case-sensitive Linux FS, so passing the raw
127 * user-typed casing would resolve the DB row but then 404 on disk.
122128 */
123129async function resolveReadableRepo(
124130 owner: string,
125131 repo: string,
126132 ctx: McpContext
127): Promise<{ id: string; isPrivate: boolean }> {
133): Promise<{ id: string; isPrivate: boolean; owner: string; name: string }> {
128134 // Case-insensitive owner/repo match — GitHub-style. Repo slugs are meant to
129135 // read as lowercase, but a caller typing "Vapron", "vapron", or "VAPRON"
130136 // must all resolve to the same repo instead of a confusing "not found".
131137 const [r] = await db
132 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
138 .select({
139 id: repositories.id,
140 isPrivate: repositories.isPrivate,
141 owner: users.username,
142 name: repositories.name,
143 })
133144 .from(repositories)
134145 .innerJoin(users, eq(repositories.ownerId, users.id))
135146 .where(
@@ -243,9 +254,10 @@ const repoReadFile: McpToolHandler = {
243254 const path = argString(args, "path");
244255
245256 // Readable if public, or private + the authenticated caller has access.
246 await resolveReadableRepo(owner, repo, ctx);
257 // Use the CANONICAL owner/name for the on-disk read (case-sensitive FS).
258 const canon = await resolveReadableRepo(owner, repo, ctx);
247259
248 const blob = await getBlob(owner, repo, ref, path);
260 const blob = await getBlob(canon.owner, canon.name, ref, path);
249261 if (!blob) {
250262 throw new McpError(
251263 ERR_METHOD_NOT_FOUND,
@@ -382,14 +394,15 @@ const repoHealth: McpToolHandler = {
382394 const owner = argString(args, "owner");
383395 const repo = argString(args, "repo");
384396
385 await resolveReadableRepo(owner, repo, ctx);
386 if (!(await repoExists(owner, repo))) {
397 // Canonical owner/name for the on-disk git ops (case-sensitive FS).
398 const canon = await resolveReadableRepo(owner, repo, ctx);
399 if (!(await repoExists(canon.owner, canon.name))) {
387400 throw new McpError(
388401 ERR_METHOD_NOT_FOUND,
389402 `${owner}/${repo} has no on-disk git data yet`
390403 );
391404 }
392 const report = await computeHealthScore(owner, repo);
405 const report = await computeHealthScore(canon.owner, canon.name);
393406 return {
394407 score: report.score,
395408 grade: report.grade,
396409