# CLAUDE.md — Developer Guide ## Project overview Gitea REST API exposed as 66 tools from a single source file: `server/gitea_mcp.py`. Config in `config.json` (gitignored). **Dual-mode — one file, two entrypoints over the same tool functions:** - **MCP server:** no subcommand → `mcp.run()`. Launched by `.mcp.json` (`uv run server/gitea_mcp.py --config config.json`). Tools = `mcp__gitea__`. - **CLI skill:** `uv run server/gitea_mcp.py --flags` → one JSON object on stdout (`{"success", "data"|"error"}`). Powers the `skills/gitea` Cline skill. The split lives at the bottom of the file: every tool is registered in the `TOOLS` dict by the `@tool` decorator, and `dispatch()` builds an argparse parser from each function's signature. `main()` routes to CLI or server. No tool logic is duplicated. ## Commit style - Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `test:` - No `Co-Authored-By` or any AI attribution lines in commit messages ## Adding tools Each tool is a `@tool` function (registers it for **both** the MCP server and the CLI). Pattern: ```python @tool def my_tool(owner: str, repo: str, instance: str = "") -> Any: """One-line docstring — becomes the tool description shown to the LLM.""" return GET(instance, f"/repos/{owner}/{repo}/something") ``` Rules: - Use `@tool`, not `@mcp.tool()` — the former also registers the CLI subcommand. - `instance: str = ""` is always the last parameter - Use `_strip({...})` to drop `None` values before sending - URL-encode path segments that may contain slashes via `_b(value)` - Keep type hints accurate — the CLI builds its argparse flags from them (`int`→typed, `bool`→`--flag`/`--no-flag`, `list[str]`→space-separated values). - Update the tool count in `README.md`, the smoke test, and `test_cli.py` after adding or removing tools. --- ## Testing ### Philosophy The server has no business logic — every tool is a thin mapping from MCP arguments to a Gitea REST call. Tests therefore focus on **contract correctness**: - correct HTTP method and URL - correct request body / query parameters - correct handling of API error responses - all tools are registered and publicly discoverable We do **not** test Gitea's own behavior — that is covered by Gitea's own test suite. ### Test pyramid ``` ┌──────────────────────────────┐ │ Integration (opt-in) │ real Gitea via Docker Compose ├──────────────────────────────┤ │ Unit — per-tool contracts │ pytest + respx (httpx mock) ├──────────────────────────────┤ │ Smoke — registration check │ import + introspect registered tools └──────────────────────────────┘ ``` ### Tooling | Tool | Purpose | |------|---------| | `pytest` | test runner | | `respx` | mock `httpx.Client` at the transport level — no monkey-patching | | `pytest-cov` | coverage reporting | Install dev dependencies: ```bash uv add --dev pytest respx pytest-cov ``` ### Directory layout ``` tests/ ├── conftest.py # shared fixtures (mock transport, config override) ├── smoke/ │ └── test_registration.py # all tools are registered, count matches README └── unit/ ├── test_meta.py ├── test_repos.py ├── test_branches.py ├── test_files.py ├── test_issues.py ├── test_labels_milestones.py ├── test_pull_requests.py ├── test_releases.py ├── test_webhooks.py ├── test_users_orgs.py ├── test_commits.py └── test_notifications.py ``` ### What each unit test must cover For every tool function, write at minimum: 1. **Happy path** — correct HTTP method, URL pattern, and response forwarded as-is 2. **Parameter mapping** — optional params omitted when empty, present when set 3. **Error propagation** — non-2xx status raises `RuntimeError` with the status code ```python # Example pattern import respx, httpx, pytest from server.gitea_mcp import get_commit @respx.mock def test_get_commit_happy_path(): route = respx.get( "https://gitea.example.com/api/v1/repos/owner/repo/git/commits/abc123" ).mock(return_value=httpx.Response(200, json={"sha": "abc123"})) result = get_commit("owner", "repo", "abc123") assert route.called assert result["sha"] == "abc123" @respx.mock def test_get_commit_api_error(): respx.get(...).mock(return_value=httpx.Response(404, json={"message": "not found"})) with pytest.raises(RuntimeError, match="404"): get_commit("owner", "repo", "deadbeef") ``` ### Smoke test: tool registration ```python # tests/smoke/test_registration.py from server.gitea_mcp import mcp EXPECTED_TOOL_COUNT = 66 # update when adding tools def test_all_tools_registered(): tools = mcp.list_tools() # or equivalent FastMCP introspection assert len(tools) == EXPECTED_TOOL_COUNT ``` Update `EXPECTED_TOOL_COUNT` whenever you add or remove tools. If the count drifts from the README table, the smoke test catches it. ### Running tests ```bash # All tests uv run pytest # With coverage uv run pytest --cov=server --cov-report=term-missing # Smoke only uv run pytest tests/smoke/ # One module uv run pytest tests/unit/test_commits.py -v ``` ### Coverage target - **Unit tests**: 90 % line coverage on `server/gitea_mcp.py` - **Smoke tests**: 100 % tool registration (no gaps) - Integration tests are opt-in (require `GITEA_TEST_URL` + `GITEA_TEST_TOKEN` env vars) ### Integration tests (opt-in) Integration tests live in `tests/integration/` and are skipped unless the environment variables `GITEA_TEST_URL` and `GITEA_TEST_TOKEN` are set. Use a dedicated test organisation/user on a throwaway Gitea instance — tests create and delete real resources. ```python import pytest, os pytestmark = pytest.mark.skipif( not os.environ.get("GITEA_TEST_URL"), reason="integration tests require GITEA_TEST_URL" ) ``` For local development, a Docker Compose file (`docker-compose.test.yml`) can spin up a fresh Gitea instance automatically.