diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index aa4ac84..df38b58 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "gitea-platform-mcp", - "version": "0.1.0", + "version": "0.3.0", "description": "Full Gitea platform management — repositories, issues, PRs, branches, releases, webhooks, organizations", "author": { "name": "Andrey Limasov" diff --git a/.gitignore b/.gitignore index 49ff1b4..94046c0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,12 @@ __pycache__/ dist/ .env +# Test / coverage artifacts +.coverage +.coverage.* +.pytest_cache/ +htmlcov/ + # Gitea API spec — large auto-generated file, download from your Gitea instance gitea-api.yaml diff --git a/CLAUDE.md b/CLAUDE.md index c30d240..28b1b28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,8 +2,19 @@ ## Project overview -MCP server exposing Gitea REST API as 66 tools. Single source file: `server/gitea_mcp.py`. -Config in `config.json` (gitignored). Run via `uv run server/gitea_mcp.py`. +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 @@ -12,20 +23,25 @@ Config in `config.json` (gitignored). Run via `uv run server/gitea_mcp.py`. ## Adding tools -Each tool is a `@mcp.tool()` function. Pattern: +Each tool is a `@tool` function (registers it for **both** the MCP server and the +CLI). Pattern: ```python -@mcp.tool() +@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)` -- Update the tool count in `README.md` after adding tools +- 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. --- diff --git a/README.md b/README.md index 9ebeabd..8c321e4 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,26 @@ Full Gitea platform management — 66 tools for repositories, issues, PRs, branches, files, releases, webhooks, and organizations. +## Two ways to run (one source file) + +`server/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__`. + This is how you connect it to an MCP-aware client. +- **CLI skill** — `uv run server/gitea_mcp.py --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 +``` + +CLI output contract: exactly one JSON object — `{"success": true, "data": …}` or +`{"success": false, "error": …}`. Check the `success` field, not the exit code. + ## Setup (one-time) ### 1. Edit config.json diff --git a/pyproject.toml b/pyproject.toml index 7877229..dd680b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "gitea-platform-mcp" -version = "0.2.0" -description = "Full Gitea platform management via MCP" +version = "0.3.0" +description = "Full Gitea platform management — MCP server and CLI skill in one file" requires-python = ">=3.10" dependencies = [ "mcp[cli]>=1.0", diff --git a/server/gitea_mcp.py b/server/gitea_mcp.py index 871a362..7fcca37 100644 --- a/server/gitea_mcp.py +++ b/server/gitea_mcp.py @@ -8,6 +8,9 @@ import base64 import os import sys import json +import types +import inspect +import typing import argparse import httpx from typing import Any @@ -107,11 +110,27 @@ def DELETE(instance, path, body=None): mcp = FastMCP("gitea") +# Single registry of every tool, keyed by name. Populated by the @tool decorator +# below and consumed by the CLI dispatcher at the bottom of the file. This is what +# lets one source file serve both as an MCP server and as a CLI-backed skill. +TOOLS: dict[str, Any] = {} + + +def tool(fn): + """Register a function as BOTH an MCP tool and a CLI subcommand. + + The wrapped function stays directly callable (mcp.tool() returns the original + function), so unit tests and the CLI dispatcher can invoke it without going + through the MCP transport. + """ + TOOLS[fn.__name__] = fn + return mcp.tool()(fn) + # ══════════════════════════════════════════════════════════════════════════════ # META # ══════════════════════════════════════════════════════════════════════════════ -@mcp.tool() +@tool def list_instances() -> Any: """List all configured Gitea instances and show which one is default.""" return { @@ -119,12 +138,12 @@ def list_instances() -> Any: "default": DEFAULT_INSTANCE, } -@mcp.tool() +@tool def get_server_info(instance: str = "") -> Any: """Get Gitea server version and info.""" return GET(instance, "/version") -@mcp.tool() +@tool def get_current_user(instance: str = "") -> Any: """Get information about the authenticated user.""" return GET(instance, "/user") @@ -133,22 +152,22 @@ def get_current_user(instance: str = "") -> Any: # REPOSITORIES # ══════════════════════════════════════════════════════════════════════════════ -@mcp.tool() +@tool def list_my_repos(limit: int = 50, page: int = 1, instance: str = "") -> Any: """List repositories of the authenticated user.""" return GET(instance, "/user/repos", {"limit": limit, "page": page}) -@mcp.tool() +@tool def search_repos(query: str, limit: int = 20, page: int = 1, instance: str = "") -> Any: """Search repositories across the Gitea instance.""" return GET(instance, "/repos/search", {"q": query, "limit": limit, "page": page}) -@mcp.tool() +@tool def get_repo(owner: str, repo: str, instance: str = "") -> Any: """Get details of a repository.""" return GET(instance, f"/repos/{owner}/{repo}") -@mcp.tool() +@tool def create_repo( name: str, description: str = "", @@ -170,7 +189,7 @@ def create_repo( "license": license or None, })) -@mcp.tool() +@tool def create_org_repo( org: str, name: str, @@ -189,7 +208,7 @@ def create_org_repo( "default_branch": default_branch, })) -@mcp.tool() +@tool def update_repo( owner: str, repo: str, description: str = "", private: bool | None = None, website: str = "", @@ -202,12 +221,12 @@ def update_repo( "website": website or None, })) -@mcp.tool() +@tool def delete_repo(owner: str, repo: str, instance: str = "") -> Any: """Delete a repository permanently.""" return DELETE(instance, f"/repos/{owner}/{repo}") -@mcp.tool() +@tool def fork_repo(owner: str, repo: str, organization: str = "", new_repo_name: str = "", instance: str = "") -> Any: """Fork a repository.""" return POST(instance, f"/repos/{owner}/{repo}/forks", _strip({ @@ -215,7 +234,7 @@ def fork_repo(owner: str, repo: str, organization: str = "", new_repo_name: str "name": new_repo_name or None, })) -@mcp.tool() +@tool def set_repo_topics(owner: str, repo: str, topics: list[str], instance: str = "") -> Any: """Replace all topics of a repository.""" return PUT(instance, f"/repos/{owner}/{repo}/topics", {"topics": topics}) @@ -224,17 +243,17 @@ def set_repo_topics(owner: str, repo: str, topics: list[str], instance: str = "" # BRANCHES & TAGS # ══════════════════════════════════════════════════════════════════════════════ -@mcp.tool() +@tool def list_branches(owner: str, repo: str, limit: int = 50, instance: str = "") -> Any: """List all branches in a repository.""" return GET(instance, f"/repos/{owner}/{repo}/branches", {"limit": limit}) -@mcp.tool() +@tool def get_branch(owner: str, repo: str, branch: str, instance: str = "") -> Any: """Get details of a branch including its latest commit.""" return GET(instance, f"/repos/{owner}/{repo}/branches/{_b(branch)}") -@mcp.tool() +@tool def create_branch(owner: str, repo: str, new_branch_name: str, old_branch_name: str = "", instance: str = "") -> Any: """Create a branch. old_branch_name defaults to the repo's default branch.""" return POST(instance, f"/repos/{owner}/{repo}/branches", _strip({ @@ -242,12 +261,12 @@ def create_branch(owner: str, repo: str, new_branch_name: str, old_branch_name: "old_branch_name": old_branch_name or None, })) -@mcp.tool() +@tool def delete_branch(owner: str, repo: str, branch: str, instance: str = "") -> Any: """Delete a branch.""" return DELETE(instance, f"/repos/{owner}/{repo}/branches/{_b(branch)}") -@mcp.tool() +@tool def list_tags(owner: str, repo: str, limit: int = 20, instance: str = "") -> Any: """List tags in a repository.""" return GET(instance, f"/repos/{owner}/{repo}/tags", {"limit": limit}) @@ -256,17 +275,17 @@ def list_tags(owner: str, repo: str, limit: int = 20, instance: str = "") -> Any # FILES & CONTENTS # ══════════════════════════════════════════════════════════════════════════════ -@mcp.tool() +@tool def list_directory(owner: str, repo: str, path: str = "", ref: str = "", instance: str = "") -> Any: """List files and directories at a path. ref can be branch/tag/commit.""" return GET(instance, f"/repos/{owner}/{repo}/contents/{path}", _strip({"ref": ref or None})) -@mcp.tool() +@tool def get_file(owner: str, repo: str, filepath: str, ref: str = "", instance: str = "") -> Any: """Get file content and metadata. Returns base64-encoded content + SHA.""" return GET(instance, f"/repos/{owner}/{repo}/contents/{filepath}", _strip({"ref": ref or None})) -@mcp.tool() +@tool def create_file( owner: str, repo: str, filepath: str, content: str, message: str, @@ -280,7 +299,7 @@ def create_file( "branch": branch or None, })) -@mcp.tool() +@tool def update_file( owner: str, repo: str, filepath: str, content: str, message: str, sha: str, @@ -295,7 +314,7 @@ def update_file( "branch": branch or None, })) -@mcp.tool() +@tool def delete_file( owner: str, repo: str, filepath: str, message: str, sha: str, @@ -310,7 +329,7 @@ def delete_file( # ISSUES # ══════════════════════════════════════════════════════════════════════════════ -@mcp.tool() +@tool def list_issues( owner: str, repo: str, state: str = "open", issue_type: str = "issues", @@ -325,12 +344,12 @@ def list_issues( "limit": limit, "page": page, })) -@mcp.tool() +@tool def get_issue(owner: str, repo: str, index: int, instance: str = "") -> Any: """Get a single issue by number.""" return GET(instance, f"/repos/{owner}/{repo}/issues/{index}") -@mcp.tool() +@tool def create_issue( owner: str, repo: str, title: str, body: str = "", assignees: list[str] | None = None, labels: list[int] | None = None, milestone: int = 0, @@ -345,7 +364,7 @@ def create_issue( "milestone": milestone or None, })) -@mcp.tool() +@tool def update_issue( owner: str, repo: str, index: int, title: str = "", body: str = "", state: str = "", @@ -356,32 +375,32 @@ def update_issue( "title": title or None, "body": body or None, "state": state or None, })) -@mcp.tool() +@tool def close_issue(owner: str, repo: str, index: int, instance: str = "") -> Any: """Close an issue.""" return PATCH(instance, f"/repos/{owner}/{repo}/issues/{index}", {"state": "closed"}) -@mcp.tool() +@tool def reopen_issue(owner: str, repo: str, index: int, instance: str = "") -> Any: """Reopen a closed issue.""" return PATCH(instance, f"/repos/{owner}/{repo}/issues/{index}", {"state": "open"}) -@mcp.tool() +@tool def list_issue_comments(owner: str, repo: str, index: int, instance: str = "") -> Any: """List comments on an issue.""" return GET(instance, f"/repos/{owner}/{repo}/issues/{index}/comments") -@mcp.tool() +@tool def add_issue_comment(owner: str, repo: str, index: int, body: str, instance: str = "") -> Any: """Add a comment to an issue or PR.""" return POST(instance, f"/repos/{owner}/{repo}/issues/{index}/comments", {"body": body}) -@mcp.tool() +@tool def edit_issue_comment(owner: str, repo: str, comment_id: int, body: str, instance: str = "") -> Any: """Edit an existing comment.""" return PATCH(instance, f"/repos/{owner}/{repo}/issues/comments/{comment_id}", {"body": body}) -@mcp.tool() +@tool def delete_issue_comment(owner: str, repo: str, comment_id: int, instance: str = "") -> Any: """Delete a comment.""" return DELETE(instance, f"/repos/{owner}/{repo}/issues/comments/{comment_id}") @@ -390,29 +409,29 @@ def delete_issue_comment(owner: str, repo: str, comment_id: int, instance: str = # LABELS & MILESTONES # ══════════════════════════════════════════════════════════════════════════════ -@mcp.tool() +@tool def list_labels(owner: str, repo: str, instance: str = "") -> Any: """List all labels in a repository.""" return GET(instance, f"/repos/{owner}/{repo}/labels") -@mcp.tool() +@tool def create_label(owner: str, repo: str, name: str, color: str, description: str = "", instance: str = "") -> Any: """Create a label. color is hex like #ff0000.""" return POST(instance, f"/repos/{owner}/{repo}/labels", _strip({ "name": name, "color": color, "description": description or None, })) -@mcp.tool() +@tool def add_issue_labels(owner: str, repo: str, index: int, label_ids: list[int], instance: str = "") -> Any: """Add labels to an issue.""" return POST(instance, f"/repos/{owner}/{repo}/issues/{index}/labels", {"labels": label_ids}) -@mcp.tool() +@tool def list_milestones(owner: str, repo: str, state: str = "open", instance: str = "") -> Any: """List milestones. state: open|closed|all.""" return GET(instance, f"/repos/{owner}/{repo}/milestones", {"state": state}) -@mcp.tool() +@tool def create_milestone(owner: str, repo: str, title: str, description: str = "", due_on: str = "", instance: str = "") -> Any: """Create a milestone. due_on: 2024-12-31T00:00:00Z""" return POST(instance, f"/repos/{owner}/{repo}/milestones", _strip({ @@ -423,17 +442,17 @@ def create_milestone(owner: str, repo: str, title: str, description: str = "", d # PULL REQUESTS # ══════════════════════════════════════════════════════════════════════════════ -@mcp.tool() +@tool def list_pull_requests(owner: str, repo: str, state: str = "open", limit: int = 20, instance: str = "") -> Any: """List pull requests. state: open|closed|all.""" return GET(instance, f"/repos/{owner}/{repo}/pulls", {"state": state, "limit": limit}) -@mcp.tool() +@tool def get_pull_request(owner: str, repo: str, index: int, instance: str = "") -> Any: """Get a pull request by index.""" return GET(instance, f"/repos/{owner}/{repo}/pulls/{index}") -@mcp.tool() +@tool def create_pull_request( owner: str, repo: str, title: str, head: str, base: str, body: str = "", assignees: list[str] | None = None, labels: list[int] | None = None, @@ -447,7 +466,7 @@ def create_pull_request( "labels": labels or None, })) -@mcp.tool() +@tool def update_pull_request( owner: str, repo: str, index: int, title: str = "", body: str = "", state: str = "", @@ -458,7 +477,7 @@ def update_pull_request( "title": title or None, "body": body or None, "state": state or None, })) -@mcp.tool() +@tool def merge_pull_request( owner: str, repo: str, index: int, merge_style: str = "merge", @@ -474,7 +493,7 @@ def merge_pull_request( "delete_branch_after_merge": delete_branch_after_merge, })) -@mcp.tool() +@tool def get_pr_diff(owner: str, repo: str, index: int, instance: str = "") -> Any: """Get the diff of a pull request as text.""" api, headers = _get_instance(instance) @@ -484,7 +503,7 @@ def get_pr_diff(owner: str, repo: str, index: int, instance: str = "") -> Any: raise RuntimeError(f"Gitea API {r.status_code}: {r.text}") return {"diff": r.text} -@mcp.tool() +@tool def list_pr_files( owner: str, repo: str, index: int, limit: int = 50, page: int = 1, @@ -497,12 +516,12 @@ def list_pr_files( "whitespace": whitespace or None, })) -@mcp.tool() +@tool def list_pr_reviews(owner: str, repo: str, index: int, instance: str = "") -> Any: """List reviews on a pull request.""" return GET(instance, f"/repos/{owner}/{repo}/pulls/{index}/reviews") -@mcp.tool() +@tool def create_pr_review( owner: str, repo: str, index: int, state: str, body: str = "", @@ -517,17 +536,17 @@ def create_pr_review( # RELEASES # ══════════════════════════════════════════════════════════════════════════════ -@mcp.tool() +@tool def list_releases(owner: str, repo: str, limit: int = 10, instance: str = "") -> Any: """List releases.""" return GET(instance, f"/repos/{owner}/{repo}/releases", {"limit": limit}) -@mcp.tool() +@tool def get_latest_release(owner: str, repo: str, instance: str = "") -> Any: """Get the latest release.""" return GET(instance, f"/repos/{owner}/{repo}/releases/latest") -@mcp.tool() +@tool def create_release( owner: str, repo: str, tag_name: str, name: str, body: str = "", draft: bool = False, prerelease: bool = False, @@ -541,7 +560,7 @@ def create_release( "target_commitish": target_commitish or None, })) -@mcp.tool() +@tool def delete_release(owner: str, repo: str, release_id: int, instance: str = "") -> Any: """Delete a release.""" return DELETE(instance, f"/repos/{owner}/{repo}/releases/{release_id}") @@ -550,12 +569,12 @@ def delete_release(owner: str, repo: str, release_id: int, instance: str = "") - # WEBHOOKS # ══════════════════════════════════════════════════════════════════════════════ -@mcp.tool() +@tool def list_repo_webhooks(owner: str, repo: str, instance: str = "") -> Any: """List webhooks for a repository.""" return GET(instance, f"/repos/{owner}/{repo}/hooks") -@mcp.tool() +@tool def create_repo_webhook( owner: str, repo: str, url: str, events: list[str], secret: str = "", active: bool = True, @@ -569,7 +588,7 @@ def create_repo_webhook( "active": active, }) -@mcp.tool() +@tool def delete_repo_webhook(owner: str, repo: str, hook_id: int, instance: str = "") -> Any: """Delete a webhook.""" return DELETE(instance, f"/repos/{owner}/{repo}/hooks/{hook_id}") @@ -578,37 +597,37 @@ def delete_repo_webhook(owner: str, repo: str, hook_id: int, instance: str = "") # USERS & ORGANIZATIONS # ══════════════════════════════════════════════════════════════════════════════ -@mcp.tool() +@tool def get_user(username: str, instance: str = "") -> Any: """Get public information about a user.""" return GET(instance, f"/users/{username}") -@mcp.tool() +@tool def search_users(query: str, limit: int = 10, instance: str = "") -> Any: """Search for users.""" return GET(instance, "/users/search", {"q": query, "limit": limit}) -@mcp.tool() +@tool def list_my_orgs(instance: str = "") -> Any: """List organizations the authenticated user belongs to.""" return GET(instance, "/user/orgs") -@mcp.tool() +@tool def get_org(org: str, instance: str = "") -> Any: """Get information about an organization.""" return GET(instance, f"/orgs/{org}") -@mcp.tool() +@tool def list_org_repos(org: str, limit: int = 50, instance: str = "") -> Any: """List repositories of an organization.""" return GET(instance, f"/orgs/{org}/repos", {"limit": limit}) -@mcp.tool() +@tool def list_org_members(org: str, instance: str = "") -> Any: """List members of an organization.""" return GET(instance, f"/orgs/{org}/members") -@mcp.tool() +@tool def list_org_teams(org: str, instance: str = "") -> Any: """List teams in an organization.""" return GET(instance, f"/orgs/{org}/teams") @@ -617,7 +636,7 @@ def list_org_teams(org: str, instance: str = "") -> Any: # COMMITS & HISTORY # ══════════════════════════════════════════════════════════════════════════════ -@mcp.tool() +@tool def list_commits( owner: str, repo: str, sha: str = "", path: str = "", @@ -630,7 +649,7 @@ def list_commits( "limit": limit, "page": page, })) -@mcp.tool() +@tool def get_commit( owner: str, repo: str, sha: str, stat: bool = True, files: bool = True, verification: bool = False, @@ -641,12 +660,12 @@ def get_commit( "stat": stat, "files": files, "verification": verification or None, })) -@mcp.tool() +@tool def compare_branches(owner: str, repo: str, base: str, head: str, instance: str = "") -> Any: """Compare two branches, tags, or commits.""" return GET(instance, f"/repos/{owner}/{repo}/compare/{_b(base)}...{_b(head)}") -@mcp.tool() +@tool def list_repo_contributors(owner: str, repo: str, limit: int = 20, instance: str = "") -> Any: """List contributors to a repository.""" return GET(instance, f"/repos/{owner}/{repo}/contributors", {"limit": limit}) @@ -655,17 +674,163 @@ def list_repo_contributors(owner: str, repo: str, limit: int = 20, instance: str # NOTIFICATIONS # ══════════════════════════════════════════════════════════════════════════════ -@mcp.tool() +@tool def list_notifications(include_read: bool = False, limit: int = 20, instance: str = "") -> Any: """List notifications. include_read=True includes already-read notifications.""" return GET(instance, "/notifications", _strip({"all": include_read, "limit": limit})) -@mcp.tool() +@tool def mark_all_notifications_read(instance: str = "") -> Any: """Mark all notifications as read.""" return PUT(instance, "/notifications", {}) -# ────────────────────────────────────────────────────────────────────────────── +# ══════════════════════════════════════════════════════════════════════════════ +# CLI DISPATCHER (the second face of the same tools) +# ══════════════════════════════════════════════════════════════════════════════ +# +# The same tool functions are exposed two ways from this one file: +# • MCP server — no subcommand → mcp.run() (for MCP-aware clients) +# • CLI — ` --args` → one JSON line stdout (for Cline-style skills) +# +# CLI output contract: exactly ONE JSON object on stdout. Tool-level errors are +# JSON too ({"success": false, "error": ...}); the process prints and exits 0 +# rather than crashing — the caller checks the `success` field, not the exit code. +# Hard startup failures (missing/invalid config) still exit non-zero, before this. + + +class _CliError(Exception): + """Raised instead of argparse's SystemExit so we can emit a JSON envelope.""" + + +class _ArgParser(argparse.ArgumentParser): + def error(self, message): # noqa: D102 — argparse override + raise _CliError(message) + + +def _arg_spec(annotation): + """Map a parameter annotation to (kind, element_type) for argparse. + + kind ∈ {"str", "int", "bool", "list"}. Unions like `bool | None` and + `list[str] | None` are unwrapped to their non-None inner type. + """ + origin = typing.get_origin(annotation) + if origin in (list, typing.List): + elem = (typing.get_args(annotation) or (str,))[0] + return "list", (int if elem is int else str) + if isinstance(annotation, type) and issubclass(annotation, bool): + return "bool", bool + if annotation is int: + return "int", int + if origin is typing.Union or isinstance(annotation, types.UnionType): + non_none = [a for a in typing.get_args(annotation) if a is not type(None)] + if non_none: + inner = non_none[0] + if inner is bool: + return "bool", bool + if typing.get_origin(inner) in (list, typing.List): + elem = (typing.get_args(inner) or (str,))[0] + return "list", (int if elem is int else str) + return "str", str + + +def _build_parser(name, fn): + """Build an argparse parser for a tool from its signature.""" + sig = inspect.signature(fn) + parser = _ArgParser( + prog=f"gitea {name}", + description=(fn.__doc__ or "").strip(), + add_help=False, + ) + for pname, param in sig.parameters.items(): + kind, etype = _arg_spec(param.annotation) + required = param.default is inspect.Parameter.empty + opt = "--" + pname.replace("_", "-") + if kind == "bool": + parser.add_argument( + opt, dest=pname, action=argparse.BooleanOptionalAction, + default=None, required=required, + ) + elif kind == "list": + parser.add_argument(opt, dest=pname, nargs="*", type=etype, default=None, required=required) + elif kind == "int": + parser.add_argument(opt, dest=pname, type=int, default=None, required=required) + else: + parser.add_argument(opt, dest=pname, type=str, default=None, required=required) + return parser, sig + + +def _tool_list_payload(): + """Self-describing catalogue of every tool — for skill/agent discovery.""" + tools = [] + for name, fn in sorted(TOOLS.items()): + params = [] + for pname, param in inspect.signature(fn).parameters.items(): + kind, _ = _arg_spec(param.annotation) + params.append({ + "name": pname, + "type": kind, + "required": param.default is inspect.Parameter.empty, + }) + tools.append({ + "name": name, + "description": (fn.__doc__ or "").strip(), + "params": params, + }) + return {"success": True, "data": {"count": len(tools), "tools": tools}} + + +def dispatch(argv): + """Run one CLI subcommand. + + Returns an envelope dict to print, or None to signal "start the MCP server". + """ + if not argv or argv[0] in ("serve", "--serve"): + return None + cmd, rest = argv[0], argv[1:] + if cmd in ("list-tools", "--list-tools", "tools"): + return _tool_list_payload() + if cmd not in TOOLS: + return { + "success": False, + "error": f"Unknown command '{cmd}'. Run 'list-tools' to see all {len(TOOLS)} tools.", + "type": "UnknownCommand", + } + fn = TOOLS[cmd] + try: + parser, sig = _build_parser(cmd, fn) + ns = parser.parse_args(rest) + kwargs = {p: getattr(ns, p) for p in sig.parameters if getattr(ns, p) is not None} + return {"success": True, "data": fn(**kwargs)} + except _CliError as e: + return {"success": False, "error": str(e), "type": "UsageError"} + except Exception as e: # tool-level error → JSON, not a crash + return {"success": False, "error": str(e), "type": type(e).__name__} + + +def _drop_config(argv): + """Strip --config X / --config=X (already consumed by find_config) from argv.""" + out, skip = [], False + for a in argv: + if skip: + skip = False + continue + if a == "--config": + skip = True + continue + if a.startswith("--config="): + continue + out.append(a) + return out + + +def main(): + envelope = dispatch(_drop_config(sys.argv[1:])) + if envelope is None: + mcp.run() + return + print(json.dumps(envelope, ensure_ascii=False, default=str)) + sys.exit(0) + if __name__ == "__main__": - mcp.run() + main() diff --git a/skills/gitea/SKILL.md b/skills/gitea/SKILL.md index a6988ea..3864d8e 100644 --- a/skills/gitea/SKILL.md +++ b/skills/gitea/SKILL.md @@ -9,96 +9,176 @@ description: > "open PR", "merge pull request", "create release", "add webhook", "list branches", "gitea notifications", "check gitea connection". metadata: - version: "0.2.0" + version: "0.3.0" --- # Gitea Platform Assistant -Full Gitea REST API integration. All tools: `mcp__gitea__`. +Full Gitea REST API — 66 tools for repos, issues, PRs, branches, files, releases, +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 --param value` → + prints **one JSON object** on stdout. This is the default for Cline. +- **MCP server:** the same tools exposed as `mcp__gitea__` when the file is + registered via `.mcp.json`. Use these instead of `execute_command` if the tools + are already available to you. + +Tool names and parameters are **identical** in both modes — only the call syntax +differs (CLI uses `--dashed-flags`, MCP uses named arguments). + +## Calling via CLI + +``` +ROOT = # the gitea-platform-mcp directory +SCRIPT = $ROOT/server/gitea_mcp.py +``` + +Run through `uv` (deps come from the project; no separate install): + +``` +uv run --project "$ROOT" "$SCRIPT" --config "$ROOT/config.json" [--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`). + +**Output contract:** exactly one JSON object on stdout. Always check `success`: + +```json +{"success": true, "data": } +{"success": false, "error": "Gitea API 404: ...", "type": "RuntimeError"} +``` + +The process exits 0 even on tool-level errors — read `success`, not the exit code. + +## Discovery + +``` +uv run --project "$ROOT" "$SCRIPT" list-tools +``` + +Returns every tool with its description and parameter spec (name / type / +required). Use it when unsure of a flag instead of guessing. ## First Use -Always start by checking the connection and listing available instances: +Always start by checking the connection: + ``` -mcp__gitea__list_instances() → shows configured instances + default -mcp__gitea__get_current_user() → verifies auth works +uv run … "$SCRIPT" list_instances → configured instances + default +uv run … "$SCRIPT" get_current_user → verifies auth works ``` -If these fail, tell the user to fill in `config.json` in the plugin folder. +If these fail with a config error, tell the user to fill in `config.json` in the +plugin folder (copy `config.example.json`). ## Instance Selection -Every tool has an optional `instance` parameter (defaults to `""` = use default from config). +Every tool takes an optional `--instance` (default `""` = use config default). -When user says "на work" / "на home" / "on the work server" → pass `instance="work"` (or relevant name). -When no instance is specified → pass `instance=""` (uses config default). +- User says "на work" / "on the work server" → add `--instance work`. +- No instance mentioned → omit `--instance` (uses config default). ## Tool Categories ### Meta & Health -- `list_instances()` — show all configured instances -- `get_server_info(instance)` — Gitea version -- `get_current_user(instance)` — authenticated user +- `list_instances` — show all configured instances +- `get_server_info --instance ` — Gitea version +- `get_current_user --instance ` — authenticated user ### Repositories -- `list_my_repos(limit, page, instance)` -- `search_repos(query, instance)` -- `get_repo(owner, repo, instance)` -- `create_repo(name, description, private, auto_init, default_branch, instance)` -- `create_org_repo(org, name, ..., instance)` -- `update_repo(owner, repo, ..., instance)` -- `delete_repo(owner, repo, instance)` ⚠️ permanent -- `fork_repo(owner, repo, organization, new_repo_name, instance)` -- `set_repo_topics(owner, repo, topics, instance)` +- `list_my_repos --limit 50 --page 1` +- `search_repos --query ` +- `get_repo --owner --repo ` +- `create_repo --name --description --private/--no-private --auto-init/--no-auto-init --default-branch main` +- `create_org_repo --org --name …` +- `update_repo --owner --repo --description … --no-private --website …` +- `delete_repo --owner --repo ` ⚠️ permanent +- `fork_repo --owner --repo --organization … --new-repo-name …` +- `set_repo_topics --owner --repo --topics ci backend` ### Branches & Tags -- `list_branches(owner, repo, instance)` -- `get_branch(owner, repo, branch, instance)` -- `create_branch(owner, repo, new_branch_name, old_branch_name, instance)` -- `delete_branch(owner, repo, branch, instance)` ⚠️ -- `list_tags(owner, repo, instance)` +- `list_branches --owner --repo ` +- `get_branch --owner --repo --branch ` +- `create_branch --owner --repo --new-branch-name --old-branch-name ` +- `delete_branch --owner --repo --branch ` ⚠️ +- `list_tags --owner --repo ` ### Files -- `list_directory(owner, repo, path, ref, instance)` — browse repo tree -- `get_file(owner, repo, filepath, ref, instance)` — returns content + sha -- `create_file(owner, repo, filepath, content, message, branch, instance)` — plain text input, auto-encoded -- `update_file(owner, repo, filepath, content, message, sha, branch, instance)` — requires sha from get_file -- `delete_file(owner, repo, filepath, message, sha, branch, instance)` — requires sha +- `list_directory --owner --repo --path

--ref ` — browse tree +- `get_file --owner --repo --filepath

--ref ` — content + sha +- `create_file --owner --repo --filepath

--content "" --message --branch ` — plain text, auto-encoded +- `update_file … --content "" --message --sha --branch ` — sha from get_file +- `delete_file … --message --sha --branch ` — sha required ### Issues -- `list_issues(owner, repo, state, type, labels, assignee, limit, instance)` -- `get_issue(owner, repo, index, instance)` -- `create_issue(owner, repo, title, body, assignees, labels, instance)` -- `update_issue(owner, repo, index, title, body, state, instance)` -- `close_issue / reopen_issue(owner, repo, index, instance)` -- `list_issue_comments / add_issue_comment / edit_issue_comment / delete_issue_comment` +- `list_issues --owner --repo --state open|closed|all --issue-type issues|pulls --labels … --assignee … --limit 20` +- `get_issue --owner --repo --index ` +- `create_issue --owner --repo --title --body --assignees alice bob --labels 1 2` +- `update_issue --owner --repo --index --title … --body … --state open|closed` +- `close_issue` / `reopen_issue --owner --repo --index ` +- `list_issue_comments` / `add_issue_comment --body ` / `edit_issue_comment --comment-id --body ` / `delete_issue_comment --comment-id ` ### Labels & Milestones -- `list_labels / create_label(name, color #rrggbb) / add_issue_labels` -- `list_milestones / create_milestone(title, due_on)` +- `list_labels` / `create_label --name --color "#rrggbb"` / `add_issue_labels --index --label-ids 1 2` +- `list_milestones --state open|closed|all` / `create_milestone --title --due-on 2024-12-31T00:00:00Z` ### Pull Requests -- `list_pull_requests(owner, repo, state, instance)` -- `get_pull_request(owner, repo, index, instance)` -- `create_pull_request(owner, repo, title, head, base, body, instance)` -- `merge_pull_request(owner, repo, index, merge_style, instance)` — merge_style: merge|rebase|squash -- `get_pr_diff(owner, repo, index, instance)` — returns diff text -- `list_pr_reviews / create_pr_review(state: APPROVED|COMMENT|REQUEST_CHANGES)` +- `list_pull_requests --owner --repo --state open|closed|all` +- `get_pull_request --owner --repo --index ` +- `create_pull_request --owner --repo --title --head --base --body ` +- `update_pull_request --owner --repo --index --title … --body … --state …` +- `merge_pull_request --owner --repo --index --merge-style merge|rebase|squash|fast-forward-only --delete-branch-after-merge` +- `get_pr_diff --owner --repo --index ` — returns diff text +- `list_pr_files --owner --repo --index ` +- `list_pr_reviews` / `create_pr_review --index --state APPROVED|COMMENT|REQUEST_CHANGES --body ` ### Releases -- `list_releases / get_latest_release / create_release / delete_release` +- `list_releases` / `get_latest_release` / `create_release --tag-name --name --body … --draft --prerelease` / `delete_release --release-id ` ### Webhooks -- `list_repo_webhooks / create_repo_webhook(url, events) / delete_repo_webhook` +- `list_repo_webhooks` / `create_repo_webhook --url --events push issues pull_request --secret …` / `delete_repo_webhook --hook-id ` ### Users & Organizations -- `get_current_user / get_user(username) / search_users(query)` -- `list_my_orgs / get_org / list_org_repos / list_org_members / list_org_teams` +- `get_current_user` / `get_user --username ` / `search_users --query ` +- `list_my_orgs` / `get_org --org ` / `list_org_repos --org ` / `list_org_members --org ` / `list_org_teams --org ` ### Commits & Activity -- `list_commits(owner, repo, sha, path, limit)` -- `compare_branches(owner, repo, base, head)` — diff + commit list -- `list_repo_contributors` +- `list_commits --owner --repo --sha --path

--limit 20` +- `get_commit --owner --repo --sha ` — `--no-stat` / `--no-files` to slim output +- `compare_branches --owner --repo --base --head ` — diff + commit list +- `list_repo_contributors --owner --repo ` ### Notifications -- `list_notifications(all) / mark_all_notifications_read` +- `list_notifications --include-read --limit 20` / `mark_all_notifications_read` + +## Typical Examples + +``` +# Open an issue +uv run --project "$ROOT" "$SCRIPT" 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 \ + --content "new text" --message "docs: tweak" --sha + +# Create and merge a PR on the work instance +uv run … "$SCRIPT" 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 \ + --merge-style squash --delete-branch-after-merge --instance work +``` + +## Notes / Gotchas + +- **File edits are two-step:** `get_file` → use its `sha` in `update_file` / + `delete_file`. Without the current sha Gitea rejects the write. +- **Destructive ops** (`delete_repo`, `delete_branch`, `delete_release`) are + permanent — confirm with the user before running. +- **`list_issues --issue-type pulls`** returns PRs (Gitea treats PRs as issues). +- **Multi-value flags** (`--topics`, `--events`, `--assignees`, `--label-ids`) + take space-separated values, not a comma string. diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..8fd4bb7 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,126 @@ +"""Contract tests for the CLI dispatcher (the second face of the same tools).""" + +import json + +import httpx +import respx + +from server.gitea_mcp import TOOLS, dispatch, main + +BASE = "https://gitea.test/api/v1" + + +# ── Registry parity ───────────────────────────────────────────────────────────── + +def test_tools_registry_matches_count(): + # Same number the smoke test asserts for MCP registration. + assert len(TOOLS) == 66 + + +def test_every_tool_is_callable(): + assert all(callable(fn) for fn in TOOLS.values()) + + +# ── serve vs CLI routing ──────────────────────────────────────────────────────── + +def test_no_args_means_serve(): + assert dispatch([]) is None + + +def test_serve_subcommand_means_serve(): + assert dispatch(["serve"]) is None + + +# ── Discovery ─────────────────────────────────────────────────────────────────── + +def test_list_tools_payload(): + env = dispatch(["list-tools"]) + assert env["success"] is True + assert env["data"]["count"] == 66 + names = {t["name"] for t in env["data"]["tools"]} + assert "get_repo" in names + # parameter spec carries name/type/required + get_repo = next(t for t in env["data"]["tools"] if t["name"] == "get_repo") + owner = next(p for p in get_repo["params"] if p["name"] == "owner") + assert owner["required"] is True + assert owner["type"] == "str" + + +# ── Happy path: flags map to a correct REST call ──────────────────────────────── + +@respx.mock +def test_dispatch_happy_path(): + route = respx.get(f"{BASE}/repos/andrey/infra").mock( + return_value=httpx.Response(200, json={"name": "infra", "id": 7}) + ) + env = dispatch(["get_repo", "--owner", "andrey", "--repo", "infra"]) + assert route.called + assert env["success"] is True + assert env["data"]["name"] == "infra" + + +@respx.mock +def test_int_flag_is_typed(): + route = respx.get(f"{BASE}/user/repos").mock(return_value=httpx.Response(200, json=[])) + dispatch(["list_my_repos", "--limit", "5", "--page", "2"]) + params = dict(route.calls[0].request.url.params) + assert params["limit"] == "5" + assert params["page"] == "2" + + +@respx.mock +def test_bool_no_flag_sends_false(): + route = respx.post(f"{BASE}/user/repos").mock( + return_value=httpx.Response(201, json={"name": "x"}) + ) + dispatch(["create_repo", "--name", "x", "--no-private", "--no-auto-init"]) + body = json.loads(route.calls[0].request.content) + assert body["private"] is False + assert body["auto_init"] is False + + +@respx.mock +def test_list_flag_takes_multiple_values(): + route = respx.put(f"{BASE}/repos/o/r/topics").mock(return_value=httpx.Response(204)) + dispatch(["set_repo_topics", "--owner", "o", "--repo", "r", "--topics", "ci", "backend"]) + body = json.loads(route.calls[0].request.content) + assert body["topics"] == ["ci", "backend"] + + +# ── Error handling ────────────────────────────────────────────────────────────── + +def test_unknown_command(): + env = dispatch(["no_such_tool"]) + assert env["success"] is False + assert env["type"] == "UnknownCommand" + + +def test_missing_required_flag_is_usage_error(): + env = dispatch(["get_repo", "--owner", "andrey"]) # no --repo + assert env["success"] is False + assert env["type"] == "UsageError" + + +@respx.mock +def test_api_error_is_propagated_as_envelope(): + respx.get(f"{BASE}/repos/o/missing").mock( + return_value=httpx.Response(404, json={"message": "not found"}) + ) + env = dispatch(["get_repo", "--owner", "o", "--repo", "missing"]) + assert env["success"] is False + assert "404" in env["error"] + + +# ── main() prints exactly one JSON object and exits 0 ─────────────────────────── + +@respx.mock +def test_main_prints_single_json_and_exits_zero(capsys, monkeypatch): + respx.get(f"{BASE}/repos/o/r").mock(return_value=httpx.Response(200, json={"name": "r"})) + monkeypatch.setattr("sys.argv", ["gitea_mcp.py", "get_repo", "--owner", "o", "--repo", "r"]) + try: + main() + except SystemExit as e: + assert e.code == 0 + out = capsys.readouterr().out.strip() + parsed = json.loads(out) # exactly one JSON object + assert parsed["success"] is True diff --git a/uv.lock b/uv.lock index 97728f4..b581aa1 100644 --- a/uv.lock +++ b/uv.lock @@ -351,7 +351,7 @@ wheels = [ [[package]] name = "gitea-platform-mcp" -version = "0.2.0" +version = "0.3.0" source = { virtual = "." } dependencies = [ { name = "httpx" },