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
+122
View File
@@ -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()