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.
6.2 KiB
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 theskills/giteaCline 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-Byor 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:
@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 dropNonevalues 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, andtest_cli.pyafter 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:
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:
- Happy path — correct HTTP method, URL pattern, and response forwarded as-is
- Parameter mapping — optional params omitted when empty, present when set
- Error propagation — non-2xx status raises
RuntimeErrorwith the status code
# 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
# 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
# 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_TOKENenv 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.
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.