import { listRepos } from "../api/repos"; import { RPC, RpcError } from "./protocol"; import type { JsonObject, McpCaller } from "./protocol"; import { eventJson, eventsUri, repoHub, sessionHub, sessionSubscriber, subscriberOwner } from "./hub-do"; import { apiCtx, apiJson, scopedRepo } from "./tools/base"; import { readableEventTypes } from "./event-access"; import { MAX_FILE_BYTES, readFileAt, resolveRef } from "./tools/content"; const LISTED_REPOS = 50; const EVENTS_IN_RESOURCE = 50; export const RESOURCE_TEMPLATES = [ { uriTemplate: "gitfor://{owner}/{repo}/{ref}/{+path}", name: "repo-file", title: "Repository file", description: "A file at a branch, tag or commit. URL-encode refs that contain slashes (feature%2Fx).", }, { uriTemplate: "gitfor-events://{owner}/{repo}", name: "repo-events", title: "Repository events", description: "Recent push, review, CI and claim events. Subscribe to be notified when it changes.", mimeType: "application/json", }, ]; type Parsed = | { kind: "file"; owner: string; repo: string; ref: string; path: string } | { kind: "events"; owner: string; repo: string }; function decode(part: string): string { try { return decodeURIComponent(part); } catch { throw new RpcError(RPC.invalidParams, "Malformed resource URI"); } } export function parseResourceUri(uri: string): Parsed { const events = /^gitfor-events:\/\/([^/]+)\/([^/]+)\/?$/.exec(uri); if (events) return { kind: "events", owner: decode(events[1]), repo: decode(events[2]) }; const file = /^gitfor:\/\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/.exec(uri); if (file) { return { kind: "file", owner: decode(file[1]), repo: decode(file[2]), ref: decode(file[3]), path: file[4].split("/").map(decode).join("/"), }; } throw new RpcError(RPC.invalidParams, `Resource not found: ${uri}`); } const MIME_BY_EXTENSION: Record = { md: "text/markdown", json: "application/json", ts: "text/typescript", tsx: "text/typescript", js: "text/javascript", html: "text/html", css: "text/css", yml: "application/yaml", yaml: "application/yaml", }; function mimeFor(path: string, binary: boolean): string { const extension = path.split(".").pop()?.toLowerCase() ?? ""; return MIME_BY_EXTENSION[extension] ?? (binary ? "application/octet-stream" : "text/plain"); } /** Lists the events resource of each repository the grant can read; files are reached via the template. */ export async function listResources(caller: McpCaller): Promise { const repos = await apiJson>(listRepos(apiCtx(caller))); return { resources: repos.slice(0, LISTED_REPOS).map((repo) => ({ uri: eventsUri(repo.full_name), name: `${repo.full_name} events`, description: repo.description ?? undefined, mimeType: "application/json", })), }; } export async function readResource(caller: McpCaller, uri: string): Promise { const parsed = parseResourceUri(uri); const scoped = await scopedRepo(caller, parsed.owner, parsed.repo, "contents", "read").catch(() => { throw new RpcError(RPC.invalidParams, `Resource not found: ${uri}`); }); if (parsed.kind === "events") { const hub = repoHub(scoped.repo.id); const { latest } = await hub.eventsSince(Number.MAX_SAFE_INTEGER, 0, null); const readable = [...(await readableEventTypes(scoped.repo, caller.credential))]; const recent = await hub.eventsSince(Math.max(0, latest - EVENTS_IN_RESOURCE), EVENTS_IN_RESOURCE, readable); return { contents: [ { uri, mimeType: "application/json", text: JSON.stringify({ cursor: latest, events: recent.events.map(eventJson) }), }, ], }; } const revision = await resolveRef(scoped.repo, parsed.ref); const file = await readFileAt(scoped.repo, revision, parsed.path); const mimeType = mimeFor(file.path, file.binary); if (file.text !== null) return { contents: [{ uri, mimeType, text: file.text }] }; if (file.base64 !== null) return { contents: [{ uri, mimeType, blob: file.base64 }] }; throw new RpcError(RPC.invalidParams, `File exceeds ${MAX_FILE_BYTES} bytes; use read_file with a line window`); } /** 2025-era resources/subscribe: only the events resource changes, so only it can be subscribed. */ export async function subscribeResource(caller: McpCaller, uri: string, subscribe: boolean): Promise { if (!caller.sessionId) throw new RpcError(RPC.invalidRequest, "Subscriptions need a session"); const parsed = parseResourceUri(uri); if (parsed.kind !== "events") throw new RpcError(RPC.invalidParams, "Only gitfor-events:// resources can be subscribed"); const { repo } = await scopedRepo(caller, parsed.owner, parsed.repo, "contents", "read").catch(() => { throw new RpcError(RPC.invalidParams, `Resource not found: ${uri}`); }); const hub = sessionHub(caller.sessionId); const session = await hub.session(); if (!session) throw new RpcError(RPC.invalidRequest, "Session expired"); const canonical = eventsUri(`${repo.owner_name}/${repo.name}`); if (subscribe) { await repoHub(repo.id).subscribe(sessionSubscriber(caller.sessionId), ["*"], subscriberOwner(caller)); await hub.updateSession({ resourceUris: [...new Set([...session.resourceUris, canonical])], repos: [...new Set([...session.repos, repo.id])], }); } else { await hub.updateSession({ resourceUris: session.resourceUris.filter((item) => item !== canonical) }); if (!session.toolEvents[repo.id]) await repoHub(repo.id).unsubscribe(sessionSubscriber(caller.sessionId)); } }