db5a43b19a
Relocate the script into the skill (skills/gitea/scripts/gitea_mcp.py) and add a PEP 723 dependency block, so `uv run scripts/gitea_mcp.py` provisions deps with no install step and the skill folder is portable (drop into ~/.cline/skills/). Drop the non-standard `metadata` frontmatter — Cline only recognizes name + description. - .mcp.json points at the relocated script and runs with --no-project (PEP 723) - SKILL.md uses relative scripts/ paths; metadata block removed - find_config walks up the tree to locate config.json (skill root or plugin root) - tests import gitea_mcp via pytest pythonpath; README/CLAUDE paths updated Tests: 119 passed, 92% coverage. Standalone list-tools verified via uv --no-project.
196 lines
6.2 KiB
Markdown
196 lines
6.2 KiB
Markdown
# CLAUDE.md — Developer Guide
|
|
|
|
## Project overview
|
|
|
|
Gitea REST API exposed as 66 tools from a single source file: `skills/gitea/scripts/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 skills/gitea/scripts/gitea_mcp.py --config config.json`). Tools = `mcp__gitea__<tool>`.
|
|
- **CLI skill:** `uv run skills/gitea/scripts/gitea_mcp.py <tool> --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 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 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=gitea_mcp --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 `skills/gitea/scripts/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.
|