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 <noreply@anthropic.com>
This commit is contained in:
Peter Foster
2025-12-17 02:24:16 +00:00
commit ddb530b673
5 changed files with 585 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
.env
*.log

60
README.md Normal file
View File

@@ -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)

345
index.js Executable file
View File

@@ -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);

161
package-lock.json generated Normal file
View File

@@ -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"
}
}
}
}

16
package.json Normal file
View File

@@ -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"
}
}