- 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.
This commit is contained in:
+327
@@ -0,0 +1,327 @@
|
||||
#!/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")
|
||||
Reference in New Issue
Block a user