refactor: make gitea a self-contained Cline skill

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.
This commit is contained in:
2026-06-24 14:35:28 +03:00
parent 9a5750ddca
commit db5a43b19a
21 changed files with 77 additions and 58 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
"command": "uv",
"args": [
"run",
"--project", "${CLAUDE_PLUGIN_ROOT}",
"${CLAUDE_PLUGIN_ROOT}/server/gitea_mcp.py",
"--no-project",
"${CLAUDE_PLUGIN_ROOT}/skills/gitea/scripts/gitea_mcp.py",
"--config", "${CLAUDE_PLUGIN_ROOT}/config.json"
]
}
+7 -7
View File
@@ -2,14 +2,14 @@
## Project overview
Gitea REST API exposed as 66 tools from a single source file: `server/gitea_mcp.py`.
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 server/gitea_mcp.py --config config.json`). Tools = `mcp__gitea__<tool>`.
- **CLI skill:** `uv run server/gitea_mcp.py <tool> --flags` → one JSON object on
(`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`
@@ -118,7 +118,7 @@ For every tool function, write at minimum:
```python
# Example pattern
import respx, httpx, pytest
from server.gitea_mcp import get_commit
from gitea_mcp import get_commit
@respx.mock
def test_get_commit_happy_path():
@@ -142,7 +142,7 @@ def test_get_commit_api_error():
```python
# tests/smoke/test_registration.py
from server.gitea_mcp import mcp
from gitea_mcp import mcp
EXPECTED_TOOL_COUNT = 66 # update when adding tools
@@ -161,7 +161,7 @@ the README table, the smoke test catches it.
uv run pytest
# With coverage
uv run pytest --cov=server --cov-report=term-missing
uv run pytest --cov=gitea_mcp --cov-report=term-missing
# Smoke only
uv run pytest tests/smoke/
@@ -172,7 +172,7 @@ uv run pytest tests/unit/test_commits.py -v
### Coverage target
- **Unit tests**: 90 % line coverage on `server/gitea_mcp.py`
- **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)
+5 -5
View File
@@ -4,19 +4,19 @@ Full Gitea platform management — 66 tools for repositories, issues, PRs, branc
## Two ways to run (one source file)
`server/gitea_mcp.py` is both an MCP server and a CLI. The 66 tool functions are
`skills/gitea/scripts/gitea_mcp.py` is both an MCP server and a CLI. The 66 tool functions are
shared; only the entrypoint differs:
- **MCP server** — registered via `.mcp.json`, tools appear as `mcp__gitea__<tool>`.
This is how you connect it to an MCP-aware client.
- **CLI skill** — `uv run server/gitea_mcp.py <tool> --flags` prints one JSON
- **CLI skill** — `uv run skills/gitea/scripts/gitea_mcp.py <tool> --flags` prints one JSON
object on stdout. This is how Cline (and the `skills/gitea` skill) uses it.
```bash
# CLI examples
uv run --project . server/gitea_mcp.py --config ./config.json list_instances
uv run --project . server/gitea_mcp.py --config ./config.json get_repo --owner andrey --repo infra
uv run --project . server/gitea_mcp.py list-tools # self-describing catalogue
uv run skills/gitea/scripts/gitea_mcp.py --config ./config.json list_instances
uv run skills/gitea/scripts/gitea_mcp.py --config ./config.json get_repo --owner andrey --repo infra
uv run skills/gitea/scripts/gitea_mcp.py list-tools # self-describing catalogue
```
CLI output contract: exactly one JSON object — `{"success": true, "data": …}` or
+1
View File
@@ -17,6 +17,7 @@ dev = [
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["skills/gitea/scripts"]
[tool.uv]
package = false
+19 -21
View File
@@ -8,8 +8,6 @@ description: >
"переключись на work gitea", "gitea", "list repos", "create issue",
"open PR", "merge pull request", "create release", "add webhook",
"list branches", "gitea notifications", "check gitea connection".
metadata:
version: "0.3.0"
---
# Gitea Platform Assistant
@@ -18,9 +16,9 @@ Full Gitea REST API — 66 tools for repos, issues, PRs, branches, files, releas
webhooks, organizations. One source file, two ways to call it (pick whichever your
runtime gives you):
- **CLI (this skill):** `uv run … server/gitea_mcp.py <tool> --param value`
- **CLI (this skill):** `uv run scripts/gitea_mcp.py <tool> --param value`
prints **one JSON object** on stdout. This is the default for Cline.
- **MCP server:** the same tools exposed as `mcp__gitea__<tool>` when the file is
- **MCP server:** the same tools exposed as `mcp__gitea__<tool>` when the script is
registered via `.mcp.json`. Use these instead of `execute_command` if the tools
are already available to you.
@@ -29,21 +27,21 @@ differs (CLI uses `--dashed-flags`, MCP uses named arguments).
## Calling via CLI
```
ROOT = <plugin folder> # the gitea-platform-mcp directory
SCRIPT = $ROOT/server/gitea_mcp.py
```
Run through `uv` (deps come from the project; no separate install):
The script is bundled inside this skill at `scripts/gitea_mcp.py` and carries its
own PEP 723 dependency block, so `uv` provisions everything on first run — no
install step. Run it from the skill directory:
```
uv run --project "$ROOT" "$SCRIPT" --config "$ROOT/config.json" <tool> [--flags]
uv run scripts/gitea_mcp.py <tool> [--flags]
```
Convert snake_case params to `--dashed` flags. Booleans use `--flag` / `--no-flag`
(e.g. `--private` / `--no-private`). List params take space-separated values
(e.g. `--topics ci backend`).
Config is auto-discovered (`config.json` in the skill folder or a parent). Pass
`--config <path>` to point elsewhere.
**Output contract:** exactly one JSON object on stdout. Always check `success`:
```json
@@ -56,7 +54,7 @@ The process exits 0 even on tool-level errors — read `success`, not the exit c
## Discovery
```
uv run --project "$ROOT" "$SCRIPT" list-tools
uv run scripts/gitea_mcp.py list-tools
```
Returns every tool with its description and parameter spec (name / type /
@@ -67,12 +65,12 @@ required). Use it when unsure of a flag instead of guessing.
Always start by checking the connection:
```
uv run … "$SCRIPT" list_instances → configured instances + default
uv run … "$SCRIPT" get_current_user → verifies auth works
uv run scripts/gitea_mcp.py list_instances → configured instances + default
uv run scripts/gitea_mcp.py get_current_user → verifies auth works
```
If these fail with a config error, tell the user to fill in `config.json` in the
plugin folder (copy `config.example.json`).
If these fail with a config error, tell the user to create `config.json` in the
skill folder (copy `config.example.json` from the plugin root).
## Instance Selection
@@ -158,18 +156,18 @@ Every tool takes an optional `--instance` (default `""` = use config default).
```
# Open an issue
uv run --project "$ROOT" "$SCRIPT" create_issue \
uv run scripts/gitea_mcp.py create_issue \
--owner andrey --repo infra --title "CI flaky on main" --body "Fails ~1/5 runs"
# Edit then commit a file (two steps — get sha first)
uv run … "$SCRIPT" get_file --owner andrey --repo infra --filepath README.md
uv run … "$SCRIPT" update_file --owner andrey --repo infra --filepath README.md \
uv run scripts/gitea_mcp.py get_file --owner andrey --repo infra --filepath README.md
uv run scripts/gitea_mcp.py update_file --owner andrey --repo infra --filepath README.md \
--content "new text" --message "docs: tweak" --sha <sha-from-get_file>
# Create and merge a PR on the work instance
uv run … "$SCRIPT" create_pull_request --owner team --repo svc \
uv run scripts/gitea_mcp.py create_pull_request --owner team --repo svc \
--title "Feature X" --head feature/x --base main --instance work
uv run … "$SCRIPT" merge_pull_request --owner team --repo svc --index 42 \
uv run scripts/gitea_mcp.py merge_pull_request --owner team --repo svc --index 42 \
--merge-style squash --delete-branch-after-merge --instance work
```
@@ -1,7 +1,20 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["mcp[cli]>=1.0", "httpx>=0.27"]
# ///
"""
Gitea MCP Server full Gitea platform management via REST API.
Reads credentials from config.json (passed via --config argument or auto-discovered).
Gitea platform tools full Gitea management via the REST API.
Dual-mode, one file:
MCP server: no subcommand -> mcp.run() (registered via .mcp.json)
CLI skill: `<tool> --flags` -> one JSON line (run via `uv run`)
The PEP 723 block above lets `uv run gitea_mcp.py ...` provision deps with no
separate install, so the skill folder is self-contained.
Reads credentials from config.json (--config argument, GITEA_CONFIG env, or
auto-discovered by walking up from the script).
"""
import base64
@@ -20,7 +33,11 @@ from mcp.server.fastmcp import FastMCP
# ── Config loading ─────────────────────────────────────────────────────────────
def find_config() -> str:
"""Find config.json: --config arg > GITEA_CONFIG env > script dir > cwd."""
"""Find config.json: --config arg > GITEA_CONFIG env > walk up from script > cwd.
The script lives at skills/gitea/scripts/, so config.json may sit in the skill
folder (skills/gitea/) or at the plugin root walk up a few levels to find it.
"""
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("--config", default="")
args, _ = parser.parse_known_args()
@@ -28,11 +45,14 @@ def find_config() -> str:
return args.config
if os.environ.get("GITEA_CONFIG"):
return os.environ["GITEA_CONFIG"]
# Look next to the script
script_dir = os.path.dirname(os.path.abspath(__file__))
candidate = os.path.join(script_dir, "..", "config.json")
if os.path.exists(candidate):
return os.path.normpath(candidate)
# Walk up from the script directory looking for config.json (skill root,
# then plugin root, etc.).
here = os.path.dirname(os.path.abspath(__file__))
for _ in range(5):
candidate = os.path.join(here, "config.json")
if os.path.exists(candidate):
return candidate
here = os.path.dirname(here)
return "config.json"
CONFIG_PATH = find_config()
+1 -1
View File
@@ -1,6 +1,6 @@
import os
from pathlib import Path
# Must be set at module level, before any import of server.gitea_mcp,
# Must be set at module level, before any import of gitea_mcp,
# so find_config() picks up the test credentials instead of the real config.json.
os.environ.setdefault("GITEA_CONFIG", str(Path(__file__).parent / "config.test.json"))
+1 -1
View File
@@ -1,5 +1,5 @@
import asyncio
from server.gitea_mcp import mcp
from gitea_mcp import mcp
EXPECTED_TOOL_COUNT = 66
+1 -1
View File
@@ -2,7 +2,7 @@ import json
import pytest
import httpx
import respx
from server.gitea_mcp import list_branches, get_branch, create_branch, delete_branch, list_tags
from gitea_mcp import list_branches, get_branch, create_branch, delete_branch, list_tags
BASE = "https://gitea.test/api/v1"
+1 -1
View File
@@ -5,7 +5,7 @@ import json
import httpx
import respx
from server.gitea_mcp import TOOLS, dispatch, main
from gitea_mcp import TOOLS, dispatch, main
BASE = "https://gitea.test/api/v1"
+1 -1
View File
@@ -1,7 +1,7 @@
import pytest
import httpx
import respx
from server.gitea_mcp import list_commits, get_commit, compare_branches, list_repo_contributors
from gitea_mcp import list_commits, get_commit, compare_branches, list_repo_contributors
BASE = "https://gitea.test/api/v1"
+1 -1
View File
@@ -3,7 +3,7 @@ import json
import pytest
import httpx
import respx
from server.gitea_mcp import list_directory, get_file, create_file, update_file, delete_file
from gitea_mcp import list_directory, get_file, create_file, update_file, delete_file
BASE = "https://gitea.test/api/v1"
+1 -1
View File
@@ -2,7 +2,7 @@ import json
import pytest
import httpx
import respx
from server.gitea_mcp import (
from gitea_mcp import (
list_issues, get_issue, create_issue, update_issue, close_issue, reopen_issue,
list_issue_comments, add_issue_comment, edit_issue_comment, delete_issue_comment,
)
+1 -1
View File
@@ -2,7 +2,7 @@ import json
import pytest
import httpx
import respx
from server.gitea_mcp import (
from gitea_mcp import (
list_labels, create_label, add_issue_labels, list_milestones, create_milestone,
)
+1 -1
View File
@@ -1,7 +1,7 @@
import pytest
import httpx
import respx
from server.gitea_mcp import list_instances, get_server_info, get_current_user
from gitea_mcp import list_instances, get_server_info, get_current_user
BASE = "https://gitea.test/api/v1"
+1 -1
View File
@@ -2,7 +2,7 @@ import json
import pytest
import httpx
import respx
from server.gitea_mcp import list_notifications, mark_all_notifications_read
from gitea_mcp import list_notifications, mark_all_notifications_read
BASE = "https://gitea.test/api/v1"
+1 -1
View File
@@ -2,7 +2,7 @@ import json
import pytest
import httpx
import respx
from server.gitea_mcp import (
from gitea_mcp import (
list_pull_requests, get_pull_request, create_pull_request, update_pull_request,
merge_pull_request, get_pr_diff, list_pr_files, list_pr_reviews, create_pr_review,
)
+1 -1
View File
@@ -2,7 +2,7 @@ import json
import pytest
import httpx
import respx
from server.gitea_mcp import list_releases, get_latest_release, create_release, delete_release
from gitea_mcp import list_releases, get_latest_release, create_release, delete_release
BASE = "https://gitea.test/api/v1"
+1 -1
View File
@@ -2,7 +2,7 @@ import json
import pytest
import httpx
import respx
from server.gitea_mcp import (
from gitea_mcp import (
list_my_repos, search_repos, get_repo, create_repo, create_org_repo,
update_repo, delete_repo, fork_repo, set_repo_topics,
)
+1 -1
View File
@@ -1,7 +1,7 @@
import pytest
import httpx
import respx
from server.gitea_mcp import (
from gitea_mcp import (
get_user, search_users, list_my_orgs, get_org,
list_org_repos, list_org_members, list_org_teams,
)
+1 -1
View File
@@ -2,7 +2,7 @@ import json
import pytest
import httpx
import respx
from server.gitea_mcp import list_repo_webhooks, create_repo_webhook, delete_repo_webhook
from gitea_mcp import list_repo_webhooks, create_repo_webhook, delete_repo_webhook
BASE = "https://gitea.test/api/v1"