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,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