import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import type { AppEnv } from "../config/env.js"; import { assertReadablePath } from "../integrations/gitea/policy.js"; import { listPortfolioRepositories, parsePortfolioFetchId, portfolioFetchId, type PortfolioRepository, } from "../integrations/gitea/portfolio.js"; import { getGiteaClient, recordMcpAudit } from "../integrations/gitea/service.js"; import type { GiteaContent } from "../integrations/gitea/types.js"; type ToolResult = { content: Array<{ type: "text"; text: string }>; structuredContent?: Record; isError?: boolean; }; const asResult = (data: unknown, message = "Operation completed"): ToolResult => ({ content: [{ type: "text", text: `${message}\n${JSON.stringify(data, null, 2)}` }], structuredContent: data && typeof data === "object" && !Array.isArray(data) ? (data as Record) : { data }, }); const asKnowledgeResult = (data: Record): ToolResult => ({ content: [{ type: "text", text: JSON.stringify(data) }], }); const decodeFile = (content: GiteaContent) => { if ( content.type !== "file" || content.encoding !== "base64" || typeof content.content !== "string" ) { throw new Error("GITEA_CONTENT_NOT_FILE"); } return Buffer.from(content.content.replace(/\s/g, ""), "base64").toString("utf8"); }; const mapBounded = async ( items: T[], limit: number, operation: (item: T) => Promise, ): Promise => { const output: R[] = []; for (let index = 0; index < items.length; index += limit) { output.push(...(await Promise.all(items.slice(index, index + limit).map(operation)))); } return output; }; const repositoryKey = (repository: Pick) => `${repository.owner}/${repository.repository}`.toLowerCase(); const errorCode = (error: unknown) => error instanceof Error ? error.message : "MCP_PORTFOLIO_OPERATION_FAILED"; const auditedRead = async ( actor: string, toolName: string, operation: () => Promise, context: { repository?: string; branch?: string } = {}, ): Promise => { const started = Date.now(); try { const result = await operation(); await recordMcpAudit({ actor, toolName, repository: context.repository, branch: context.branch, outcome: "succeeded", durationMs: Date.now() - started, metadata: { projection: "gitea-portfolio", readOnly: true }, }).catch(() => undefined); return result; } catch (error) { const code = errorCode(error); await recordMcpAudit({ actor, toolName, repository: context.repository, branch: context.branch, outcome: "failed", errorCode: code, durationMs: Date.now() - started, metadata: { projection: "gitea-portfolio", readOnly: true }, }).catch(() => undefined); return { content: [{ type: "text", text: `Operation failed: ${code}` }], structuredContent: { error: code }, isError: true, }; } }; /** * Register cross-repository search and fetch tools without changing the * existing portfolio catalog or the GiteaRepositoryLink ownership/write * boundary used by ProjectBrain delivery. */ export const registerPortfolioMcpTools = ( server: McpServer, env: AppEnv, actor: string, ) => { const client = getGiteaClient(env); server.registerTool( "search_portfolio_repositories", { title: "Search Gitea portfolio repositories", description: "Search code across every repository visible to ProjectBrain's scoped Gitea identity. Returned gitea-repository:// ids can be read with fetch_portfolio_file without creating ProjectBrain ownership links.", inputSchema: { query: z.string().trim().min(2).max(200).describe("Plain-text code search query"), }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, }, async ({ query }) => auditedRead(actor, "search_portfolio_repositories", async () => { const repositories = await listPortfolioRepositories(env); const searched = repositories.filter((repository) => !repository.archived); const batches = await mapBounded(searched, 6, async (repository) => { try { const response = await client.searchCode( repository.owner, repository.repository, query, repository.defaultBranch, ); const rows = Array.isArray(response) ? response : response.data ?? []; return { results: rows.map((row) => ({ id: portfolioFetchId(repository, repository.defaultBranch, row.path), title: `${repository.fullName}: ${row.path}`, url: row.html_url, metadata: { repository: repository.fullName, ref: repository.defaultBranch, path: row.path, linked: repository.linked, projectId: repository.link?.projectSlug, }, })), }; } catch { return { results: [], failedRepository: repository.fullName }; } }); const allResults = batches.flatMap((batch) => batch.results); const failedRepositories = batches .map((batch) => batch.failedRepository) .filter((value): value is string => typeof value === "string"); return asKnowledgeResult({ results: allResults.slice(0, 50), metadata: { repositoryCount: repositories.length, searchedRepositoryCount: searched.length, failedRepositories, truncated: allResults.length > 50, }, }); }), ); server.registerTool( "fetch_portfolio_file", { title: "Fetch Gitea portfolio file", description: "Read one text file by the canonical gitea-repository:// id returned by search_portfolio_repositories. The repository must still be visible to ProjectBrain's scoped Gitea identity and sensitive paths remain blocked.", inputSchema: { id: z .string() .startsWith("gitea-repository://") .max(2_000) .describe("Canonical gitea-repository://owner/repository/ref/path identifier"), }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, }, async ({ id }) => { let parsed: ReturnType; try { parsed = parsePortfolioFetchId(id); } catch (error) { return asResult({ error: errorCode(error) }, "Invalid portfolio fetch id"); } return auditedRead( actor, "fetch_portfolio_file", async () => { const repositories = await listPortfolioRepositories(env); const visible = new Map(repositories.map((repository) => [repositoryKey(repository), repository])); const repository = visible.get(`${parsed.owner}/${parsed.repository}`.toLowerCase()); if (!repository) throw new Error("GITEA_REPOSITORY_NOT_VISIBLE"); const path = assertReadablePath(parsed.path); const file = await client.getContents( repository.owner, repository.repository, path, parsed.ref, ); if (Array.isArray(file)) throw new Error("GITEA_CONTENT_NOT_FILE"); return asKnowledgeResult({ id, title: `${repository.fullName}: ${path}`, text: decodeFile(file), url: file.html_url, metadata: { repository: repository.fullName, path, ref: parsed.ref, sha: file.sha, size: file.size, linked: repository.linked, projectId: repository.link?.projectSlug, }, }); }, { repository: `${parsed.owner}/${parsed.repository}`, branch: parsed.ref }, ); }, ); };