This repository has been archived on 2025-12-23. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
gitea-mcp/server.py
Ben 10906ab115 Initial commit: Gitea MCP Server
- 6 curated MCP tools (get_my_user_info, search_repos, list_my_repos, get_repo, list_repo_issues, list_repo_commits)
- API pass-through tool (gitea_api_call) for complete API coverage
- Curated API reference resource (gitea://api-reference)
- Health check endpoint
- Docker support with health checks
2025-12-22 04:45:30 +00:00

375 lines
12 KiB
Python

"""
Gitea MCP Server - Hybrid MCP Light implementation for Gitea API.
Provides 5 curated tools plus an API pass-through for complete API coverage.
"""
import json
import os
from contextlib import asynccontextmanager
from typing import Any
from urllib.parse import urljoin
import httpx
from dotenv import load_dotenv
from fastmcp import FastMCP
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Mount, Route
load_dotenv()
# Configuration
GITEA_URL = os.getenv("GITEA_URL", "").rstrip("/")
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
# Initialize MCP server
mcp = FastMCP(
"Gitea MCP",
instructions="MCP server for Gitea API - provides repository, issue, and user management",
)
class GiteaClient:
"""HTTP client for Gitea API with token authentication."""
def __init__(self, base_url: str, token: str):
self.base_url = base_url.rstrip("/")
self.api_url = f"{self.base_url}/api/v1"
self.token = token
self._client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(
headers={
"Authorization": f"token {self.token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=30.0,
)
return self._client
async def close(self):
if self._client:
await self._client.aclose()
self._client = None
async def request(
self,
method: str,
endpoint: str,
params: dict[str, Any] | None = None,
json_body: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Execute an API request to Gitea."""
client = await self._get_client()
url = f"{self.api_url}{endpoint}"
try:
response = await client.request(
method=method.upper(),
url=url,
params=params,
json=json_body,
)
response.raise_for_status()
if response.status_code == 204:
return {"status": "success", "message": "No content"}
return response.json()
except httpx.HTTPStatusError as e:
return {
"error": True,
"status_code": e.response.status_code,
"message": str(e),
"detail": e.response.text[:500] if e.response.text else None,
}
except Exception as e:
return {"error": True, "message": str(e)}
async def health_check(self) -> bool:
"""Check if Gitea is accessible."""
try:
client = await self._get_client()
response = await client.get(f"{self.api_url}/version")
return response.status_code == 200
except Exception:
return False
# Global client instance
client = GiteaClient(GITEA_URL, GITEA_TOKEN)
# =============================================================================
# Curated API Reference
# =============================================================================
API_REFERENCE = """
# Gitea API Quick Reference
For complete API documentation, see: {base_url}/api/swagger
## Common Endpoints for `gitea_api_call` tool
### Repository Operations
- GET `/repos/search?q={{keyword}}&limit=10` - Search repositories
- GET `/repos/{{owner}}/{{repo}}` - Get repository details
- POST `/repos/{{owner}}/{{repo}}` - Create repository (body: name, description, private)
- PATCH `/repos/{{owner}}/{{repo}}` - Update repository
- DELETE `/repos/{{owner}}/{{repo}}` - Delete repository
- GET `/repos/{{owner}}/{{repo}}/branches` - List branches
- GET `/repos/{{owner}}/{{repo}}/commits?limit=20` - List commits
### Issues
- GET `/repos/{{owner}}/{{repo}}/issues?state=open` - List issues
- POST `/repos/{{owner}}/{{repo}}/issues` - Create issue (body: title, body)
- GET `/repos/{{owner}}/{{repo}}/issues/{{index}}` - Get issue by number
- PATCH `/repos/{{owner}}/{{repo}}/issues/{{index}}` - Update issue
- POST `/repos/{{owner}}/{{repo}}/issues/{{index}}/comments` - Add comment (body: body)
### Pull Requests
- GET `/repos/{{owner}}/{{repo}}/pulls?state=open` - List PRs
- POST `/repos/{{owner}}/{{repo}}/pulls` - Create PR (body: title, head, base)
- GET `/repos/{{owner}}/{{repo}}/pulls/{{index}}` - Get PR by number
- POST `/repos/{{owner}}/{{repo}}/pulls/{{index}}/merge` - Merge PR
### File Operations
- GET `/repos/{{owner}}/{{repo}}/contents/{{filepath}}?ref={{branch}}` - Get file content
- PUT `/repos/{{owner}}/{{repo}}/contents/{{filepath}}` - Create/update file
- DELETE `/repos/{{owner}}/{{repo}}/contents/{{filepath}}` - Delete file
### Releases & Tags
- GET `/repos/{{owner}}/{{repo}}/releases` - List releases
- GET `/repos/{{owner}}/{{repo}}/releases/latest` - Get latest release
- POST `/repos/{{owner}}/{{repo}}/releases` - Create release
- GET `/repos/{{owner}}/{{repo}}/tags` - List tags
- POST `/repos/{{owner}}/{{repo}}/tags` - Create tag
### Actions/CI (Gitea Actions)
- GET `/repos/{{owner}}/{{repo}}/actions/runs` - List workflow runs
- GET `/repos/{{owner}}/{{repo}}/actions/jobs?status={{status}}` - List jobs
### User & Organizations
- GET `/user` - Get authenticated user
- GET `/users/{{username}}` - Get user by username
- GET `/user/repos?limit=50` - List authenticated user's repos
- GET `/orgs/{{org}}` - Get organization
- GET `/orgs/{{org}}/repos` - List organization repos
- GET `/orgs/{{org}}/members` - List organization members
### Pagination
Most list endpoints support `page` and `limit` query parameters.
Default limit is usually 20-50 items.
""".format(base_url=GITEA_URL)
@mcp.resource("gitea://api-reference")
def get_api_reference() -> str:
"""Returns the Gitea API quick reference for using the gitea_api_call tool."""
return API_REFERENCE
# =============================================================================
# MCP Tools - Curated Operations
# =============================================================================
@mcp.tool()
async def get_my_user_info() -> str:
"""Get information about the authenticated user.
Returns the current user's profile including username, email, and permissions.
This is useful for determining the authenticated identity before other operations.
"""
result = await client.request("GET", "/user")
return json.dumps(result)
@mcp.tool()
async def search_repos(
keyword: str,
limit: int = 20,
private: bool | None = None,
archived: bool | None = None,
) -> str:
"""Search for repositories by keyword.
Args:
keyword: Search term to find in repository names/descriptions
limit: Maximum number of results (default: 20, max: 50)
private: Filter by private status (optional)
archived: Filter by archived status (optional)
"""
params: dict[str, Any] = {"q": keyword, "limit": min(limit, 50)}
if private is not None:
params["private"] = private
if archived is not None:
params["archived"] = archived
result = await client.request("GET", "/repos/search", params=params)
return json.dumps(result)
@mcp.tool()
async def list_my_repos(
limit: int = 50,
page: int = 1,
) -> str:
"""List repositories owned by or accessible to the authenticated user.
Args:
limit: Maximum number of results per page (default: 50)
page: Page number for pagination (default: 1)
"""
params = {"limit": limit, "page": page}
result = await client.request("GET", "/user/repos", params=params)
return json.dumps(result)
@mcp.tool()
async def get_repo(owner: str, repo: str) -> str:
"""Get detailed information about a specific repository.
Args:
owner: Repository owner (username or organization)
repo: Repository name
"""
result = await client.request("GET", f"/repos/{owner}/{repo}")
return json.dumps(result)
@mcp.tool()
async def list_repo_issues(
owner: str,
repo: str,
state: str = "open",
limit: int = 30,
page: int = 1,
) -> str:
"""List issues for a repository.
Args:
owner: Repository owner (username or organization)
repo: Repository name
state: Issue state filter: 'open', 'closed', or 'all' (default: 'open')
limit: Maximum number of results (default: 30)
page: Page number for pagination (default: 1)
"""
params = {"state": state, "limit": limit, "page": page}
result = await client.request("GET", f"/repos/{owner}/{repo}/issues", params=params)
return json.dumps(result)
@mcp.tool()
async def list_repo_commits(
owner: str,
repo: str,
sha: str | None = None,
limit: int = 30,
page: int = 1,
) -> str:
"""List commits for a repository.
Args:
owner: Repository owner (username or organization)
repo: Repository name
sha: SHA or branch to start listing from (default: default branch)
limit: Maximum number of results (default: 30)
page: Page number for pagination (default: 1)
"""
params: dict[str, Any] = {"limit": limit, "page": page}
if sha:
params["sha"] = sha
result = await client.request("GET", f"/repos/{owner}/{repo}/commits", params=params)
return json.dumps(result)
# =============================================================================
# API Pass-through Tool
# =============================================================================
@mcp.tool()
async def gitea_api_call(
endpoint: str,
method: str = "GET",
params: str = "{}",
body: str = "{}",
) -> str:
"""Execute a raw API call to Gitea.
Use this for any operation not covered by the other tools.
Refer to the 'gitea://api-reference' resource for common endpoints,
or see the full API docs at {base_url}/api/swagger
Args:
endpoint: API endpoint path (e.g., '/repos/owner/repo/releases')
method: HTTP method (GET, POST, PUT, PATCH, DELETE)
params: JSON string of query parameters (optional)
body: JSON string of request body for POST/PUT/PATCH (optional)
Example:
gitea_api_call('/repos/myorg/myrepo/releases', 'POST',
body='{{"tag_name": "v1.0.0", "name": "Release 1.0"}}')
""".format(base_url=GITEA_URL)
try:
params_dict = json.loads(params) if params else {}
body_dict = json.loads(body) if body else {}
except json.JSONDecodeError as e:
return json.dumps({"error": True, "message": f"Invalid JSON: {e}"})
result = await client.request(
method=method,
endpoint=endpoint,
params=params_dict if params_dict else None,
json_body=body_dict if body_dict else None,
)
return json.dumps(result)
# =============================================================================
# Starlette Wrapper for Health Checks
# =============================================================================
async def health_check(request):
"""Health check endpoint for Docker/Kubernetes."""
is_healthy = await client.health_check()
if is_healthy:
return JSONResponse({"status": "ok", "gitea_url": GITEA_URL})
return JSONResponse(
{"status": "unhealthy", "message": "Cannot connect to Gitea"},
status_code=503,
)
@asynccontextmanager
async def lifespan(app):
"""Manage client lifecycle."""
yield
await client.close()
def create_app() -> Starlette:
"""Create the Starlette application with health check and MCP."""
mcp_app = mcp.http_app()
# Add health check route directly to the MCP app
mcp_app.add_route("/health", health_check, methods=["GET"])
return mcp_app
app = create_app()
if __name__ == "__main__":
import uvicorn
port = int(os.getenv("PORT", "8000"))
uvicorn.run(app, host="0.0.0.0", port=port)