feat: initial homelab-mcp-gitea v0.1
MCP server exposing self-hosted Gitea (gitlab.impresion3d.pro) via stdio JSON-RPC 2.0. Six tools: list_repos, get_repo, get_file, create_issue, list_open_prs, merge_pr. The merge_pr tool pins the Gitea 'do'-field trap (see skill gitea pitfall #13) — sends lowercase 'do', treats HTTP 200 with non-empty body as failure. Stack: FastMCP + httpx + pydantic + uv, with respx-mocked pytest suite (15 tests, 0.69s) and ruff + mypy strict green. Verification: scripts/verify_stdio.py boots the server as a subprocess, exchanges real MCP messages over stdio, and calls list_repos against the live Gitea instance — all green.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Copy to .env and fill in. The server reads these on every tool call.
|
||||
GITEA_URL=https://gitlab.impresion3d.pro
|
||||
GITEA_TOKEN=replace-with-your-40-char-personal-access-token
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
# Editor / OS
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
# Local secrets — never commit. Copy .env.example to .env.
|
||||
.env
|
||||
|
||||
# Coverage / test artifacts
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# uv lock is committed; the build artifacts are not.
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -0,0 +1,131 @@
|
||||
# homelab-mcp-gitea
|
||||
|
||||
A **Model Context Protocol (MCP) server** that exposes a self-hosted **Gitea**
|
||||
instance as a set of tools, so any MCP-aware client (Claude Desktop, Hermes
|
||||
Agent, mcp-cli, the MCP Inspector) can list repos, fetch files, open issues,
|
||||
list PRs, and merge PRs through natural language — without hand-rolling
|
||||
`curl` invocations every time.
|
||||
|
||||
This is the **first** package of a planned `homelab-mcp-*` family:
|
||||
`homelab-mcp-gitea` → `homelab-mcp-portainer` → `homelab-mcp-nas` … each one
|
||||
a thin MCP wrapper over an existing self-hosted service.
|
||||
|
||||
## What is MCP, in one paragraph
|
||||
|
||||
MCP (Model Context Protocol) is the open standard (Anthropic, late 2024) that
|
||||
lets an LLM client talk to "tool servers" the same way regardless of which
|
||||
host it runs on. A server exposes:
|
||||
- **tools** (named functions with typed arguments and return values)
|
||||
- **resources** (named blobs the client can read)
|
||||
- **prompts** (templated prompts the client can render)
|
||||
|
||||
…over **stdio** or **HTTP+SSE**, using **JSON-RPC 2.0** as the wire format.
|
||||
The protocol is deliberately minimal so any language can implement it; the
|
||||
Python and TypeScript SDKs are the most mature.
|
||||
|
||||
In this project, each `GiteaClient` method becomes an MCP `tool` decorated
|
||||
with `@mcp.tool()`. The server runs over stdio (the default and simplest
|
||||
transport); the client launches it as a subprocess and exchanges JSON-RPC
|
||||
messages on stdin/stdout.
|
||||
|
||||
## Tools exposed
|
||||
|
||||
| Tool | What it does |
|
||||
|-----------------|---------------------------------------------------------|
|
||||
| `list_repos` | List repos visible to the authenticated user |
|
||||
| `get_repo` | Get one repo's metadata |
|
||||
| `get_file` | Read a file's text content (base64-decoded) |
|
||||
| `create_issue` | Open a new issue on a repo |
|
||||
| `list_open_prs` | List open pull requests for a repo |
|
||||
| `merge_pr` | Merge a PR by index (with the Gitea `do` field trap fix)|
|
||||
|
||||
The `merge_pr` tool is non-trivial on purpose: Gitea's merge endpoint is
|
||||
the famous `[Do]: Required` footgun (see the `gitea` skill, pitfall #13).
|
||||
The client sends `{"do": "merge"}` (lowercase, two chars) and treats a
|
||||
non-empty response body at HTTP 200 as failure. The tests pin both halves
|
||||
of this contract so it can't regress.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd ~/homelab-mcp-gitea
|
||||
uv sync --all-groups
|
||||
cp .env.example .env
|
||||
# Edit .env: set GITEA_URL and GITEA_TOKEN
|
||||
uv run pytest # 14 tests, no network required
|
||||
uv run ruff check src tests
|
||||
uv run mypy src
|
||||
```
|
||||
|
||||
## Running the server
|
||||
|
||||
```bash
|
||||
# Foreground stdio transport (the default — what MCP clients expect)
|
||||
uv run homelab-mcp-gitea
|
||||
```
|
||||
|
||||
The server reads `GITEA_URL` and `GITEA_TOKEN` from the environment on every
|
||||
tool call (no global state), so the same server process can be reused across
|
||||
requests without leaking credentials between calls.
|
||||
|
||||
## Connecting from a client
|
||||
|
||||
### Claude Desktop (`~/.config/claude_desktop_config.json` on Linux)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"homelab-gitea": {
|
||||
"command": "uv",
|
||||
"args": ["--directory", "/home/caleidos/homelab-mcp-gitea", "run", "homelab-mcp-gitea"],
|
||||
"env": {
|
||||
"GITEA_URL": "https://gitlab.impresion3d.pro",
|
||||
"GITEA_TOKEN": "<your-token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart Claude Desktop. The `homelab-gitea` server appears with its 6 tools
|
||||
under the "tools" menu.
|
||||
|
||||
### MCP Inspector (browser-based debugger)
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector uv --directory ~/homelab-mcp-gitea run homelab-mcp-gitea
|
||||
```
|
||||
|
||||
Opens a web UI where you can call each tool by hand and see the raw JSON-RPC.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/homelab_mcp_gitea/
|
||||
client.py # httpx-based Gitea REST client, no MCP knowledge
|
||||
server.py # FastMCP server; thin wrapper that maps tools -> client calls
|
||||
tests/
|
||||
test_client.py # respx-mocked unit tests for every HTTP call
|
||||
test_server.py # tool registration + routing smoke tests
|
||||
```
|
||||
|
||||
The split is deliberate: `client.py` knows nothing about MCP, so it can be
|
||||
reused from a plain Python REPL or a CLI, and `server.py` is small enough
|
||||
to read in one sitting (~120 lines including docstrings).
|
||||
|
||||
## What's NOT here (and why)
|
||||
|
||||
- **No FastAPI / Celery / Alembic / Docker**. Those belong to the
|
||||
`python-project-template-internal` stack, which targets deployable web
|
||||
apps. An MCP server is a long-lived subprocess, not an HTTP service.
|
||||
- **No CI-to-NAS deploy**. Local-only for v0.1; we can add Gitea Actions +
|
||||
SSH deploy when there's a reason to keep the server running unattended.
|
||||
- **No auth scopes, no rate limiting, no audit log**. The Gitea token
|
||||
scopes are enforced server-side; this MCP just forwards.
|
||||
|
||||
## Next steps in the `homelab-mcp-*` family
|
||||
|
||||
- `homelab-mcp-portainer`: list containers, restart, tail logs, redeploy
|
||||
stack. Requires the Portainer token + endpoint ID.
|
||||
- `homelab-mcp-nas`: SSH-based filesystem + Container Station ops. The
|
||||
trickier one (askpass / key auth, see `qnap-nas` skill pitfall J).
|
||||
@@ -0,0 +1,50 @@
|
||||
[project]
|
||||
name = "homelab-mcp-gitea"
|
||||
version = "0.1.0"
|
||||
description = "MCP server that exposes a self-hosted Gitea instance to MCP-aware clients"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"httpx>=0.27",
|
||||
"mcp[cli]>=1.2",
|
||||
"pydantic>=2.7",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
homelab-mcp-gitea = "homelab_mcp_gitea.server:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.3",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pytest-cov>=6",
|
||||
"ruff>=0.7",
|
||||
"mypy>=1.13",
|
||||
"respx>=0.21",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/homelab_mcp_gitea"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
strict = true
|
||||
warn_unused_ignores = true
|
||||
disallow_untyped_defs = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-ra --strict-markers"
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,122 @@
|
||||
"""End-to-end stdio verification.
|
||||
|
||||
Boots the MCP server as a subprocess, exchanges real JSON-RPC 2.0 messages
|
||||
over stdio (the same transport Claude Desktop / mcp-cli use), and verifies:
|
||||
|
||||
1. The initialize handshake completes and reports our 6 tools.
|
||||
2. tools/list returns the same 6 tools with proper JSON Schema.
|
||||
3. tools/call (list_repos) hits the real Gitea instance and returns data.
|
||||
|
||||
This is the closest you can get to "what does a real MCP client see?"
|
||||
without installing one. If this script passes, the server speaks the
|
||||
protocol correctly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def run_e2e() -> None:
|
||||
env = os.environ.copy()
|
||||
if "GITEA_URL" not in env or "GITEA_TOKEN" not in env:
|
||||
print("ERROR: GITEA_URL and GITEA_TOKEN must be in env.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
cmd = ["uv", "run", "homelab-mcp-gitea"]
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=Path(__file__).parent.parent,
|
||||
env=env,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
def send(msg: dict[str, Any]) -> dict[str, Any]:
|
||||
line = json.dumps(msg) + "\n"
|
||||
assert proc.stdin is not None
|
||||
proc.stdin.write(line)
|
||||
proc.stdin.flush()
|
||||
assert proc.stdout is not None
|
||||
response_line = proc.stdout.readline()
|
||||
return json.loads(response_line)
|
||||
|
||||
try:
|
||||
# 1) initialize
|
||||
init_resp = send(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "stdio-verifier", "version": "0.1.0"},
|
||||
},
|
||||
}
|
||||
)
|
||||
assert "result" in init_resp, f"initialize failed: {init_resp}"
|
||||
server_info = init_resp["result"]["serverInfo"]
|
||||
print(f"server: {server_info['name']} v{server_info['version']}")
|
||||
|
||||
# MCP requires the initialized notification before tool calls
|
||||
assert proc.stdin is not None
|
||||
proc.stdin.write(
|
||||
json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n"
|
||||
)
|
||||
proc.stdin.flush()
|
||||
|
||||
# 2) tools/list
|
||||
tools_resp = send({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
|
||||
assert "result" in tools_resp, f"tools/list failed: {tools_resp}"
|
||||
tools = tools_resp["result"]["tools"]
|
||||
tool_names = sorted(t["name"] for t in tools)
|
||||
print(f"tools registered ({len(tools)}): {tool_names}")
|
||||
expected = {
|
||||
"create_issue",
|
||||
"get_file",
|
||||
"get_repo",
|
||||
"list_open_prs",
|
||||
"list_repos",
|
||||
"merge_pr",
|
||||
}
|
||||
assert set(tool_names) == expected, f"unexpected tools: {tool_names}"
|
||||
|
||||
# 3) tools/call list_repos
|
||||
call_resp = send(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "list_repos", "arguments": {"limit": 3}},
|
||||
}
|
||||
)
|
||||
assert "result" in call_resp, f"tools/call failed: {call_resp}"
|
||||
content = call_resp["result"]["content"]
|
||||
assert content and content[0]["type"] == "text"
|
||||
text = content[0]["text"]
|
||||
print("list_repos returned:")
|
||||
for line in text.splitlines()[:5]:
|
||||
print(f" {line}")
|
||||
|
||||
print("\nALL CHECKS PASSED — MCP server speaks the protocol correctly.")
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
stderr = proc.stderr.read() if proc.stderr else ""
|
||||
if stderr.strip():
|
||||
print("\n--- server stderr ---")
|
||||
print(stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_e2e()
|
||||
@@ -0,0 +1,2 @@
|
||||
def main() -> None:
|
||||
print("Hello from homelab-mcp-gitea!")
|
||||
@@ -0,0 +1,150 @@
|
||||
"""HTTP client for the Gitea REST API.
|
||||
|
||||
All operations live here so the MCP server layer stays thin and the client
|
||||
can be unit-tested with respx (httpx MockTransport) without touching the MCP
|
||||
machinery.
|
||||
|
||||
Auth: read GITEA_URL and GITEA_TOKEN from environment. The token is sent as
|
||||
'Authorization: token <value>' per the Gitea API convention.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class GiteaError(RuntimeError):
|
||||
"""Raised when the Gitea API returns a non-2xx response or a known trap."""
|
||||
|
||||
def __init__(self, status: int, message: str, body: Any = None) -> None:
|
||||
super().__init__(f"HTTP {status}: {message}")
|
||||
self.status = status
|
||||
self.body = body
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
"""Thin wrapper over httpx for the Gitea /api/v1/ endpoints we use."""
|
||||
|
||||
def __init__(self, base_url: str | None = None, token: str | None = None) -> None:
|
||||
self.base_url = (base_url or os.environ.get("GITEA_URL", "")).rstrip("/")
|
||||
token = token or os.environ.get("GITEA_TOKEN", "")
|
||||
if not self.base_url:
|
||||
raise ValueError("GITEA_URL is required (env var or constructor arg)")
|
||||
if not token:
|
||||
raise ValueError("GITEA_TOKEN is required (env var or constructor arg)")
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
headers={"Authorization": f"token {token}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
async def __aenter__(self) -> GiteaClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> None:
|
||||
await self.aclose()
|
||||
|
||||
# ----- low-level -----
|
||||
|
||||
async def _request_json(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
response = await self._client.request(
|
||||
method,
|
||||
path,
|
||||
params=params,
|
||||
json=json_body,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
# Gitea returns either JSON {"message": "..."} or plain text
|
||||
try:
|
||||
body = response.json()
|
||||
except Exception:
|
||||
body = response.text
|
||||
raise GiteaError(response.status_code, str(body), body)
|
||||
if not response.content:
|
||||
return None
|
||||
return response.json()
|
||||
|
||||
# ----- repositories -----
|
||||
|
||||
async def list_repos(self, limit: int = 30) -> list[dict[str, Any]]:
|
||||
"""List repositories visible to the authenticated user."""
|
||||
data = await self._request_json("GET", "/api/v1/user/repos", params={"limit": limit})
|
||||
return data or []
|
||||
|
||||
async def get_repo(self, owner: str, repo: str) -> dict[str, Any]:
|
||||
"""Fetch a single repo by owner/name."""
|
||||
return cast(
|
||||
dict[str, Any], await self._request_json("GET", f"/api/v1/repos/{owner}/{repo}")
|
||||
)
|
||||
|
||||
async def get_file(self, owner: str, repo: str, path: str, ref: str = "main") -> str:
|
||||
"""Fetch a file's raw text content. Gitea returns base64; we decode."""
|
||||
data = await self._request_json(
|
||||
"GET",
|
||||
f"/api/v1/repos/{owner}/{repo}/contents/{path}",
|
||||
params={"ref": ref},
|
||||
)
|
||||
if not data or "content" not in data:
|
||||
raise GiteaError(404, f"file not found: {path}")
|
||||
return base64.b64decode(data["content"]).decode("utf-8")
|
||||
|
||||
# ----- issues -----
|
||||
|
||||
async def create_issue(
|
||||
self, owner: str, repo: str, title: str, body: str = ""
|
||||
) -> dict[str, Any]:
|
||||
"""Open a new issue. Returns the issue object (includes 'number')."""
|
||||
return cast(
|
||||
dict[str, Any],
|
||||
await self._request_json(
|
||||
"POST",
|
||||
f"/api/v1/repos/{owner}/{repo}/issues",
|
||||
json_body={"title": title, "body": body},
|
||||
),
|
||||
)
|
||||
|
||||
# ----- pull requests -----
|
||||
|
||||
async def list_open_prs(self, owner: str, repo: str) -> list[dict[str, Any]]:
|
||||
"""List pull requests in 'open' state for the given repo."""
|
||||
data = await self._request_json(
|
||||
"GET", f"/api/v1/repos/{owner}/{repo}/pulls", params={"state": "open"}
|
||||
)
|
||||
return data or []
|
||||
|
||||
async def merge_pr(self, owner: str, repo: str, index: int) -> bool:
|
||||
"""Merge a PR. Returns True on success.
|
||||
|
||||
Trap (from skill gitea): the field is the lowercase key `do`, NOT `Do`
|
||||
or `Merge_Method`. Gitea returns HTTP 200 with a misleading body when
|
||||
the field name is wrong. Success body is empty.
|
||||
"""
|
||||
response = await self._client.post(
|
||||
f"/api/v1/repos/{owner}/{repo}/pulls/{index}/merge",
|
||||
json={"do": "merge"},
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise GiteaError(response.status_code, response.text)
|
||||
# Success = HTTP 200 with EMPTY body. Non-empty = server-side error
|
||||
# even when status is 200 (the classic Gitea trap).
|
||||
if response.content:
|
||||
raise GiteaError(
|
||||
response.status_code,
|
||||
f"merge returned non-empty body (likely the 'do' field trap): {response.text!r}",
|
||||
response.json() if response.text else None,
|
||||
)
|
||||
return True
|
||||
@@ -0,0 +1,167 @@
|
||||
"""MCP server exposing Gitea operations as tools.
|
||||
|
||||
Run with:
|
||||
uv run homelab-mcp-gitea
|
||||
|
||||
The server uses stdio transport (the default for MCP). Connect from any MCP
|
||||
client (Claude Desktop, mcp-cli, inspector) by pointing it at this command.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from .client import GiteaClient, GiteaError
|
||||
|
||||
mcp = FastMCP("homelab-gitea")
|
||||
|
||||
|
||||
def _client() -> GiteaClient:
|
||||
"""Fresh client per call — MCP tools are stateless and short-lived."""
|
||||
return GiteaClient()
|
||||
|
||||
|
||||
def _format_error(e: GiteaError) -> str:
|
||||
return f"Gitea API error: {e}"
|
||||
|
||||
|
||||
# ----- tools -----
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def list_repos(limit: int = 30) -> str:
|
||||
"""List repositories the authenticated user can see.
|
||||
|
||||
Args:
|
||||
limit: Max number of repos to return (default 30).
|
||||
"""
|
||||
try:
|
||||
async with _client() as c:
|
||||
repos = await c.list_repos(limit=limit)
|
||||
except GiteaError as e:
|
||||
return _format_error(e)
|
||||
except ValueError as e:
|
||||
return f"Configuration error: {e}"
|
||||
if not repos:
|
||||
return "(no repositories found)"
|
||||
lines = [f"{r['full_name']} [private={r['private']}]" for r in repos]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_repo(owner: str, repo: str) -> str:
|
||||
"""Fetch a single repository's metadata.
|
||||
|
||||
Args:
|
||||
owner: Gitea login (e.g. 'root').
|
||||
repo: Repository name.
|
||||
"""
|
||||
try:
|
||||
async with _client() as c:
|
||||
data = await c.get_repo(owner, repo)
|
||||
except GiteaError as e:
|
||||
return _format_error(e)
|
||||
except ValueError as e:
|
||||
return f"Configuration error: {e}"
|
||||
return (
|
||||
f"{data['full_name']}\n"
|
||||
f" description : {data.get('description') or '(none)'}\n"
|
||||
f" private : {data['private']}\n"
|
||||
f" default : {data['default_branch']}\n"
|
||||
f" stars : {data['stars_count']}\n"
|
||||
f" open issues : {data['open_issues_count']}"
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_file(owner: str, repo: str, path: str, ref: str = "main") -> str:
|
||||
"""Fetch a file's text content from a repository.
|
||||
|
||||
Args:
|
||||
owner: Gitea login.
|
||||
repo: Repository name.
|
||||
path: Path inside the repo (e.g. 'README.md').
|
||||
ref: Branch or tag (default 'main').
|
||||
"""
|
||||
try:
|
||||
async with _client() as c:
|
||||
content = await c.get_file(owner, repo, path, ref=ref)
|
||||
except GiteaError as e:
|
||||
return _format_error(e)
|
||||
except ValueError as e:
|
||||
return f"Configuration error: {e}"
|
||||
return content
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def create_issue(owner: str, repo: str, title: str, body: str = "") -> str:
|
||||
"""Open a new issue on a repository.
|
||||
|
||||
Args:
|
||||
owner: Gitea login.
|
||||
repo: Repository name.
|
||||
title: Issue title.
|
||||
body: Optional issue body (markdown).
|
||||
"""
|
||||
try:
|
||||
async with _client() as c:
|
||||
issue = await c.create_issue(owner, repo, title, body)
|
||||
except GiteaError as e:
|
||||
return _format_error(e)
|
||||
except ValueError as e:
|
||||
return f"Configuration error: {e}"
|
||||
return f"Created issue #{issue['number']}: {issue['title']}\n url: {issue['html_url']}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def list_open_prs(owner: str, repo: str) -> str:
|
||||
"""List open pull requests for a repository.
|
||||
|
||||
Args:
|
||||
owner: Gitea login.
|
||||
repo: Repository name.
|
||||
"""
|
||||
try:
|
||||
async with _client() as c:
|
||||
prs = await c.list_open_prs(owner, repo)
|
||||
except GiteaError as e:
|
||||
return _format_error(e)
|
||||
except ValueError as e:
|
||||
return f"Configuration error: {e}"
|
||||
if not prs:
|
||||
return f"(no open PRs in {owner}/{repo})"
|
||||
lines = [
|
||||
f"#{pr['number']} {pr['title']} ({pr['user']['login']} -> {pr['base']['ref']})"
|
||||
for pr in prs
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def merge_pr(owner: str, repo: str, index: int) -> str:
|
||||
"""Merge a pull request.
|
||||
|
||||
Args:
|
||||
owner: Gitea login.
|
||||
repo: Repository name.
|
||||
index: PR number (the URL-visible number, NOT the internal id).
|
||||
"""
|
||||
try:
|
||||
async with _client() as c:
|
||||
ok = await c.merge_pr(owner, repo, index)
|
||||
except GiteaError as e:
|
||||
return _format_error(e)
|
||||
except ValueError as e:
|
||||
return f"Configuration error: {e}"
|
||||
if ok:
|
||||
return f"PR #{index} merged successfully."
|
||||
return f"PR #{index} merge returned ok=False (unexpected)."
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point for the 'homelab-mcp-gitea' script."""
|
||||
mcp.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Unit tests for the Gitea HTTP client. Uses respx to mock httpx."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from homelab_mcp_gitea.client import GiteaClient, GiteaError
|
||||
|
||||
BASE = "https://gitea.test"
|
||||
TOKEN = "test-token-xyz"
|
||||
|
||||
|
||||
def _client() -> GiteaClient:
|
||||
return GiteaClient(base_url=BASE, token=TOKEN)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_list_repos_returns_parsed_json() -> None:
|
||||
respx.get(f"{BASE}/api/v1/user/repos").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{"full_name": "root/alpha", "private": False},
|
||||
{"full_name": "root/beta", "private": True},
|
||||
],
|
||||
)
|
||||
)
|
||||
async with _client() as c:
|
||||
repos = await c.list_repos(limit=10)
|
||||
assert len(repos) == 2
|
||||
assert repos[0]["full_name"] == "root/alpha"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_get_repo_returns_metadata() -> None:
|
||||
respx.get(f"{BASE}/api/v1/repos/root/alpha").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"full_name": "root/alpha",
|
||||
"description": "test repo",
|
||||
"private": False,
|
||||
"default_branch": "main",
|
||||
"stars_count": 7,
|
||||
"open_issues_count": 2,
|
||||
},
|
||||
)
|
||||
)
|
||||
async with _client() as c:
|
||||
data = await c.get_repo("root", "alpha")
|
||||
assert data["stars_count"] == 7
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_get_file_decodes_base64_content() -> None:
|
||||
raw = "hello world\n"
|
||||
encoded = base64.b64encode(raw.encode()).decode()
|
||||
respx.get(f"{BASE}/api/v1/repos/root/alpha/contents/README.md").mock(
|
||||
return_value=httpx.Response(200, json={"content": encoded})
|
||||
)
|
||||
async with _client() as c:
|
||||
text = await c.get_file("root", "alpha", "README.md")
|
||||
assert text == raw
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_create_issue_posts_payload_and_returns_issue() -> None:
|
||||
respx.post(f"{BASE}/api/v1/repos/root/alpha/issues").mock(
|
||||
return_value=httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"number": 42,
|
||||
"title": "bug",
|
||||
"html_url": "https://gitea.test/root/alpha/issues/42",
|
||||
},
|
||||
)
|
||||
)
|
||||
async with _client() as c:
|
||||
issue = await c.create_issue("root", "alpha", "bug", "details")
|
||||
assert issue["number"] == 42
|
||||
sent = respx.calls.last.request
|
||||
import json
|
||||
|
||||
payload = json.loads(sent.content)
|
||||
assert payload == {"title": "bug", "body": "details"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_list_open_prs_filters_by_state() -> None:
|
||||
respx.get(f"{BASE}/api/v1/repos/root/alpha/pulls").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"number": 5,
|
||||
"title": "feat: x",
|
||||
"user": {"login": "alice"},
|
||||
"base": {"ref": "main"},
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
async with _client() as c:
|
||||
prs = await c.list_open_prs("root", "alpha")
|
||||
assert prs[0]["number"] == 5
|
||||
sent = respx.calls.last.request
|
||||
assert sent.url.params["state"] == "open"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_merge_pr_uses_lowercase_do_field() -> None:
|
||||
"""Trap regression: Gitea needs the JSON key `do`, not `Do` or `Merge_Method`."""
|
||||
respx.post(f"{BASE}/api/v1/repos/root/alpha/pulls/5/merge").mock(
|
||||
return_value=httpx.Response(200, content=b"")
|
||||
)
|
||||
async with _client() as c:
|
||||
ok = await c.merge_pr("root", "alpha", 5)
|
||||
assert ok is True
|
||||
import json
|
||||
|
||||
sent_payload = json.loads(respx.calls.last.request.content)
|
||||
assert sent_payload == {"do": "merge"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_merge_pr_raises_on_nonempty_body_even_at_200() -> None:
|
||||
"""The other half of the trap: status 200 with a body means failure."""
|
||||
respx.post(f"{BASE}/api/v1/repos/root/alpha/pulls/5/merge").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"message": "[Do]: Required"},
|
||||
)
|
||||
)
|
||||
async with _client() as c:
|
||||
with pytest.raises(GiteaError) as exc_info:
|
||||
await c.merge_pr("root", "alpha", 5)
|
||||
assert "do" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_http_error_raises_gitea_error() -> None:
|
||||
respx.get(f"{BASE}/api/v1/repos/root/missing").mock(
|
||||
return_value=httpx.Response(404, json={"message": "not found"})
|
||||
)
|
||||
async with _client() as c:
|
||||
with pytest.raises(GiteaError) as exc_info:
|
||||
await c.get_repo("root", "missing")
|
||||
assert exc_info.value.status == 404
|
||||
|
||||
|
||||
def test_constructor_requires_url_and_token() -> None:
|
||||
import os
|
||||
|
||||
os.environ.pop("GITEA_URL", None)
|
||||
os.environ.pop("GITEA_TOKEN", None)
|
||||
with pytest.raises(ValueError, match="GITEA_URL"):
|
||||
GiteaClient(token="t")
|
||||
with pytest.raises(ValueError, match="GITEA_TOKEN"):
|
||||
GiteaClient(base_url=BASE)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Smoke tests for the MCP server layer.
|
||||
|
||||
Uses FastMCP's public API:
|
||||
- mcp.list_tools() to enumerate registered tools
|
||||
- mcp.call_tool(name, args) to invoke one
|
||||
|
||||
This avoids reaching into private attributes and reflects exactly what an
|
||||
MCP client sees over the wire.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from homelab_mcp_gitea import server
|
||||
|
||||
BASE = "https://gitea.test"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("GITEA_URL", BASE)
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-token")
|
||||
|
||||
|
||||
def _text(call_result: object) -> str:
|
||||
"""Extract the first text block from a FastMCP call_tool result."""
|
||||
content_blocks, _meta = call_result # type: ignore[misc]
|
||||
return content_blocks[0].text # type: ignore[union-attr]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_six_tools_registered() -> None:
|
||||
tools = await server.mcp.list_tools()
|
||||
names = {t.name for t in tools}
|
||||
expected = {
|
||||
"list_repos",
|
||||
"get_repo",
|
||||
"get_file",
|
||||
"create_issue",
|
||||
"list_open_prs",
|
||||
"merge_pr",
|
||||
}
|
||||
assert expected <= names, f"missing tools: {expected - names}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_list_repos_tool_routes_through_client() -> None:
|
||||
respx.get(f"{BASE}/api/v1/user/repos").mock(
|
||||
return_value=httpx.Response(200, json=[{"full_name": "root/alpha", "private": False}])
|
||||
)
|
||||
result = await server.mcp.call_tool("list_repos", {"limit": 10})
|
||||
assert "root/alpha" in _text(result)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_get_file_tool_returns_decoded_text() -> None:
|
||||
raw = "from fastapi import FastAPI\n"
|
||||
encoded = base64.b64encode(raw.encode()).decode()
|
||||
respx.get(f"{BASE}/api/v1/repos/root/alpha/contents/main.py").mock(
|
||||
return_value=httpx.Response(200, json={"content": encoded})
|
||||
)
|
||||
result = await server.mcp.call_tool(
|
||||
"get_file", {"owner": "root", "repo": "alpha", "path": "main.py", "ref": "main"}
|
||||
)
|
||||
assert _text(result) == raw
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_create_issue_tool_returns_number_and_url() -> None:
|
||||
respx.post(f"{BASE}/api/v1/repos/root/alpha/issues").mock(
|
||||
return_value=httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"number": 7,
|
||||
"title": "x",
|
||||
"html_url": "https://gitea.test/root/alpha/issues/7",
|
||||
},
|
||||
)
|
||||
)
|
||||
result = await server.mcp.call_tool(
|
||||
"create_issue", {"owner": "root", "repo": "alpha", "title": "x", "body": ""}
|
||||
)
|
||||
text = _text(result)
|
||||
assert "#7" in text
|
||||
assert "issues/7" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_merge_pr_tool_success_message() -> None:
|
||||
respx.post(f"{BASE}/api/v1/repos/root/alpha/pulls/5/merge").mock(
|
||||
return_value=httpx.Response(200, content=b"")
|
||||
)
|
||||
result = await server.mcp.call_tool("merge_pr", {"owner": "root", "repo": "alpha", "index": 5})
|
||||
assert "merged successfully" in _text(result)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_error_from_gitea_surfaces_as_human_string() -> None:
|
||||
respx.get(f"{BASE}/api/v1/user/repos").mock(
|
||||
return_value=httpx.Response(401, json={"message": "unauthorized"})
|
||||
)
|
||||
result = await server.mcp.call_tool("list_repos", {})
|
||||
text = _text(result)
|
||||
assert "Gitea API error" in text
|
||||
assert "401" in text
|
||||
Reference in New Issue
Block a user