commit ddb530b6730bb3e9a2704cc4129f415eda7c08b1 Author: Peter Foster Date: Wed Dec 17 02:24:16 2025 +0000 Initial commit: MCP server for Gitea repository search Provides tools for searching files and content across all Gitea repositories: - gitea_list_repos, gitea_search_files, gitea_search_content - gitea_get_file, gitea_repo_info, gitea_list_files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e8157a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7f3817 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# MCP Gitea Search + +A Model Context Protocol (MCP) server for searching files and content across Gitea repositories. + +## Installation + +```bash +npm install +``` + +## Configuration + +Set environment variables: + +```bash +export GITEA_URL="http://your-gitea-server:3000" +export GITEA_TOKEN="your-api-token" +``` + +Or configure in Claude Code's `~/.claude/settings.json`: + +```json +{ + "mcpServers": { + "gitea": { + "command": "node", + "args": ["/path/to/mcp-gitea-search/index.js"], + "env": { + "GITEA_URL": "http://your-gitea-server:3000", + "GITEA_TOKEN": "your-api-token" + } + } + } +} +``` + +## Available Tools + +| Tool | Description | +|------|-------------| +| `gitea_list_repos` | List all accessible repositories | +| `gitea_search_files` | Search files by name pattern (regex) | +| `gitea_search_content` | Search within file contents | +| `gitea_get_file` | Get contents of a specific file | +| `gitea_repo_info` | Get repository details | +| `gitea_list_files` | List all files in a repo | + +## Usage Examples + +After configuring in Claude Code: + +- "Search my Gitea for all .csproj files" +- "Find files containing 'ConnectionString' across all repos" +- "Get the contents of peter/myrepo/appsettings.json" + +## Creating a Gitea API Token + +1. Go to Settings → Applications in your Gitea instance +2. Generate a new token with `read:repository` scope +3. Copy the token (shown only once) diff --git a/index.js b/index.js new file mode 100755 index 0000000..dab2739 --- /dev/null +++ b/index.js @@ -0,0 +1,345 @@ +#!/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); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..02fb8b6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,161 @@ +{ + "name": "mcp-gitea-search", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mcp-gitea-search", + "version": "1.0.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^0.5.0" + }, + "bin": { + "mcp-gitea-search": "index.js" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-0.5.0.tgz", + "integrity": "sha512-RXgulUX6ewvxjAG0kOpLMEdXXWkzWgaoCGaA2CwNW7cQCIphjpJhjpHSiaPdVCnisjRF/0Cm9KWHUuIoeiAblQ==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "raw-body": "^3.0.0", + "zod": "^3.23.8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..13f5c56 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "mcp-gitea-search", + "version": "1.0.0", + "description": "MCP server for searching Gitea repositories", + "main": "index.js", + "type": "module", + "bin": { + "mcp-gitea-search": "./index.js" + }, + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^0.5.0" + } +}