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:
homelab-mcp
2026-07-22 12:47:59 +00:00
commit 0212913831
12 changed files with 2218 additions and 0 deletions
+171
View File
@@ -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)
+115
View File
@@ -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