Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit85fc0ef

fix(mcp): let authed callers read their own private repos via v1 read tools

fix(mcp): let authed callers read their own private repos via v1 read tools

The v1 "repo:*" READ tools (repo_read_file, repo_list_issues,
repo_explain_codebase, repo_health, repo_search) were hardcoded
public-only — they rejected EVERY private repo with
"...is private; v1 MCP read tool is public-only", even for the
authenticated owner. A user signed into the OAuth connector (e.g.
Claude Desktop) therefore could not see or read their own private
repos at all (Vapron never appeared).

Add resolveReadableRepo(owner, repo, ctx): readable when the repo is
public, or private AND the authenticated caller (ctx.userId) has read
access via resolveRepoAccess/satisfiesAccess (owner / collaborator /
org member). Privacy-preserving: unknown repo and unreadable private
repo both throw the same METHOD_NOT_FOUND, so we never confirm a
private repo's existence to a non-collaborator.

repo_search now also includes private repos owned by the caller
(public OR ownerId === ctx.userId) instead of filtering to public-only.

Regression test locks: no read-tool description says "public-only",
and unknown repos still throw METHOD_NOT_FOUND.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ccantynz-alt committed on July 24, 2026Parent: 1d74c37
2 files changed+1266285fc0ef218cbee4badded230eec490c234afbbc7
2 changed files+126−62
Addedsrc/__tests__/mcp-private-read.test.ts+73−0View fileUnifiedSplit
1/**
2 * Regression: the v1 "repo:*" READ tools must NOT be hardcoded public-only.
3 *
4 * They used to reject EVERY private repo with
5 * `${owner}/${repo} is private; v1 MCP read tool is public-only`
6 * even for the authenticated owner. That meant a user who signed into the
7 * OAuth connector (e.g. Claude Desktop) could not read their own private
8 * repos at all — Vapron never showed up. Now they gate on the caller's
9 * actual access via resolveReadableRepo(): public, OR private + caller can
10 * read (owner / collaborator / org member).
11 *
12 * Two invariants are locked here without needing a live DB:
13 * 1. No read-tool description still advertises "public-only".
14 * 2. Privacy contract: an unknown repo throws METHOD_NOT_FOUND (the same
15 * error a private repo the caller can't see would throw — we never
16 * confirm a private repo's existence).
17 */
18import { describe, it, expect } from "bun:test";
19import { ERR_METHOD_NOT_FOUND, McpError } from "../lib/mcp";
20import { defaultTools } from "../lib/mcp-tools";
21
22const HAS_DB = Boolean(process.env.DATABASE_URL);
23const anonCtx = { userId: null, scopes: [] };
24
25const READ_TOOLS = [
26 "gluecron_repo_read_file",
27 "gluecron_repo_list_issues",
28 "gluecron_repo_explain_codebase",
29 "gluecron_repo_health",
30 "gluecron_repo_search",
31];
32
33describe("v1 MCP read tools are no longer public-only", () => {
34 it("no read-tool description advertises 'public-only'", () => {
35 const tools = defaultTools();
36 for (const name of READ_TOOLS) {
37 const handler = tools[name];
38 expect(handler).toBeDefined();
39 expect(handler.tool.description.toLowerCase()).not.toContain("public-only");
40 }
41 });
42
43 it.skipIf(!HAS_DB)(
44 "unknown repo → METHOD_NOT_FOUND (privacy contract), for the read tools that resolve a specific repo",
45 async () => {
46 const tools = defaultTools();
47 const cases: Array<{ name: string; args: Record<string, unknown> }> = [
48 {
49 name: "gluecron_repo_read_file",
50 args: { owner: "nobody-xyz-fixture", repo: "nope", path: "README.md" },
51 },
52 {
53 name: "gluecron_repo_list_issues",
54 args: { owner: "nobody-xyz-fixture", repo: "nope" },
55 },
56 {
57 name: "gluecron_repo_explain_codebase",
58 args: { owner: "nobody-xyz-fixture", repo: "nope" },
59 },
60 {
61 name: "gluecron_repo_health",
62 args: { owner: "nobody-xyz-fixture", repo: "nope" },
63 },
64 ];
65 for (const tc of cases) {
66 const handler = tools[tc.name];
67 await expect(handler.run(tc.args, anonCtx)).rejects.toMatchObject({
68 code: ERR_METHOD_NOT_FOUND,
69 });
70 }
71 }
72 );
73});
Modifiedsrc/lib/mcp-tools.ts+53−62View fileUnifiedSplit
109109 throw new McpError(ERR_INVALID_PARAMS, `argument '${key}' must be a number`);
110110};
111111
112/**
113 * Gate for the "repo:*" read tools. A private repo is readable when the
114 * AUTHENTICATED caller (ctx.userId) has read access to it — owner,
115 * collaborator, or org member. These tools used to reject EVERY private repo
116 * ("v1 public-only"), which meant a user who signed in via the OAuth
117 * connector couldn't read their own private repos (e.g. Vapron) at all.
118 *
119 * Privacy-preserving: an unknown repo AND a private repo the caller can't
120 * 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}.
122 */
123async function resolveReadableRepo(
124 owner: string,
125 repo: string,
126 ctx: McpContext
127): Promise<{ id: string; isPrivate: boolean }> {
128 const [r] = await db
129 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
130 .from(repositories)
131 .innerJoin(users, eq(repositories.ownerId, users.id))
132 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
133 .limit(1);
134 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
135 if (r.isPrivate) {
136 const access = await resolveRepoAccess({
137 repoId: r.id,
138 userId: ctx.userId ?? null,
139 isPublic: false,
140 });
141 if (!satisfiesAccess(access, "read")) {
142 throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
143 }
144 }
145 return r;
146}
147
112148// ---------------------------------------------------------------------------
113149// gluecron_repo_search
114150// ---------------------------------------------------------------------------
117153 tool: {
118154 name: "gluecron_repo_search",
119155 description:
120 "Search public Gluecron repositories by keyword. Matches against name + description. Returns up to 20 results.",
156 "Search Gluecron repositories by keyword (name + description). Returns public repos plus any private repos owned by the authenticated caller. Up to 20 results.",
121157 annotations: { title: "Search repositories", readOnlyHint: true, destructiveHint: false },
122158 inputSchema: {
123159 type: "object",
128164 required: ["query"],
129165 },
130166 },
131 async run(args) {
167 async run(args, ctx) {
132168 const q = argString(args, "query");
133169 if (q.length > 100) {
134170 throw new McpError(ERR_INVALID_PARAMS, "query too long (max 100 chars)");
135171 }
136172 const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 20)));
137173 const pattern = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
174 // Visible = public, OR private-but-owned-by-the-authenticated-caller.
175 const visibility = ctx.userId
176 ? or(eq(repositories.isPrivate, false), eq(repositories.ownerId, ctx.userId))
177 : eq(repositories.isPrivate, false);
138178 const rows = await db
139179 .select({
140180 id: repositories.id,
147187 .innerJoin(users, eq(repositories.ownerId, users.id))
148188 .where(
149189 and(
150 eq(repositories.isPrivate, false),
190 visibility,
151191 or(
152192 like(repositories.name, pattern),
153193 like(repositories.description, pattern)
175215 tool: {
176216 name: "gluecron_repo_read_file",
177217 description:
178 "Read a single file from a public repository at a given ref (branch / tag / commit). Returns the text content (binary files rejected).",
218 "Read a single file from a repository at a given ref (branch / tag / commit). Private repos are readable when the authenticated caller has access. Returns the text content (binary files rejected).",
179219 annotations: { title: "Read repository file", readOnlyHint: true, destructiveHint: false },
180220 inputSchema: {
181221 type: "object",
188228 required: ["owner", "repo", "path"],
189229 },
190230 },
191 async run(args) {
231 async run(args, ctx) {
192232 const owner = argString(args, "owner");
193233 const repo = argString(args, "repo");
194234 const ref = argString(args, "ref", "main");
195235 const path = argString(args, "path");
196236
197 // Visibility check — public-only for v1. (Authed users see private
198 // repos in v2 once we extend the args.)
199 const [r] = await db
200 .select({ isPrivate: repositories.isPrivate })
201 .from(repositories)
202 .innerJoin(users, eq(repositories.ownerId, users.id))
203 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
204 .limit(1);
205 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
206 if (r.isPrivate) {
207 throw new McpError(
208 ERR_METHOD_NOT_FOUND,
209 `${owner}/${repo} is private; v1 MCP read tool is public-only`
210 );
211 }
237 // Readable if public, or private + the authenticated caller has access.
238 await resolveReadableRepo(owner, repo, ctx);
212239
213240 const blob = await getBlob(owner, repo, ref, path);
214241 if (!blob) {
248275 required: ["owner", "repo"],
249276 },
250277 },
251 async run(args) {
278 async run(args, ctx) {
252279 const owner = argString(args, "owner");
253280 const repo = argString(args, "repo");
254281 const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 25)));
255282
256 const [r] = await db
257 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
258 .from(repositories)
259 .innerJoin(users, eq(repositories.ownerId, users.id))
260 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
261 .limit(1);
262 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
263 if (r.isPrivate) {
264 throw new McpError(
265 ERR_METHOD_NOT_FOUND,
266 `${owner}/${repo} is private; v1 MCP read tool is public-only`
267 );
268 }
283 const r = await resolveReadableRepo(owner, repo, ctx);
269284
270285 const rows = await db
271286 .select({
311326 required: ["owner", "repo"],
312327 },
313328 },
314 async run(args) {
329 async run(args, ctx) {
315330 const owner = argString(args, "owner");
316331 const repo = argString(args, "repo");
317 const [r] = await db
318 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
319 .from(repositories)
320 .innerJoin(users, eq(repositories.ownerId, users.id))
321 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
322 .limit(1);
323 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
324 if (r.isPrivate) {
325 throw new McpError(
326 ERR_METHOD_NOT_FOUND,
327 `${owner}/${repo} is private; v1 MCP read tool is public-only`
328 );
329 }
332 const r = await resolveReadableRepo(owner, repo, ctx);
330333 const [row] = await db
331334 .select({
332335 commitSha: codebaseExplanations.commitSha,
367370 required: ["owner", "repo"],
368371 },
369372 },
370 async run(args) {
373 async run(args, ctx) {
371374 const owner = argString(args, "owner");
372375 const repo = argString(args, "repo");
373376
374 const [r] = await db
375 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
376 .from(repositories)
377 .innerJoin(users, eq(repositories.ownerId, users.id))
378 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
379 .limit(1);
380 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
381 if (r.isPrivate) {
382 throw new McpError(
383 ERR_METHOD_NOT_FOUND,
384 `${owner}/${repo} is private; v1 MCP tools are public-only`
385 );
386 }
377 await resolveReadableRepo(owner, repo, ctx);
387378 if (!(await repoExists(owner, repo))) {
388379 throw new McpError(
389380 ERR_METHOD_NOT_FOUND,
390381