9a5750ddca
Expose the same 66 Gitea tools two ways from server/gitea_mcp.py:
- MCP server (no subcommand -> mcp.run()), as before
- CLI: `<tool> --flags` prints one JSON object {success, data/error}
A new @tool decorator registers each function in both FastMCP and a TOOLS
registry; a CLI dispatcher builds argparse from each function's signature and
wraps results in the repo's JSON+success contract. Adds `list-tools` for
discovery. No tool logic duplicated.
- Rewrite skills/gitea/SKILL.md for CLI-first usage (MCP kept as an option)
- Sync version to 0.3.0 across plugin.json, pyproject.toml, SKILL.md
- Add tests/unit/test_cli.py (dispatch routing, flag typing, errors, main)
- Document dual-mode in README.md and CLAUDE.md
- Ignore coverage artifacts
Tests: 119 passed, 93% coverage.
127 lines
4.6 KiB
Python
127 lines
4.6 KiB
Python
"""Contract tests for the CLI dispatcher (the second face of the same tools)."""
|
|
|
|
import json
|
|
|
|
import httpx
|
|
import respx
|
|
|
|
from server.gitea_mcp import TOOLS, dispatch, main
|
|
|
|
BASE = "https://gitea.test/api/v1"
|
|
|
|
|
|
# ── Registry parity ─────────────────────────────────────────────────────────────
|
|
|
|
def test_tools_registry_matches_count():
|
|
# Same number the smoke test asserts for MCP registration.
|
|
assert len(TOOLS) == 66
|
|
|
|
|
|
def test_every_tool_is_callable():
|
|
assert all(callable(fn) for fn in TOOLS.values())
|
|
|
|
|
|
# ── serve vs CLI routing ────────────────────────────────────────────────────────
|
|
|
|
def test_no_args_means_serve():
|
|
assert dispatch([]) is None
|
|
|
|
|
|
def test_serve_subcommand_means_serve():
|
|
assert dispatch(["serve"]) is None
|
|
|
|
|
|
# ── Discovery ───────────────────────────────────────────────────────────────────
|
|
|
|
def test_list_tools_payload():
|
|
env = dispatch(["list-tools"])
|
|
assert env["success"] is True
|
|
assert env["data"]["count"] == 66
|
|
names = {t["name"] for t in env["data"]["tools"]}
|
|
assert "get_repo" in names
|
|
# parameter spec carries name/type/required
|
|
get_repo = next(t for t in env["data"]["tools"] if t["name"] == "get_repo")
|
|
owner = next(p for p in get_repo["params"] if p["name"] == "owner")
|
|
assert owner["required"] is True
|
|
assert owner["type"] == "str"
|
|
|
|
|
|
# ── Happy path: flags map to a correct REST call ────────────────────────────────
|
|
|
|
@respx.mock
|
|
def test_dispatch_happy_path():
|
|
route = respx.get(f"{BASE}/repos/andrey/infra").mock(
|
|
return_value=httpx.Response(200, json={"name": "infra", "id": 7})
|
|
)
|
|
env = dispatch(["get_repo", "--owner", "andrey", "--repo", "infra"])
|
|
assert route.called
|
|
assert env["success"] is True
|
|
assert env["data"]["name"] == "infra"
|
|
|
|
|
|
@respx.mock
|
|
def test_int_flag_is_typed():
|
|
route = respx.get(f"{BASE}/user/repos").mock(return_value=httpx.Response(200, json=[]))
|
|
dispatch(["list_my_repos", "--limit", "5", "--page", "2"])
|
|
params = dict(route.calls[0].request.url.params)
|
|
assert params["limit"] == "5"
|
|
assert params["page"] == "2"
|
|
|
|
|
|
@respx.mock
|
|
def test_bool_no_flag_sends_false():
|
|
route = respx.post(f"{BASE}/user/repos").mock(
|
|
return_value=httpx.Response(201, json={"name": "x"})
|
|
)
|
|
dispatch(["create_repo", "--name", "x", "--no-private", "--no-auto-init"])
|
|
body = json.loads(route.calls[0].request.content)
|
|
assert body["private"] is False
|
|
assert body["auto_init"] is False
|
|
|
|
|
|
@respx.mock
|
|
def test_list_flag_takes_multiple_values():
|
|
route = respx.put(f"{BASE}/repos/o/r/topics").mock(return_value=httpx.Response(204))
|
|
dispatch(["set_repo_topics", "--owner", "o", "--repo", "r", "--topics", "ci", "backend"])
|
|
body = json.loads(route.calls[0].request.content)
|
|
assert body["topics"] == ["ci", "backend"]
|
|
|
|
|
|
# ── Error handling ──────────────────────────────────────────────────────────────
|
|
|
|
def test_unknown_command():
|
|
env = dispatch(["no_such_tool"])
|
|
assert env["success"] is False
|
|
assert env["type"] == "UnknownCommand"
|
|
|
|
|
|
def test_missing_required_flag_is_usage_error():
|
|
env = dispatch(["get_repo", "--owner", "andrey"]) # no --repo
|
|
assert env["success"] is False
|
|
assert env["type"] == "UsageError"
|
|
|
|
|
|
@respx.mock
|
|
def test_api_error_is_propagated_as_envelope():
|
|
respx.get(f"{BASE}/repos/o/missing").mock(
|
|
return_value=httpx.Response(404, json={"message": "not found"})
|
|
)
|
|
env = dispatch(["get_repo", "--owner", "o", "--repo", "missing"])
|
|
assert env["success"] is False
|
|
assert "404" in env["error"]
|
|
|
|
|
|
# ── main() prints exactly one JSON object and exits 0 ───────────────────────────
|
|
|
|
@respx.mock
|
|
def test_main_prints_single_json_and_exits_zero(capsys, monkeypatch):
|
|
respx.get(f"{BASE}/repos/o/r").mock(return_value=httpx.Response(200, json={"name": "r"}))
|
|
monkeypatch.setattr("sys.argv", ["gitea_mcp.py", "get_repo", "--owner", "o", "--repo", "r"])
|
|
try:
|
|
main()
|
|
except SystemExit as e:
|
|
assert e.code == 0
|
|
out = capsys.readouterr().out.strip()
|
|
parsed = json.loads(out) # exactly one JSON object
|
|
assert parsed["success"] is True
|