feat: dual-mode — MCP server and CLI skill in one file
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.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user