Files
Gitea-Tools/mcp_server.py
T

593 lines
18 KiB
Python

#!/usr/bin/env python3
"""Gitea MCP Server — exposes Gitea operations as MCP tools.
Runs over stdio. All tools authenticate via macOS keychain (git credential fill).
Usage (standalone test):
python3 mcp_server.py
Configuration (mcp_config.json):
"gitea-tools": {
"command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
"args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"],
"env": {}
}
"""
import os
import sys
import subprocess
# Auto-execute using the project's local virtual environment Python
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
venv_python = os.path.join(PROJECT_ROOT, "venv", "bin", "python3")
if os.path.exists(venv_python) and sys.executable != venv_python:
os.execv(venv_python, [venv_python] + sys.argv)
# Ensure the project root is on the path so gitea_auth.py can be imported.
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from mcp.server.fastmcp import FastMCP # noqa: E402
from gitea_auth import ( # noqa: E402
REMOTES,
get_credentials,
get_auth_header,
api_request,
repo_api_url,
)
mcp = FastMCP("gitea-tools", instructions=(
"Gitea issue tracker and PR management for dadeschools and prgs instances. "
"Use the gitea_ prefixed tools to create issues, PRs, list issues, etc."
))
# ── Helpers ───────────────────────────────────────────────────────────────────
def _resolve(remote: str, host: str | None, org: str | None, repo: str | None):
"""Resolve remote + overrides to (host, org, repo)."""
if remote not in REMOTES:
raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}")
profile = REMOTES[remote]
return (
host or profile["host"],
org or profile["org"],
repo or profile["repo"],
)
def _auth(host: str) -> str:
"""Get auth header, raise if unavailable."""
header = get_auth_header(host)
if header is None:
raise RuntimeError(
f"No credentials for {host}. "
"Ensure you've logged in via HTTPS at least once."
)
return header
# ── Tools ─────────────────────────────────────────────────────────────────────
@mcp.tool()
def gitea_create_issue(
title: str,
body: str = "",
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Create a new issue on a Gitea repository.
Args:
title: Issue title (required).
body: Issue body text.
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with 'number' and 'url' of the created issue.
"""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/issues"
data = api_request("POST", url, auth, {"title": title, "body": body})
return {"number": data["number"], "url": data["html_url"]}
@mcp.tool()
def gitea_create_pr(
title: str,
head: str,
base: str = "main",
body: str = "",
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Create a pull request on a Gitea repository.
Args:
title: PR title (required).
head: Source branch name (required).
base: Target branch (default: 'main').
body: PR description.
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with 'number' and 'url' of the created PR.
"""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/pulls"
payload = {"title": title, "body": body, "head": head, "base": base}
data = api_request("POST", url, auth, payload)
return {"number": data["number"], "url": data["html_url"]}
@mcp.tool()
def gitea_list_prs(
state: str = "open",
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> list[dict]:
"""List pull requests on a Gitea repository.
Args:
state: State filter — 'open', 'closed', or 'all' (default: 'open').
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
List of dicts with 'number', 'title', 'state', 'head', 'base', 'url', 'mergeable'.
"""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/pulls?state={state}"
prs = api_request("GET", url, auth) or []
return [
{
"number": pr["number"],
"title": pr["title"],
"state": pr["state"],
"head": pr["head"]["ref"],
"base": pr["base"]["ref"],
"url": pr["html_url"],
"mergeable": pr.get("mergeable"),
}
for pr in prs
]
@mcp.tool()
def gitea_view_pr(
pr_number: int,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Get details of a single pull request.
Args:
pr_number: The pull request index/number.
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with PR details.
"""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}"
pr = api_request("GET", url, auth)
return {
"number": pr["number"],
"title": pr["title"],
"body": pr.get("body", ""),
"state": pr["state"],
"head": pr["head"]["ref"],
"base": pr["base"]["ref"],
"url": pr["html_url"],
"mergeable": pr.get("mergeable"),
"user": pr.get("user", {}).get("login", ""),
}
@mcp.tool()
def gitea_merge_pr(
pr_number: int,
do: str = "merge",
title: str | None = None,
message: str | None = None,
force: bool = False,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Merge a Gitea pull request.
Args:
pr_number: The PR number to merge.
do: Merge style — 'merge', 'squash', or 'rebase'.
title: Optional merge title.
message: Optional merge message.
force: Force merge, ignoring status checks.
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with 'success' and 'message'.
"""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}/merge"
payload = {
"Do": do,
"force_merge": force,
}
if title:
payload["MergeTitleField"] = title
if message:
payload["MergeMessageField"] = message
api_request("POST", url, auth, payload)
return {"success": True, "message": f"PR #{pr_number} merged via '{do}'."}
@mcp.tool()
def gitea_delete_branch(
branch: str,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Delete a remote branch from a Gitea repository.
Args:
branch: The remote branch name (e.g. 'feat/branch-name').
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with 'success' and 'message'.
"""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
import urllib.parse
encoded_branch = urllib.parse.quote(branch, safe="")
url = f"{repo_api_url(h, o, r)}/branches/{encoded_branch}"
api_request("DELETE", url, auth)
return {"success": True, "message": f"Remote branch '{branch}' deleted."}
@mcp.tool()
def gitea_close_issue(
issue_number: int,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Close an issue by setting its state to 'closed'.
Args:
issue_number: The issue number to close.
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with 'success' boolean and 'message'.
"""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/issues/{issue_number}"
api_request("PATCH", url, auth, {"state": "closed"})
return {"success": True, "message": f"Issue #{issue_number} closed."}
@mcp.tool()
def gitea_list_issues(
state: str = "open",
label: str | None = None,
limit: int = 50,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> list[dict]:
"""List issues on a Gitea repository with optional filters.
Args:
state: Filter by state — 'open', 'closed', or 'all'.
label: Filter by label name (e.g. 'important').
limit: Max number of issues to return (default: 50).
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
List of dicts with 'number', 'title', 'state', 'labels', 'assignee'.
"""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
params = f"state={state}&limit={limit}&type=issues"
if label:
params += f"&labels={label}"
url = f"{repo_api_url(h, o, r)}/issues?{params}"
issues = api_request("GET", url, auth)
return [
{
"number": i["number"],
"title": i["title"],
"state": i["state"],
"labels": [lb["name"] for lb in i.get("labels", [])],
"assignee": (i.get("assignee") or {}).get("login", ""),
}
for i in issues
]
@mcp.tool()
def gitea_view_issue(
issue_number: int,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Get full details of a single issue.
Args:
issue_number: The issue number to view.
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with 'number', 'title', 'body', 'state', 'labels', 'assignee', 'url'.
"""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/issues/{issue_number}"
i = api_request("GET", url, auth)
return {
"number": i["number"],
"title": i["title"],
"body": i.get("body", ""),
"state": i["state"],
"labels": [lb["name"] for lb in i.get("labels", [])],
"assignee": (i.get("assignee") or {}).get("login", ""),
"url": i["html_url"],
}
@mcp.tool()
def gitea_mark_issue(
issue_number: int,
action: str,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Claim or release an issue via the status:in-progress label.
This is the cross-agent lock mechanism. Check before starting work.
Args:
issue_number: The issue number.
action: 'start' to claim (add label) or 'done' to release (remove label).
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with 'success' boolean and 'message'.
"""
if action not in ("start", "done"):
raise ValueError(f"action must be 'start' or 'done', got '{action}'")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
# Find the status:in-progress label id
labels = api_request("GET", f"{base}/labels?limit=100", auth)
label_id = None
for lb in labels:
if lb["name"] == "status:in-progress":
label_id = lb["id"]
break
if label_id is None:
raise RuntimeError(
"Label 'status:in-progress' not found. "
"Run manage_labels.py to create it first."
)
if action == "start":
api_request("POST", f"{base}/issues/{issue_number}/labels", auth,
{"labels": [label_id]})
return {"success": True, "message": f"Issue #{issue_number} claimed."}
else:
api_request("DELETE",
f"{base}/issues/{issue_number}/labels/{label_id}", auth)
return {"success": True, "message": f"Issue #{issue_number} released."}
@mcp.tool()
def gitea_list_labels(
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> list:
"""List all available labels in a Gitea repository.
Args:
remote: Known Gitea instance ('dadeschools' or 'prgs').
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
list of labels.
"""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
return api_request("GET", f"{base}/labels?limit=100", auth)
@mcp.tool()
def gitea_create_label(
name: str,
color: str,
description: str = "",
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Create a new label on a Gitea repository.
Args:
name: Name of the label (e.g. 'bug', 'epic').
color: HTML color code (hex, e.g. 'fbca04' or '#fbca04').
description: Description of the label.
remote: Known Gitea instance ('dadeschools' or 'prgs').
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict containing the created label details.
"""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
if color.startswith("#"):
color = color[1:]
payload = {
"name": name,
"color": color,
"description": description,
}
return api_request("POST", f"{base}/labels", auth, payload)
@mcp.tool()
def gitea_set_issue_labels(
issue_number: int,
labels: list[str],
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> list:
"""Replace all labels on a Gitea issue with a new list of label names.
Args:
issue_number: The issue number.
labels: List of label names to apply.
remote: Known Gitea instance ('dadeschools' or 'prgs').
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
list of all labels currently applied to the issue.
"""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
# 1. Fetch existing labels on the repo to resolve names -> IDs
existing = api_request("GET", f"{base}/labels?limit=100", auth)
name_to_id = {lb["name"]: lb["id"] for lb in existing}
# 2. Check if any requested labels do not exist, and raise error
label_ids = []
missing_labels = []
for name in labels:
if name in name_to_id:
label_ids.append(name_to_id[name])
else:
missing_labels.append(name)
if missing_labels:
raise RuntimeError(
f"The following labels do not exist on the repository: {missing_labels}. "
"Please create them first using gitea_create_label."
)
# 3. PUT the labels to the issue
res = api_request("PUT", f"{base}/issues/{issue_number}/labels", auth, {"labels": label_ids})
return res
@mcp.tool()
def gitea_mirror_refs(
apply: bool = False,
force: bool = False,
) -> dict:
"""Mirror branches and tags between dadeschools and prgs Timesheet repos.
Additive only — never deletes branches or tags. Diverged branches are
skipped unless force is True.
Args:
apply: If True, actually push. If False (default), dry-run only.
force: If True, force-push diverged branches.
Returns:
dict with 'output' (script stdout) and 'return_code'.
"""
script = os.path.join(PROJECT_ROOT, "mirror_refs.sh")
args = [script]
if apply:
args.append("--apply")
if force:
args.append("--force")
result = subprocess.run(
args, capture_output=True, text=True, timeout=120,
)
return {
"output": result.stdout + result.stderr,
"return_code": result.returncode,
}
# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
mcp.run(transport="stdio")