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

feat(mcp): expose repos as MCP resources so they appear in Claude Desktop

feat(mcp): expose repos as MCP resources so they appear in Claude Desktop

THE reason repos "couldn't be seen" in the Claude app. Server logs proved
the connector works (POST /mcp -> 200, tokens valid) — but initialize only
advertised `capabilities: { tools }`, never `resources`. Claude Desktop
only SHOWS things exposed as resources, so there was nothing to browse; you
could only use the repo by asking in chat.

Implements the resources surface the doc comment always promised:
- resources/list -> the caller's repos (public + their own private), each
  as gluecron://repo/<owner>/<name>, ordered by stars.
- resources/read -> the repo's README at its real default branch, using the
  same access-checked, case-insensitive, canonical-name gate as the read
  tools (private repos only readable by someone with access).
- initialize now advertises capabilities.resources when the provider is
  wired; the router (mcp.ts) stays DB-free — the provider is injected from
  routes/mcp.ts.

Regression tests cover capability advertisement + list/read routing +
invalid-params. Typecheck clean; suite 3122 pass / 4 pre-existing fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ccantynz-alt committed on July 24, 2026Parent: 527e1e3
4 files changed+19683a152198a00077320720c25900a52b69ec21270d
4 changed files+196−8
Addedsrc/__tests__/mcp-resources.test.ts+79−0View fileUnifiedSplit
1/**
2 * MCP resources: initialize must advertise the `resources` capability when a
3 * provider is wired, and resources/list + resources/read must route to it.
4 *
5 * Why this matters: Claude Desktop only SHOWS things a server exposes as
6 * resources. Before this, the server advertised only `tools`, so repos never
7 * appeared in the app's picker and users thought the repo "wasn't there".
8 * These tests lock the wiring with a stub provider (no DB needed).
9 */
10import { describe, it, expect } from "bun:test";
11import { routeMcpRequest } from "../lib/mcp";
12
13const ctx = { userId: "u1", scopes: ["repo"] };
14const tools = {};
15
16const stubResources = {
17 list: async () => ({
18 resources: [
19 { uri: "gluecron://repo/ccantynz/Vapron", name: "ccantynz/Vapron" },
20 ],
21 }),
22 read: async (uri: string) => ({
23 contents: [{ uri, mimeType: "text/markdown", text: "# hello" }],
24 }),
25};
26
27function rpc(method: string, params?: unknown) {
28 return { jsonrpc: "2.0" as const, id: 1, method, params };
29}
30
31describe("MCP resources capability", () => {
32 it("initialize advertises resources when a provider is wired", async () => {
33 const res: any = await routeMcpRequest(rpc("initialize"), {
34 ctx,
35 tools,
36 resources: stubResources,
37 });
38 expect(res.result.capabilities.resources).toBeDefined();
39 expect(res.result.capabilities.tools).toBeDefined();
40 });
41
42 it("initialize omits resources when NO provider is wired", async () => {
43 const res: any = await routeMcpRequest(rpc("initialize"), { ctx, tools });
44 expect(res.result.capabilities.resources).toBeUndefined();
45 });
46
47 it("resources/list routes to the provider", async () => {
48 const res: any = await routeMcpRequest(rpc("resources/list"), {
49 ctx,
50 tools,
51 resources: stubResources,
52 });
53 expect(res.result.resources).toHaveLength(1);
54 expect(res.result.resources[0].uri).toBe("gluecron://repo/ccantynz/Vapron");
55 });
56
57 it("resources/read passes the uri through and returns contents", async () => {
58 const res: any = await routeMcpRequest(
59 rpc("resources/read", { uri: "gluecron://repo/ccantynz/Vapron" }),
60 { ctx, tools, resources: stubResources }
61 );
62 expect(res.result.contents[0].text).toBe("# hello");
63 });
64
65 it("resources/read without a uri is an invalid-params error", async () => {
66 const res: any = await routeMcpRequest(rpc("resources/read", {}), {
67 ctx,
68 tools,
69 resources: stubResources,
70 });
71 expect(res.error).toBeDefined();
72 expect(res.error.code).toBe(-32602);
73 });
74
75 it("resources/list with no provider returns an empty list (no crash)", async () => {
76 const res: any = await routeMcpRequest(rpc("resources/list"), { ctx, tools });
77 expect(res.result.resources).toEqual([]);
78 });
79});
Modifiedsrc/lib/mcp-tools.ts+72−0View fileUnifiedSplit
188188 return def;
189189}
190190
191// ---------------------------------------------------------------------------
192// MCP resources — makes repos BROWSABLE in clients (Claude Desktop shows
193// resources in its attachment/resource picker). Without these, the server
194// only exposes tools, so a user sees nothing to pick and thinks the repo
195// "isn't there". resources/list returns the caller's repos (public + their
196// own private); resources/read returns a repo's README.
197// ---------------------------------------------------------------------------
198
199export type McpResource = {
200 uri: string;
201 name: string;
202 description?: string;
203 mimeType?: string;
204};
205
206export async function mcpListResources(
207 ctx: McpContext
208): Promise<{ resources: McpResource[] }> {
209 const visibility = ctx.userId
210 ? or(eq(repositories.isPrivate, false), eq(repositories.ownerId, ctx.userId))
211 : eq(repositories.isPrivate, false);
212 const rows = await db
213 .select({
214 name: repositories.name,
215 description: repositories.description,
216 owner: users.username,
217 isPrivate: repositories.isPrivate,
218 })
219 .from(repositories)
220 .innerJoin(users, eq(repositories.ownerId, users.id))
221 .where(visibility)
222 .orderBy(desc(repositories.starCount), asc(repositories.name))
223 .limit(100);
224 return {
225 resources: rows.map((r) => ({
226 uri: `gluecron://repo/${encodeURIComponent(r.owner)}/${encodeURIComponent(r.name)}`,
227 name: `${r.owner}/${r.name}`,
228 description:
229 (r.isPrivate ? "[private] " : "") +
230 (r.description || "Gluecron repository — README"),
231 mimeType: "text/markdown",
232 })),
233 };
234}
235
236export async function mcpReadResource(
237 uri: string,
238 ctx: McpContext
239): Promise<{ contents: Array<{ uri: string; mimeType: string; text: string }> }> {
240 const m = /^gluecron:\/\/repo\/([^/]+)\/(.+)$/.exec(uri || "");
241 if (!m) {
242 throw new McpError(ERR_INVALID_PARAMS, `unsupported resource uri: ${uri}`);
243 }
244 const owner = decodeURIComponent(m[1]);
245 const repo = decodeURIComponent(m[2]);
246 // Access-checked + canonical casing (same gate the read tools use).
247 const canon = await resolveReadableRepo(owner, repo, ctx);
248 const ref = await resolveReadRef(canon.owner, canon.name, "");
249 let text = "";
250 for (const p of ["README.md", "readme.md", "README", "README.markdown", "readme"]) {
251 const blob = await getBlob(canon.owner, canon.name, ref, p);
252 if (blob) {
253 text = blob.content;
254 break;
255 }
256 }
257 if (!text) {
258 text = `# ${canon.owner}/${canon.name}\n\n_(No README found in this repository.)_`;
259 }
260 return { contents: [{ uri, mimeType: "text/markdown", text }] };
261}
262
191263// ---------------------------------------------------------------------------
192264// gluecron_repo_search
193265// ---------------------------------------------------------------------------
Modifiedsrc/lib/mcp.ts+34−5View fileUnifiedSplit
6363 scopes?: string[];
6464};
6565
66export type McpResourceProvider = {
67 list: (ctx: McpContext) => Promise<{ resources: unknown[] }>;
68 read: (uri: string, ctx: McpContext) => Promise<{ contents: unknown[] }>;
69};
70
6671export type McpRouterArgs = {
6772 ctx: McpContext;
6873 tools: Record<string, McpToolHandler>;
74 /** Optional resource provider — enables resources/list + resources/read. */
75 resources?: McpResourceProvider;
6976};
7077
7178function isJsonRpcRequest(v: unknown): v is JsonRpcRequest {
138145): Promise<unknown> {
139146 switch (method) {
140147 case "initialize":
141 return handleInitialize();
148 return handleInitialize(args);
142149 case "notifications/initialized":
143150 // Client → server notification, no response.
144151 return null;
146153 return handleToolsList(args.tools);
147154 case "tools/call":
148155 return handleToolsCall(params, args);
156 case "resources/list":
157 if (!args.resources) return { resources: [] };
158 return args.resources.list(args.ctx);
159 case "resources/read": {
160 if (!args.resources) {
161 throw new McpError(ERR_METHOD_NOT_FOUND, "resources not supported");
162 }
163 const uri =
164 params && typeof params === "object"
165 ? (params as { uri?: unknown }).uri
166 : undefined;
167 if (typeof uri !== "string" || !uri) {
168 throw new McpError(ERR_INVALID_PARAMS, "resources/read requires a string 'uri'");
169 }
170 return args.resources.read(uri, args.ctx);
171 }
149172 case "ping":
150173 return {};
151174 default:
156179 }
157180}
158181
159function handleInitialize() {
182function handleInitialize(args: McpRouterArgs) {
183 const capabilities: Record<string, unknown> = {
184 tools: { listChanged: false },
185 };
186 // Only advertise resources when a provider is wired, so clients (Claude
187 // Desktop) show the repo picker exactly when we can actually serve it.
188 if (args.resources) {
189 capabilities.resources = { subscribe: false, listChanged: false };
190 }
160191 return {
161192 protocolVersion: MCP_PROTOCOL_VERSION,
162 capabilities: {
163 tools: { listChanged: false },
164 },
193 capabilities,
165194 serverInfo: {
166195 name: MCP_SERVER_NAME,
167196 version: MCP_SERVER_VERSION,
Modifiedsrc/routes/mcp.ts+11−3View fileUnifiedSplit
2626 MCP_SERVER_NAME,
2727 MCP_SERVER_VERSION,
2828} from "../lib/mcp";
29import { defaultTools } from "../lib/mcp-tools";
29import { defaultTools, mcpListResources, mcpReadResource } from "../lib/mcp-tools";
30
31// Resource provider: makes repos browsable in clients (Claude Desktop's
32// resource/attachment picker). ctx flows through so private repos only show
33// for their owner.
34const mcpResources = {
35 list: (ctx: any) => mcpListResources(ctx),
36 read: (uri: string, ctx: any) => mcpReadResource(uri, ctx),
37};
3038import { config } from "../lib/config";
3139
3240const mcp = new Hono<AuthEnv>();
180188 if (Array.isArray(body)) {
181189 // Batched request — pass each through, drop nulls (notifications).
182190 const out = await Promise.all(
183 body.map((entry) => routeMcpRequest(entry, { ctx, tools }))
191 body.map((entry) => routeMcpRequest(entry, { ctx, tools, resources: mcpResources }))
184192 );
185193 const filtered = out.filter((r): r is NonNullable<typeof r> => r !== null);
186194 if (filtered.length === 0) return c.body(null, 204);
187195 return c.json(filtered);
188196 }
189197
190 const result = await routeMcpRequest(body, { ctx, tools });
198 const result = await routeMcpRequest(body, { ctx, tools, resources: mcpResources });
191199 if (result === null) {
192200 // Notification — no response body, 204.
193201 return c.body(null, 204);
194202