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", "command": "uv",
"args": [ "args": [
"run", "run",
"--project", "${CLAUDE_PLUGIN_ROOT}", "--no-project",
"${CLAUDE_PLUGIN_ROOT}/server/gitea_mcp.py", "${CLAUDE_PLUGIN_ROOT}/skills/gitea/scripts/gitea_mcp.py",
"--config", "${CLAUDE_PLUGIN_ROOT}/config.json" "--config", "${CLAUDE_PLUGIN_ROOT}/config.json"
] ]
} }
+7 -7
View File
@@ -2,14 +2,14 @@
## Project overview ## 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). Config in `config.json` (gitignored).
**Dual-mode — one file, two entrypoints over the same tool functions:** **Dual-mode — one file, two entrypoints over the same tool functions:**
- **MCP server:** no subcommand → `mcp.run()`. Launched by `.mcp.json` - **MCP server:** no subcommand → `mcp.run()`. Launched by `.mcp.json`
(`uv run server/gitea_mcp.py --config config.json`). Tools = `mcp__gitea__<tool>`. (`uv run skills/gitea/scripts/gitea_mcp.py --config config.json`). Tools = `mcp__gitea__<tool>`.
- **CLI skill:** `uv run server/gitea_mcp.py <tool> --flags` → one JSON object on - **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. 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` 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 ```python
# Example pattern # Example pattern
import respx, httpx, pytest import respx, httpx, pytest
from server.gitea_mcp import get_commit from gitea_mcp import get_commit
@respx.mock @respx.mock
def test_get_commit_happy_path(): def test_get_commit_happy_path():
@@ -142,7 +142,7 @@ def test_get_commit_api_error():
```python ```python
# tests/smoke/test_registration.py # tests/smoke/test_registration.py
from server.gitea_mcp import mcp from gitea_mcp import mcp
EXPECTED_TOOL_COUNT = 66 # update when adding tools EXPECTED_TOOL_COUNT = 66 # update when adding tools
@@ -161,7 +161,7 @@ the README table, the smoke test catches it.
uv run pytest uv run pytest
# With coverage # With coverage
uv run pytest --cov=server --cov-report=term-missing uv run pytest --cov=gitea_mcp --cov-report=term-missing
# Smoke only # Smoke only
uv run pytest tests/smoke/ uv run pytest tests/smoke/
@@ -172,7 +172,7 @@ uv run pytest tests/unit/test_commits.py -v
### Coverage target ### 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) - **Smoke tests**: 100 % tool registration (no gaps)
- Integration tests are opt-in (require `GITEA_TEST_URL` + `GITEA_TEST_TOKEN` env vars) - 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) ## 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: shared; only the entrypoint differs:
- **MCP server** — registered via `.mcp.json`, tools appear as `mcp__gitea__<tool>`. - **MCP server** — registered via `.mcp.json`, tools appear as `mcp__gitea__<tool>`.
This is how you connect it to an MCP-aware client. 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. object on stdout. This is how Cline (and the `skills/gitea` skill) uses it.
```bash ```bash
# CLI examples # CLI examples
uv run --project . server/gitea_mcp.py --config ./config.json list_instances uv run skills/gitea/scripts/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 skills/gitea/scripts/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 list-tools # self-describing catalogue
``` ```
CLI output contract: exactly one JSON object — `{"success": true, "data": …}` or CLI output contract: exactly one JSON object — `{"success": true, "data": …}` or
+1
View File
@@ -17,6 +17,7 @@ dev = [
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
pythonpath = ["skills/gitea/scripts"]
[tool.uv] [tool.uv]
package = false package = false
+19 -21
View File
@@ -8,8 +8,6 @@ description: >
"переключись на work gitea", "gitea", "list repos", "create issue", "переключись на work gitea", "gitea", "list repos", "create issue",
"open PR", "merge pull request", "create release", "add webhook", "open PR", "merge pull request", "create release", "add webhook",
"list branches", "gitea notifications", "check gitea connection". "list branches", "gitea notifications", "check gitea connection".
metadata:
version: "0.3.0"
--- ---
# Gitea Platform Assistant # 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 webhooks, organizations. One source file, two ways to call it (pick whichever your
runtime gives you): 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. 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 registered via `.mcp.json`. Use these instead of `execute_command` if the tools
are already available to you. are already available to you.
@@ -29,21 +27,21 @@ differs (CLI uses `--dashed-flags`, MCP uses named arguments).
## Calling via CLI ## Calling via CLI
``` The script is bundled inside this skill at `scripts/gitea_mcp.py` and carries its
ROOT = <plugin folder> # the gitea-platform-mcp directory own PEP 723 dependency block, so `uv` provisions everything on first run — no
SCRIPT = $ROOT/server/gitea_mcp.py install step. Run it from the skill directory:
```
Run through `uv` (deps come from the project; no separate install):
``` ```
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` 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. `--private` / `--no-private`). List params take space-separated values
(e.g. `--topics ci backend`). (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`: **Output contract:** exactly one JSON object on stdout. Always check `success`:
```json ```json
@@ -56,7 +54,7 @@ The process exits 0 even on tool-level errors — read `success`, not the exit c
## Discovery ## 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 / 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: Always start by checking the connection:
``` ```
uv run … "$SCRIPT" list_instances → configured instances + default uv run scripts/gitea_mcp.py list_instances → configured instances + default
uv run … "$SCRIPT" get_current_user → verifies auth works 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 If these fail with a config error, tell the user to create `config.json` in the
plugin folder (copy `config.example.json`). skill folder (copy `config.example.json` from the plugin root).
## Instance Selection ## Instance Selection
@@ -158,18 +156,18 @@ Every tool takes an optional `--instance` (default `""` = use config default).
``` ```
# Open an issue # 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" --owner andrey --repo infra --title "CI flaky on main" --body "Fails ~1/5 runs"
# Edit then commit a file (two steps — get sha first) # Edit then commit a file (two steps — get sha first)
uv run … "$SCRIPT" get_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 … "$SCRIPT" update_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> --content "new text" --message "docs: tweak" --sha <sha-from-get_file>
# Create and merge a PR on the work instance # 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 --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 --merge-style squash --delete-branch-after-merge --instance work
``` ```
@@ -1,7 +1,20 @@
#!/usr/bin/env python3 #!/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. Gitea platform tools full Gitea management via the REST API.
Reads credentials from config.json (passed via --config argument or auto-discovered).
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 import base64
@@ -20,7 +33,11 @@ from mcp.server.fastmcp import FastMCP
# ── Config loading ───────────────────────────────────────────────────────────── # ── Config loading ─────────────────────────────────────────────────────────────
def find_config() -> str: 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 = argparse.ArgumentParser(add_help=False)
parser.add_argument("--config", default="") parser.add_argument("--config", default="")
args, _ = parser.parse_known_args() args, _ = parser.parse_known_args()
@@ -28,11 +45,14 @@ def find_config() -> str:
return args.config return args.config
if os.environ.get("GITEA_CONFIG"): if os.environ.get("GITEA_CONFIG"):
return os.environ["GITEA_CONFIG"] return os.environ["GITEA_CONFIG"]
# Look next to the script # Walk up from the script directory looking for config.json (skill root,
script_dir = os.path.dirname(os.path.abspath(__file__)) # then plugin root, etc.).
candidate = os.path.join(script_dir, "..", "config.json") here = os.path.dirname(os.path.abspath(__file__))
for _ in range(5):
candidate = os.path.join(here, "config.json")
if os.path.exists(candidate): if os.path.exists(candidate):
return os.path.normpath(candidate) return candidate
here = os.path.dirname(here)
return "config.json" return "config.json"
CONFIG_PATH = find_config() CONFIG_PATH = find_config()
+1 -1
View File
@@ -1,6 +1,6 @@
import os import os
from pathlib import Path 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. # 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")) os.environ.setdefault("GITEA_CONFIG", str(Path(__file__).parent / "config.test.json"))
+1 -1
View File
@@ -1,5 +1,5 @@
import asyncio import asyncio
from server.gitea_mcp import mcp from gitea_mcp import mcp
EXPECTED_TOOL_COUNT = 66 EXPECTED_TOOL_COUNT = 66
+1 -1
View File
@@ -2,7 +2,7 @@ import json
import pytest import pytest
import httpx import httpx
import respx 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" BASE = "https://gitea.test/api/v1"
+1 -1
View File
@@ -5,7 +5,7 @@ import json
import httpx import httpx
import respx import respx
from server.gitea_mcp import TOOLS, dispatch, main from gitea_mcp import TOOLS, dispatch, main
BASE = "https://gitea.test/api/v1" BASE = "https://gitea.test/api/v1"
+1 -1
View File
@@ -1,7 +1,7 @@
import pytest import pytest
import httpx import httpx
import respx 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" BASE = "https://gitea.test/api/v1"
+1 -1
View File
@@ -3,7 +3,7 @@ import json
import pytest import pytest
import httpx import httpx
import respx 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" BASE = "https://gitea.test/api/v1"
+1 -1
View File
@@ -2,7 +2,7 @@ import json
import pytest import pytest
import httpx import httpx
import respx import respx
from server.gitea_mcp import ( from gitea_mcp import (
list_issues, get_issue, create_issue, update_issue, close_issue, reopen_issue, list_issues, get_issue, create_issue, update_issue, close_issue, reopen_issue,
list_issue_comments, add_issue_comment, edit_issue_comment, delete_issue_comment, 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 pytest
import httpx import httpx
import respx import respx
from server.gitea_mcp import ( from gitea_mcp import (
list_labels, create_label, add_issue_labels, list_milestones, create_milestone, list_labels, create_label, add_issue_labels, list_milestones, create_milestone,
) )
+1 -1
View File
@@ -1,7 +1,7 @@
import pytest import pytest
import httpx import httpx
import respx 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" BASE = "https://gitea.test/api/v1"
+1 -1
View File
@@ -2,7 +2,7 @@ import json
import pytest import pytest
import httpx import httpx
import respx 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" BASE = "https://gitea.test/api/v1"
+1 -1
View File
@@ -2,7 +2,7 @@ import json
import pytest import pytest
import httpx import httpx
import respx import respx
from server.gitea_mcp import ( from gitea_mcp import (
list_pull_requests, get_pull_request, create_pull_request, update_pull_request, 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, 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 pytest
import httpx import httpx
import respx 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" BASE = "https://gitea.test/api/v1"
+1 -1
View File
@@ -2,7 +2,7 @@ import json
import pytest import pytest
import httpx import httpx
import respx import respx
from server.gitea_mcp import ( from gitea_mcp import (
list_my_repos, search_repos, get_repo, create_repo, create_org_repo, list_my_repos, search_repos, get_repo, create_repo, create_org_repo,
update_repo, delete_repo, fork_repo, set_repo_topics, update_repo, delete_repo, fork_repo, set_repo_topics,
) )
+1 -1
View File
@@ -1,7 +1,7 @@
import pytest import pytest
import httpx import httpx
import respx import respx
from server.gitea_mcp import ( from gitea_mcp import (
get_user, search_users, list_my_orgs, get_org, get_user, search_users, list_my_orgs, get_org,
list_org_repos, list_org_members, list_org_teams, list_org_repos, list_org_members, list_org_teams,
) )
+1 -1
View File
@@ -2,7 +2,7 @@ import json
import pytest import pytest
import httpx import httpx
import respx 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" BASE = "https://gitea.test/api/v1"