"""Gitea MCP server — git-style tools for a self-hosted Gitea instance.""" from __future__ import annotations import base64 import os import httpx from mcp.server.fastmcp import FastMCP mcp = FastMCP( "Gitea MCP", instructions="MCP server for interacting with a Gitea instance. " "Provides tools for repositories, issues, pull requests, files, branches, " "releases, organizations, and users.", ) GITEA_URL = os.environ.get("GITEA_URL", "http://localhost:3000") GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "") _TIMEOUT = 15.0 def _client() -> httpx.Client: return httpx.Client( base_url=f"{GITEA_URL}/api/v1", headers={ "Authorization": f"token {GITEA_TOKEN}", "Accept": "application/json", "Content-Type": "application/json", }, timeout=_TIMEOUT, ) def _request(method: str, path: str, **kwargs) -> dict | list | str: with _client() as c: try: resp = c.request(method, path, **kwargs) resp.raise_for_status() if resp.status_code == 204: return {"ok": True} return resp.json() except httpx.HTTPStatusError as e: body = e.response.text[:500] return {"error": f"HTTP {e.response.status_code}", "detail": body} except httpx.RequestError as e: return {"error": str(e)} def _get(path: str, **params) -> dict | list | str: return _request("GET", path, params=params) def _post(path: str, body: dict | None = None) -> dict | list | str: return _request("POST", path, json=body or {}) def _patch(path: str, body: dict) -> dict | list | str: return _request("PATCH", path, json=body) def _delete(path: str) -> dict | list | str: return _request("DELETE", path) # ─────────────────────────────────────────────────────────────────── # User / Auth # ─────────────────────────────────────────────────────────────────── @mcp.tool() def get_authenticated_user() -> dict | list | str: """Get the currently authenticated Gitea user.""" return _get("/user") # ─────────────────────────────────────────────────────────────────── # Repositories # ─────────────────────────────────────────────────────────────────── @mcp.tool() def list_repos(limit: int = 20, page: int = 1) -> dict | list | str: """List repositories accessible to the authenticated user. Args: limit: Number of results per page. Default 20. page: Page number. Default 1. """ return _get("/user/repos", limit=limit, page=page) @mcp.tool() def get_repo(owner: str, repo: str) -> dict | list | str: """Get details of a repository. Args: owner: Repository owner username. repo: Repository name. """ return _get(f"/repos/{owner}/{repo}") @mcp.tool() def create_repo( name: str, description: str = "", private: bool = False, auto_init: bool = True, default_branch: str = "main", ) -> dict | list | str: """Create a new repository for the authenticated user. Args: name: Repository name. description: Repository description. private: Whether the repo is private. Default False. auto_init: Initialize with a README. Default True. default_branch: Default branch name. Default "main". """ return _post( "/user/repos", { "name": name, "description": description, "private": private, "auto_init": auto_init, "default_branch": default_branch, }, ) @mcp.tool() def delete_repo(owner: str, repo: str) -> dict | list | str: """Delete a repository. This is irreversible. Args: owner: Repository owner username. repo: Repository name. """ return _delete(f"/repos/{owner}/{repo}") @mcp.tool() def search_repos(query: str, limit: int = 10) -> dict | list | str: """Search for repositories. Args: query: Search query string. limit: Maximum results to return. Default 10. """ return _get("/repos/search", q=query, limit=limit) @mcp.tool() def fork_repo(owner: str, repo: str, new_name: str | None = None) -> dict | list | str: """Fork a repository. Args: owner: Owner of the repository to fork. repo: Repository name to fork. new_name: Optional new name for the fork. """ body: dict = {} if new_name: body["name"] = new_name return _post(f"/repos/{owner}/{repo}/forks", body) # ─────────────────────────────────────────────────────────────────── # Branches # ─────────────────────────────────────────────────────────────────── @mcp.tool() def list_branches(owner: str, repo: str) -> dict | list | str: """List branches of a repository. Args: owner: Repository owner. repo: Repository name. """ return _get(f"/repos/{owner}/{repo}/branches") @mcp.tool() def get_branch(owner: str, repo: str, branch: str) -> dict | list | str: """Get details of a specific branch. Args: owner: Repository owner. repo: Repository name. branch: Branch name. """ return _get(f"/repos/{owner}/{repo}/branches/{branch}") @mcp.tool() def create_branch( owner: str, repo: str, branch_name: str, old_branch: str = "main" ) -> dict | list | str: """Create a new branch. Args: owner: Repository owner. repo: Repository name. branch_name: Name of the new branch. old_branch: Branch to create from. Default "main". """ return _post( f"/repos/{owner}/{repo}/branches", { "new_branch_name": branch_name, "old_branch_name": old_branch, }, ) @mcp.tool() def delete_branch(owner: str, repo: str, branch: str) -> dict | list | str: """Delete a branch. Args: owner: Repository owner. repo: Repository name. branch: Branch name to delete. """ return _delete(f"/repos/{owner}/{repo}/branches/{branch}") # ─────────────────────────────────────────────────────────────────── # File contents # ─────────────────────────────────────────────────────────────────── @mcp.tool() def get_file( owner: str, repo: str, filepath: str, ref: str | None = None ) -> dict | list | str: """Get the contents of a file from a repository. Returns decoded text content. Args: owner: Repository owner. repo: Repository name. filepath: Path to the file in the repo. ref: Optional branch/tag/commit to read from. """ params = {} if ref: params["ref"] = ref result = _get(f"/repos/{owner}/{repo}/contents/{filepath}", **params) # Decode base64 content for convenience if ( isinstance(result, dict) and "content" in result and result.get("encoding") == "base64" ): try: result["content"] = base64.b64decode(result["content"]).decode( "utf-8", errors="replace" ) result["encoding"] = "utf-8" except Exception: pass return result @mcp.tool() def create_or_update_file( owner: str, repo: str, filepath: str, content: str, message: str, branch: str | None = None, sha: str | None = None, ) -> dict | list | str: """Create or update a file in a repository. To update an existing file you must provide the current sha (from get_file). Args: owner: Repository owner. repo: Repository name. filepath: Path where the file will be created/updated. content: File content (plain text, will be base64-encoded). message: Commit message. branch: Branch to commit to. Uses repo default if omitted. sha: Current SHA of the file (required for updates, omit for creation). """ body: dict = { "content": base64.b64encode(content.encode()).decode(), "message": message, } if branch: body["branch"] = branch if sha: body["sha"] = sha method = "PUT" with _client() as c: try: resp = c.request( method, f"/repos/{owner}/{repo}/contents/{filepath}", json=body ) resp.raise_for_status() return resp.json() except httpx.HTTPStatusError as e: return { "error": f"HTTP {e.response.status_code}", "detail": e.response.text[:500], } except httpx.RequestError as e: return {"error": str(e)} @mcp.tool() def delete_file( owner: str, repo: str, filepath: str, message: str, sha: str, branch: str | None = None, ) -> dict | list | str: """Delete a file from a repository. Args: owner: Repository owner. repo: Repository name. filepath: Path of the file to delete. message: Commit message. sha: Current SHA of the file (from get_file). branch: Branch to delete from. Uses repo default if omitted. """ body: dict = {"message": message, "sha": sha} if branch: body["branch"] = branch with _client() as c: try: resp = c.request( "DELETE", f"/repos/{owner}/{repo}/contents/{filepath}", json=body ) resp.raise_for_status() return resp.json() except httpx.HTTPStatusError as e: return { "error": f"HTTP {e.response.status_code}", "detail": e.response.text[:500], } except httpx.RequestError as e: return {"error": str(e)} @mcp.tool() def list_directory( owner: str, repo: str, path: str = "", ref: str | None = None ) -> dict | list | str: """List files and directories at a path in a repository. Args: owner: Repository owner. repo: Repository name. path: Directory path. Empty string for root. ref: Optional branch/tag/commit. """ params = {} if ref: params["ref"] = ref return _get(f"/repos/{owner}/{repo}/contents/{path}", **params) # ─────────────────────────────────────────────────────────────────── # Commits # ─────────────────────────────────────────────────────────────────── @mcp.tool() def list_commits( owner: str, repo: str, sha: str | None = None, limit: int = 10, page: int = 1 ) -> dict | list | str: """List commits in a repository. Args: owner: Repository owner. repo: Repository name. sha: Optional branch/tag/commit SHA to list from. limit: Number of commits per page. Default 10. page: Page number. Default 1. """ params: dict = {"limit": limit, "page": page} if sha: params["sha"] = sha return _get(f"/repos/{owner}/{repo}/git/commits", **params) @mcp.tool() def get_commit(owner: str, repo: str, sha: str) -> dict | list | str: """Get details of a specific commit. Args: owner: Repository owner. repo: Repository name. sha: Commit SHA. """ return _get(f"/repos/{owner}/{repo}/git/commits/{sha}") # ─────────────────────────────────────────────────────────────────── # Issues # ─────────────────────────────────────────────────────────────────── @mcp.tool() def list_issues( owner: str, repo: str, state: str = "open", labels: str | None = None, limit: int = 20, page: int = 1, ) -> dict | list | str: """List issues in a repository. Args: owner: Repository owner. repo: Repository name. state: Filter by state: "open", "closed", or "all". Default "open". labels: Comma-separated label names to filter by. limit: Results per page. Default 20. page: Page number. Default 1. """ params: dict = {"state": state, "limit": limit, "page": page, "type": "issues"} if labels: params["labels"] = labels return _get(f"/repos/{owner}/{repo}/issues", **params) @mcp.tool() def get_issue(owner: str, repo: str, index: int) -> dict | list | str: """Get details of an issue. Args: owner: Repository owner. repo: Repository name. index: Issue number. """ return _get(f"/repos/{owner}/{repo}/issues/{index}") @mcp.tool() def create_issue( owner: str, repo: str, title: str, body: str = "", labels: list[int] | None = None, assignees: list[str] | None = None, ) -> dict | list | str: """Create a new issue. Args: owner: Repository owner. repo: Repository name. title: Issue title. body: Issue body/description. labels: List of label IDs to add. assignees: List of usernames to assign. """ payload: dict = {"title": title, "body": body} if labels: payload["labels"] = labels if assignees: payload["assignees"] = assignees return _post(f"/repos/{owner}/{repo}/issues", payload) @mcp.tool() def edit_issue( owner: str, repo: str, index: int, title: str | None = None, body: str | None = None, state: str | None = None, ) -> dict | list | str: """Edit an existing issue. Args: owner: Repository owner. repo: Repository name. index: Issue number. title: New title (omit to keep current). body: New body (omit to keep current). state: Set to "open" or "closed". """ payload: dict = {} if title is not None: payload["title"] = title if body is not None: payload["body"] = body if state is not None: payload["state"] = state return _patch(f"/repos/{owner}/{repo}/issues/{index}", payload) @mcp.tool() def list_issue_comments(owner: str, repo: str, index: int) -> dict | list | str: """List comments on an issue. Args: owner: Repository owner. repo: Repository name. index: Issue number. """ return _get(f"/repos/{owner}/{repo}/issues/{index}/comments") @mcp.tool() def create_issue_comment( owner: str, repo: str, index: int, body: str ) -> dict | list | str: """Add a comment to an issue. Args: owner: Repository owner. repo: Repository name. index: Issue number. body: Comment text. """ return _post(f"/repos/{owner}/{repo}/issues/{index}/comments", {"body": body}) # ─────────────────────────────────────────────────────────────────── # Labels # ─────────────────────────────────────────────────────────────────── @mcp.tool() def list_labels(owner: str, repo: str) -> dict | list | str: """List labels in a repository. Args: owner: Repository owner. repo: Repository name. """ return _get(f"/repos/{owner}/{repo}/labels") @mcp.tool() def create_label( owner: str, repo: str, name: str, color: str = "#0075ca", description: str = "" ) -> dict | list | str: """Create a label in a repository. Args: owner: Repository owner. repo: Repository name. name: Label name. color: Hex color code. Default "#0075ca". description: Label description. """ return _post( f"/repos/{owner}/{repo}/labels", { "name": name, "color": color, "description": description, }, ) # ─────────────────────────────────────────────────────────────────── # Pull Requests # ─────────────────────────────────────────────────────────────────── @mcp.tool() def list_pull_requests( owner: str, repo: str, state: str = "open", limit: int = 20, page: int = 1 ) -> dict | list | str: """List pull requests in a repository. Args: owner: Repository owner. repo: Repository name. state: Filter by state: "open", "closed", or "all". Default "open". limit: Results per page. Default 20. page: Page number. Default 1. """ return _get(f"/repos/{owner}/{repo}/pulls", state=state, limit=limit, page=page) @mcp.tool() def get_pull_request(owner: str, repo: str, index: int) -> dict | list | str: """Get details of a pull request. Args: owner: Repository owner. repo: Repository name. index: Pull request number. """ return _get(f"/repos/{owner}/{repo}/pulls/{index}") @mcp.tool() def create_pull_request( owner: str, repo: str, title: str, head: str, base: str = "main", body: str = "", ) -> dict | list | str: """Create a new pull request. Args: owner: Repository owner. repo: Repository name. title: PR title. head: Source branch. base: Target branch. Default "main". body: PR description. """ return _post( f"/repos/{owner}/{repo}/pulls", { "title": title, "head": head, "base": base, "body": body, }, ) @mcp.tool() def merge_pull_request( owner: str, repo: str, index: int, merge_style: str = "merge", message: str = "" ) -> dict | list | str: """Merge a pull request. Args: owner: Repository owner. repo: Repository name. index: Pull request number. merge_style: One of "merge", "rebase", "rebase-merge", "squash". Default "merge". message: Optional merge commit message. """ payload: dict = {"Do": merge_style} if message: payload["merge_message_field"] = message return _post(f"/repos/{owner}/{repo}/pulls/{index}/merge", payload) @mcp.tool() def list_pr_comments(owner: str, repo: str, index: int) -> dict | list | str: """List review comments on a pull request. Args: owner: Repository owner. repo: Repository name. index: Pull request number. """ return _get(f"/repos/{owner}/{repo}/pulls/{index}/reviews") @mcp.tool() def get_pr_diff(owner: str, repo: str, index: int) -> dict | list | str: """Get the diff of a pull request. Args: owner: Repository owner. repo: Repository name. index: Pull request number. """ with _client() as c: try: resp = c.get(f"/repos/{owner}/{repo}/pulls/{index}.diff") resp.raise_for_status() return {"diff": resp.text[:50000]} except httpx.HTTPStatusError as e: return { "error": f"HTTP {e.response.status_code}", "detail": e.response.text[:500], } except httpx.RequestError as e: return {"error": str(e)} # ─────────────────────────────────────────────────────────────────── # Releases / Tags # ─────────────────────────────────────────────────────────────────── @mcp.tool() def list_releases( owner: str, repo: str, limit: int = 10, page: int = 1 ) -> dict | list | str: """List releases in a repository. Args: owner: Repository owner. repo: Repository name. limit: Results per page. Default 10. page: Page number. Default 1. """ return _get(f"/repos/{owner}/{repo}/releases", limit=limit, page=page) @mcp.tool() def create_release( owner: str, repo: str, tag_name: str, name: str = "", body: str = "", draft: bool = False, prerelease: bool = False, target: str | None = None, ) -> dict | list | str: """Create a new release. Args: owner: Repository owner. repo: Repository name. tag_name: Tag for the release (e.g. "v1.0.0"). name: Release title. body: Release notes. draft: Whether this is a draft release. prerelease: Whether this is a pre-release. target: Branch or commit SHA to tag. Uses default branch if omitted. """ payload: dict = { "tag_name": tag_name, "name": name or tag_name, "body": body, "draft": draft, "prerelease": prerelease, } if target: payload["target_commitish"] = target return _post(f"/repos/{owner}/{repo}/releases", payload) @mcp.tool() def list_tags(owner: str, repo: str) -> dict | list | str: """List tags in a repository. Args: owner: Repository owner. repo: Repository name. """ return _get(f"/repos/{owner}/{repo}/tags") # ─────────────────────────────────────────────────────────────────── # Organizations # ─────────────────────────────────────────────────────────────────── @mcp.tool() def list_orgs() -> dict | list | str: """List organizations the authenticated user belongs to.""" return _get("/user/orgs") @mcp.tool() def get_org(org: str) -> dict | list | str: """Get details of an organization. Args: org: Organization name. """ return _get(f"/orgs/{org}") @mcp.tool() def list_org_repos(org: str, limit: int = 20, page: int = 1) -> dict | list | str: """List repositories in an organization. Args: org: Organization name. limit: Results per page. Default 20. page: Page number. Default 1. """ return _get(f"/orgs/{org}/repos", limit=limit, page=page) # ─────────────────────────────────────────────────────────────────── # Users # ─────────────────────────────────────────────────────────────────── @mcp.tool() def get_user(username: str) -> dict | list | str: """Get a user's profile. Args: username: The username to look up. """ return _get(f"/users/{username}") @mcp.tool() def list_user_repos(username: str, limit: int = 20, page: int = 1) -> dict | list | str: """List a user's repositories. Args: username: The username. limit: Results per page. Default 20. page: Page number. Default 1. """ return _get(f"/users/{username}/repos", limit=limit, page=page) # ─────────────────────────────────────────────────────────────────── # Milestones # ─────────────────────────────────────────────────────────────────── @mcp.tool() def list_milestones(owner: str, repo: str, state: str = "open") -> dict | list | str: """List milestones in a repository. Args: owner: Repository owner. repo: Repository name. state: Filter by state: "open", "closed", or "all". Default "open". """ return _get(f"/repos/{owner}/{repo}/milestones", state=state) @mcp.tool() def create_milestone( owner: str, repo: str, title: str, description: str = "" ) -> dict | list | str: """Create a milestone in a repository. Args: owner: Repository owner. repo: Repository name. title: Milestone title. description: Milestone description. """ return _post( f"/repos/{owner}/{repo}/milestones", { "title": title, "description": description, }, ) if __name__ == "__main__": mcp.run()