import os import logging from typing import Any, Optional import httpx from fastapi import FastAPI, Request, HTTPException, Header from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from dotenv import load_dotenv from mcp.server import Server from mcp.server.sse import SseServerTransport from mcp.types import Tool, TextContent # Load environment variables load_dotenv() # Logging configuration logging.basicConfig(level=logging.INFO) logger = logging.getLogger("gitea-mcp-adapter") GITEA_URL = os.getenv("GITEA_URL", "https://git.carmona.digital").rstrip("/") GITEA_TOKEN = os.getenv("GITEA_TOKEN", "") app = FastAPI( title="Gitea MCP Adapter", description="Proxy wrapping Gitea REST API as an MCP server", version="1.0.0" ) # Enable CORS app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Initialize MCP Server mcp_server = Server("gitea-mcp-adapter") # Helper to get HTTPlient with Gitea headers def get_gitea_client(auth_header: Optional[str] = None) -> httpx.AsyncClient: headers = { "Accept": "application/json", "Content-Type": "application/json" } # Use token from Authorization header if present, otherwise fallback to GITEA_TOKEN token = GITEA_TOKEN if auth_header and auth_header.startswith("Bearer "): token = auth_header.split(" ")[1] if token: headers["Authorization"] = f"token {token}" return httpx.AsyncClient(base_url=GITEA_URL, headers=headers, timeout=15.0) # Define Gitea MCP Tools @mcp_server.list_tools() async def list_tools() -> list[Tool]: return [ Tool( name="issues_abiertos", description="List open issues in a Gitea repository. Supports filtering by labels.", inputSchema={ "type": "object", "properties": { "repo": {"type": "string", "description": "Repository name"}, "owner": {"type": "string", "description": "Repository owner/organization (default: estamos)", "default": "estamos"}, "labels": {"type": "string", "description": "Comma-separated list of labels to filter (optional)"} }, "required": ["repo"] } ), Tool( name="commits_recientes", description="Get the last N commits of a branch in a Gitea repository.", inputSchema={ "type": "object", "properties": { "repo": {"type": "string", "description": "Repository name"}, "owner": {"type": "string", "description": "Repository owner/organization (default: estamos)", "default": "estamos"}, "limit": {"type": "integer", "description": "Number of commits to return (default: 10)", "default": 10}, "branch": {"type": "string", "description": "Branch name (default: main)", "default": "main"} }, "required": ["repo"] } ), Tool( name="estado_actions", description="Get the last workflow runs of Gitea Actions for a Gitea repository.", inputSchema={ "type": "object", "properties": { "repo": {"type": "string", "description": "Repository name"}, "owner": {"type": "string", "description": "Repository owner/organization (default: estamos)", "default": "estamos"}, "limit": {"type": "integer", "description": "Number of runs to return (default: 5)", "default": 5} }, "required": ["repo"] } ), Tool( name="crear_issue", description="Create a new issue in a Gitea repository.", inputSchema={ "type": "object", "properties": { "repo": {"type": "string", "description": "Repository name"}, "owner": {"type": "string", "description": "Repository owner/organization (default: estamos)", "default": "estamos"}, "title": {"type": "string", "description": "Issue title"}, "body": {"type": "string", "description": "Issue description/body (optional)"}, "labels": { "type": "array", "items": {"type": "string"}, "description": "List of label names to apply (optional)" } }, "required": ["repo", "title"] } ), Tool( name="comentar_issue", description="Add a comment to an existing Gitea issue.", inputSchema={ "type": "object", "properties": { "repo": {"type": "string", "description": "Repository name"}, "owner": {"type": "string", "description": "Repository owner/organization (default: estamos)", "default": "estamos"}, "issue_number": {"type": "integer", "description": "The issue number"}, "body": {"type": "string", "description": "The comment body text"} }, "required": ["repo", "issue_number", "body"] } ), Tool( name="crear_pull_request", description="Create a new pull request in a Gitea repository.", inputSchema={ "type": "object", "properties": { "repo": {"type": "string", "description": "Repository name"}, "owner": {"type": "string", "description": "Repository owner/organization (default: estamos)", "default": "estamos"}, "title": {"type": "string", "description": "PR title"}, "body": {"type": "string", "description": "PR description/body (optional)"}, "head": {"type": "string", "description": "The branch where changes are implemented (source branch)"}, "base": {"type": "string", "description": "The branch into which changes should be merged (default: main)", "default": "main"} }, "required": ["repo", "title", "head"] } ) ] # Execute Gitea MCP Tools @mcp_server.call_tool() async def call_tool(name: str, arguments: dict) -> list[TextContent]: # Note: Since the call_tool is run within the MCP context, we can extract the Authorization header # if we pass it through context, but since MCP call_tool doesn't directly receive HTTP headers, # we default to the GITEA_TOKEN environment variable. async with get_gitea_client() as client: try: owner = arguments.get("owner", "estamos") repo = arguments.get("repo") if name == "issues_abiertos": params = {"state": "open", "type": "issues", "limit": 50} if arguments.get("labels"): params["labels"] = arguments["labels"] response = await client.get(f"/api/v1/repos/{owner}/{repo}/issues", params=params) response.raise_for_status() issues = response.json() rows = [ { "number": i.get("number"), "title": i.get("title"), "state": i.get("state"), "labels": [lb.get("name") for lb in (i.get("labels") or [])], "assignee": (i.get("assignee") or {}).get("login"), "created_at": i.get("created_at"), "html_url": i.get("html_url") } for i in (issues if isinstance(issues, list) else []) ] import json return [TextContent(type="text", text=json.dumps({"rows": rows, "count": len(rows), "repo": repo}))] elif name == "commits_recientes": limit = min(int(arguments.get("limit", 10)), 50) branch = arguments.get("branch", "main") params = {"sha": branch, "limit": limit} response = await client.get(f"/api/v1/repos/{owner}/{repo}/commits", params=params) response.raise_for_status() commits = response.json() rows = [ { "sha": c.get("sha", "")[:8], "message": (c.get("commit") or {}).get("message", "").split("\n")[0], "author": ((c.get("commit") or {}).get("author") or {}).get("name"), "date": ((c.get("commit") or {}).get("author") or {}).get("date") } for c in (commits if isinstance(commits, list) else []) ] import json return [TextContent(type="text", text=json.dumps({"rows": rows, "count": len(rows), "repo": repo, "branch": branch}))] elif name == "estado_actions": limit = min(int(arguments.get("limit", 5)), 20) try: response = await client.get(f"/api/v1/repos/{owner}/{repo}/actions/runs", params={"limit": limit}) response.raise_for_status() data = response.json() except httpx.HTTPStatusError as exc: if exc.response.status_code in (403, 404): import json return [TextContent(type="text", text=json.dumps({"available": False, "repo": repo}))] raise runs = data.get("workflow_runs") or [] if isinstance(data, dict) else [] rows = [ { "id": r.get("id"), "name": r.get("name"), "event": r.get("event"), "status": r.get("status"), "conclusion": r.get("conclusion"), "created_at": r.get("created_at") } for r in runs ] import json return [TextContent(type="text", text=json.dumps({"available": True, "rows": rows, "count": len(rows), "repo": repo}))] elif name == "crear_issue": title = arguments.get("title") body = arguments.get("body", "") labels = arguments.get("labels", []) # Get label IDs if names are provided label_ids = [] if labels: lbl_response = await client.get(f"/api/v1/repos/{owner}/{repo}/labels") if lbl_response.status_code == 200: all_labels = lbl_response.json() label_ids = [l.get("id") for l in all_labels if l.get("name") in labels] payload = { "title": title, "body": body, "labels": label_ids } response = await client.post(f"/api/v1/repos/{owner}/{repo}/issues", json=payload) response.raise_for_status() issue_data = response.json() import json return [TextContent(type="text", text=json.dumps({ "success": True, "number": issue_data.get("number"), "html_url": issue_data.get("html_url"), "title": issue_data.get("title") }))] elif name == "comentar_issue": issue_number = arguments.get("issue_number") body = arguments.get("body") payload = {"body": body} response = await client.post(f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments", json=payload) response.raise_for_status() comment_data = response.json() import json return [TextContent(type="text", text=json.dumps({ "success": True, "id": comment_data.get("id"), "html_url": comment_data.get("html_url") }))] elif name == "crear_pull_request": title = arguments.get("title") body = arguments.get("body", "") head = arguments.get("head") base = arguments.get("base", "main") payload = { "title": title, "body": body, "head": head, "base": base } response = await client.post(f"/api/v1/repos/{owner}/{repo}/pulls", json=payload) response.raise_for_status() pr_data = response.json() import json return [TextContent(type="text", text=json.dumps({ "success": True, "number": pr_data.get("number"), "html_url": pr_data.get("html_url"), "title": pr_data.get("title") }))] else: raise ValueError(f"Unknown tool: {name}") except httpx.HTTPStatusError as exc: logger.error(f"Gitea API error: {exc.response.status_code} - {exc.response.text}") raise HTTPException(status_code=exc.response.status_code, detail=f"Gitea API error: {exc.response.text}") except Exception as exc: logger.error(f"Unexpected error: {str(exc)}") raise HTTPException(status_code=500, detail=str(exc)) # Initialize SSE Transport sse_transport = SseServerTransport("/mcp/messages") @app.get("/mcp/sse") async def handle_sse(request: Request): """MCP SSE connection endpoint.""" async with sse_transport.connect_sse(request.scope, request.receive, request._send) as sse: await mcp_server.handle_request_loop(sse.reader, sse.writer) @app.post("/mcp/messages") async def handle_messages(request: Request): """MCP messages endpoint for POST requests.""" await sse_transport.handle_post_message(request.scope, request.receive, request._send) # A2A Agent Card Endpoint @app.get("/.well-known/agent.json") async def get_agent_card(request: Request): """Serves the Agent Card (A2A manifest) with dynamically resolved URLs.""" base_url = str(request.base_url).rstrip("/") card = { "name": "Gitea Adapter", "description": "MCP adapter for Gitea API REST. Wraps Gitea operations as an MCP server.", "version": "1.0.0", "url": base_url, "capabilities": { "streaming": True }, "skills": [ { "id": "issues_abiertos", "name": "Issues abiertos", "description": "Listar issues abiertos en un repositorio de Gitea", "tags": ["git", "gitea"] }, { "id": "commits_recientes", "name": "Commits recientes", "description": "Listar commits recientes en una rama", "tags": ["git", "gitea"] }, { "id": "estado_actions", "name": "Estado de Actions", "description": "Obtener el estado de las Ășltimas ejecuciones de Gitea Actions", "tags": ["git", "gitea", "ci"] }, { "id": "crear_issue", "name": "Crear issue", "description": "Crear un nuevo issue en un repositorio", "tags": ["git", "gitea"] }, { "id": "comentar_issue", "name": "Comentar issue", "description": "Agregar un comentario a un issue existente", "tags": ["git", "gitea"] }, { "id": "crear_pull_request", "name": "Crear pull request", "description": "Crear un nuevo pull request en un repositorio", "tags": ["git", "gitea"] } ], "authentication": { "schemes": ["bearer"] }, "extensions": { "mcp": { "endpoint": f"{base_url}/mcp/sse", "transport": "streamable-http" }, "capataz": { "glyph": "GT", "color": "#fc8019" } } } return JSONResponse(content=card) # Root status endpoint @app.get("/") async def root(): return { "status": "online", "service": "Gitea MCP Adapter", "endpoints": { "agent_card": "/.well-known/agent.json", "mcp_sse": "/mcp/sse", "mcp_messages": "/mcp/messages" } } if __name__ == "__main__": import uvicorn port = int(os.getenv("PORT", 8000)) host = os.getenv("HOST", "0.0.0.0") uvicorn.run("main:app", host=host, port=port, reload=True)