#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; const GITEA_URL = process.env.GITEA_URL || "http://pfoster.dynu.net:3000"; const GITEA_TOKEN = process.env.GITEA_TOKEN || ""; async function apiGet(endpoint) { const headers = {}; if (GITEA_TOKEN) { headers["Authorization"] = `token ${GITEA_TOKEN}`; } const response = await fetch(`${GITEA_URL}/api/v1${endpoint}`, { headers }); if (!response.ok) { throw new Error(`API error: ${response.status} ${response.statusText}`); } return response.json(); } async function listRepos() { const allRepos = []; let page = 1; while (page < 20) { const data = await apiGet(`/repos/search?limit=50&page=${page}`); if (!data.data || data.data.length === 0) break; allRepos.push(...data.data); page++; } return allRepos; } async function searchFilesByName(pattern) { const repos = await listRepos(); const results = []; for (const repo of repos) { try { const tree = await apiGet( `/repos/${repo.owner.login}/${repo.name}/git/trees/${repo.default_branch}?recursive=true` ); if (tree.tree) { const regex = new RegExp(pattern, "i"); const matches = tree.tree .filter((f) => regex.test(f.path)) .map((f) => ({ repo: `${repo.owner.login}/${repo.name}`, path: f.path, type: f.type, url: `${GITEA_URL}/${repo.owner.login}/${repo.name}/src/branch/${repo.default_branch}/${f.path}`, })); results.push(...matches); } } catch (e) { // Skip repos we can't access } } return results; } async function searchFileContents(query) { const repos = await listRepos(); const results = []; for (const repo of repos) { try { // Use Gitea's code search API const data = await apiGet( `/repos/${repo.owner.login}/${repo.name}/search?q=${encodeURIComponent(query)}` ); if (data && Array.isArray(data) && data.length > 0) { results.push({ repo: `${repo.owner.login}/${repo.name}`, matches: data.map((m) => ({ path: m.path, lineNumber: m.line_number, content: m.content?.substring(0, 200), })), }); } } catch (e) { // Skip repos without code search or access } } return results; } async function getFileContent(repoFullName, filePath) { const [owner, repo] = repoFullName.split("/"); const data = await apiGet( `/repos/${owner}/${repo}/contents/${encodeURIComponent(filePath)}` ); if (data.content) { return Buffer.from(data.content, "base64").toString("utf-8"); } return null; } async function getRepoInfo(repoFullName) { const [owner, repo] = repoFullName.split("/"); return await apiGet(`/repos/${owner}/${repo}`); } // Create MCP server const server = new Server( { name: "mcp-gitea-search", version: "1.0.0", }, { capabilities: { tools: {}, }, } ); // List available tools server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "gitea_list_repos", description: "List all accessible repositories on the Gitea server", inputSchema: { type: "object", properties: {}, required: [], }, }, { name: "gitea_search_files", description: "Search for files by name pattern (regex) across all repositories", inputSchema: { type: "object", properties: { pattern: { type: "string", description: "Regex pattern to match file names (e.g., '\\.csproj$' for .csproj files, 'appsettings' for files containing appsettings)", }, }, required: ["pattern"], }, }, { name: "gitea_search_content", description: "Search for content within files across all repositories (requires Gitea code indexer)", inputSchema: { type: "object", properties: { query: { type: "string", description: "Text to search for in file contents", }, }, required: ["query"], }, }, { name: "gitea_get_file", description: "Get the contents of a specific file from a repository", inputSchema: { type: "object", properties: { repo: { type: "string", description: "Repository full name (e.g., 'owner/repo')", }, path: { type: "string", description: "File path within the repository", }, }, required: ["repo", "path"], }, }, { name: "gitea_repo_info", description: "Get information about a specific repository", inputSchema: { type: "object", properties: { repo: { type: "string", description: "Repository full name (e.g., 'owner/repo')", }, }, required: ["repo"], }, }, { name: "gitea_list_files", description: "List all files in a repository", inputSchema: { type: "object", properties: { repo: { type: "string", description: "Repository full name (e.g., 'owner/repo')", }, }, required: ["repo"], }, }, ], }; }); // Handle tool calls server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case "gitea_list_repos": { const repos = await listRepos(); const summary = repos.map((r) => ({ name: `${r.owner.login}/${r.name}`, description: r.description || "(no description)", stars: r.stars_count, updated: r.updated_at, })); return { content: [ { type: "text", text: JSON.stringify(summary, null, 2), }, ], }; } case "gitea_search_files": { const results = await searchFilesByName(args.pattern); return { content: [ { type: "text", text: results.length > 0 ? JSON.stringify(results, null, 2) : `No files found matching pattern: ${args.pattern}`, }, ], }; } case "gitea_search_content": { const results = await searchFileContents(args.query); return { content: [ { type: "text", text: results.length > 0 ? JSON.stringify(results, null, 2) : `No content found matching: ${args.query}`, }, ], }; } case "gitea_get_file": { const content = await getFileContent(args.repo, args.path); return { content: [ { type: "text", text: content || "File not found or empty", }, ], }; } case "gitea_repo_info": { const info = await getRepoInfo(args.repo); return { content: [ { type: "text", text: JSON.stringify(info, null, 2), }, ], }; } case "gitea_list_files": { const [owner, repo] = args.repo.split("/"); const repoInfo = await apiGet(`/repos/${owner}/${repo}`); const tree = await apiGet( `/repos/${owner}/${repo}/git/trees/${repoInfo.default_branch}?recursive=true` ); const files = tree.tree ?.filter((f) => f.type === "blob") .map((f) => f.path); return { content: [ { type: "text", text: JSON.stringify(files, null, 2), }, ], }; } default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], isError: true, }; } }); // Start server async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("Gitea MCP server running on stdio"); } main().catch(console.error);