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,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()
|
||||
Reference in New Issue
Block a user