b7e195e426
- New: mcp_server.py — FastMCP stdio server exposing 7 tools: gitea_create_issue, gitea_create_pr, gitea_close_issue, gitea_list_issues, gitea_view_issue, gitea_mark_issue, gitea_mirror_refs - New: auth.py — shared authentication and API helpers (get_credentials, get_auth_header, api_request, repo_api_url) - Refactored: create_pr.py, create_issue.py, manage_labels.py to use shared auth module (eliminates credential duplication) - New: tests/test_mcp_server.py — 17 tests for all MCP tools - Updated: tests/test_credentials.py — now tests auth.py directly - Updated: tests/test_create_issue.py — adapted for refactored imports - New: requirements.txt — frozen venv deps (mcp[cli], pytest) - Updated: README.md — MCP server as primary interface - Config: added gitea-tools to mcp_config.json Closes #1. Resolves #2, #5. Relates to #7.
328 lines
10 KiB
Python
328 lines
10 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
|
|
|
|
# Ensure the project root is on the path so auth.py can be imported.
|
|
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
if PROJECT_ROOT not in sys.path:
|
|
sys.path.insert(0, PROJECT_ROOT)
|
|
|
|
from mcp.server.fastmcp import FastMCP # noqa: E402
|
|
|
|
from 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_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_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")
|