From 781439bbdb0fe19dc74fa98c7bc97d696dd56a23 Mon Sep 17 00:00:00 2001 From: Andrey Limasov Date: Tue, 2 Jun 2026 08:31:39 +0300 Subject: [PATCH] Initial commit: Gitea MCP server with 62 tools - Full Gitea REST API coverage for repos, issues, PRs, branches, files, releases, webhooks, orgs, notifications - Multi-instance support via config.json - pyproject.toml with pinned dependencies (mcp[cli], httpx) - config.example.json as credentials template (config.json is gitignored) --- .claude-plugin/plugin.json | 9 + .gitignore | 21 ++ .mcp.json | 13 + README.md | 73 +++++ config.example.json | 9 + pyproject.toml | 13 + server/gitea_mcp.py | 647 +++++++++++++++++++++++++++++++++++++ skills/gitea/SKILL.md | 104 ++++++ 8 files changed, 889 insertions(+) create mode 100644 .claude-plugin/plugin.json create mode 100644 .gitignore create mode 100644 .mcp.json create mode 100644 README.md create mode 100644 config.example.json create mode 100644 pyproject.toml create mode 100644 server/gitea_mcp.py create mode 100644 skills/gitea/SKILL.md diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..aa4ac84 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "gitea-platform-mcp", + "version": "0.1.0", + "description": "Full Gitea platform management — repositories, issues, PRs, branches, releases, webhooks, organizations", + "author": { + "name": "Andrey Limasov" + }, + "keywords": ["gitea", "git", "self-hosted", "issues", "pull-requests", "devops"] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..49ff1b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Credentials — never commit real tokens +config.json + +# Python +.venv/ +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +dist/ +.env + +# Gitea API spec — large auto-generated file, download from your Gitea instance +gitea-api.yaml + +# Claude Code local settings +.claude/settings.local.json + +# IDE +.idea/ +.vscode/settings.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..cb6fdd2 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "gitea": { + "command": "uv", + "args": [ + "run", + "--project", "${CLAUDE_PLUGIN_ROOT}", + "${CLAUDE_PLUGIN_ROOT}/server/gitea_mcp.py", + "--config", "${CLAUDE_PLUGIN_ROOT}/config.json" + ] + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..2dc7f23 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# gitea-platform-mcp + +Full Gitea platform management — 50 tools for repositories, issues, PRs, branches, files, releases, webhooks, and organizations. + +## Setup (one-time) + +### 1. Edit config.json + +Open `config.json` in the plugin folder and fill in your Gitea details: + +```json +{ + "instances": { + "default": { + "url": "https://your-gitea.example.com", + "token": "your_personal_access_token" + } + }, + "default": "default" +} +``` + +### Multiple Gitea instances + +```json +{ + "instances": { + "home": { + "url": "https://gitea.home.example.com", + "token": "token_for_home" + }, + "work": { + "url": "https://gitea.work.example.com", + "token": "token_for_work" + } + }, + "default": "home" +} +``` + +Then in chat: "покажи репозитории на work" — Claude передаст `instance="work"` в инструмент. + +### 2. Create a Gitea token + +Gitea → Settings → Applications → Personal Access Tokens + +Recommended permissions: `repo`, `issue`, `notification`, `user`, `organization` + +### 3. Verify connection + +After installing, ask: "проверь подключение к Gitea" — Claude вызовет `get_current_user` и `get_server_info`. + +## Requirements + +- `uv` installed (https://docs.astral.sh/uv/) +- Gitea instance accessible from your computer + +## Tools (50) + +| Category | Tools | +|----------|-------| +| Meta | list_instances, get_server_info, get_current_user | +| Repositories | list_my_repos, search_repos, get_repo, create_repo, create_org_repo, update_repo, delete_repo, fork_repo, set_repo_topics | +| Branches & Tags | list_branches, get_branch, create_branch, delete_branch, list_tags | +| Files | list_directory, get_file, create_file, update_file, delete_file | +| Issues | list_issues, get_issue, create_issue, update_issue, close_issue, reopen_issue, list_issue_comments, add_issue_comment, edit_issue_comment, delete_issue_comment | +| Labels & Milestones | list_labels, create_label, add_issue_labels, list_milestones, create_milestone | +| Pull Requests | list_pull_requests, get_pull_request, create_pull_request, update_pull_request, merge_pull_request, get_pr_diff, list_pr_reviews, create_pr_review | +| Releases | list_releases, get_latest_release, create_release, delete_release | +| Webhooks | list_repo_webhooks, create_repo_webhook, delete_repo_webhook | +| Users & Orgs | get_user, search_users, list_my_orgs, get_org, list_org_repos, list_org_members, list_org_teams | +| Commits | list_commits, compare_branches, list_repo_contributors | +| Notifications | list_notifications, mark_all_notifications_read | diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..df83458 --- /dev/null +++ b/config.example.json @@ -0,0 +1,9 @@ +{ + "instances": { + "default": { + "url": "https://your-gitea.example.com", + "token": "your_personal_access_token_here" + } + }, + "default": "default" +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..713e4f0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "gitea-platform-mcp" +version = "0.2.0" +description = "Full Gitea platform management via MCP" +requires-python = ">=3.10" +dependencies = [ + "mcp[cli]>=1.0", + "httpx>=0.27", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/server/gitea_mcp.py b/server/gitea_mcp.py new file mode 100644 index 0000000..789a036 --- /dev/null +++ b/server/gitea_mcp.py @@ -0,0 +1,647 @@ +#!/usr/bin/env python3 +""" +Gitea MCP Server — full Gitea platform management via REST API. +Reads credentials from config.json (passed via --config argument or auto-discovered). +""" + +import base64 +import os +import sys +import json +import argparse +import httpx +from typing import Any +from urllib.parse import quote +from mcp.server.fastmcp import FastMCP + +# ── Config loading ───────────────────────────────────────────────────────────── + +def find_config() -> str: + """Find config.json: --config arg > GITEA_CONFIG env > script dir > cwd.""" + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--config", default="") + args, _ = parser.parse_known_args() + if args.config: + 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) + return "config.json" + +CONFIG_PATH = find_config() + +try: + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + _config = json.load(f) +except FileNotFoundError: + print(f"ERROR: config.json not found at {CONFIG_PATH}", file=sys.stderr) + print("Create config.json next to the plugin. See README.md for format.", file=sys.stderr) + sys.exit(1) +except json.JSONDecodeError as e: + print(f"ERROR: config.json is invalid JSON: {e}", file=sys.stderr) + sys.exit(1) + +INSTANCES: dict = _config.get("instances", {}) +DEFAULT_INSTANCE: str = _config.get("default", "default") + +if not INSTANCES: + print("ERROR: config.json has no 'instances' defined.", file=sys.stderr) + sys.exit(1) + +# ── HTTP helpers ─────────────────────────────────────────────────────────────── + +_http = httpx.Client(timeout=30) + +def _b(s: str) -> str: + """URL-encode a branch/tag/path segment so slashes don't break the URL.""" + return quote(s, safe="") + +def _get_instance(instance: str) -> tuple[str, dict]: + name = instance or DEFAULT_INSTANCE + cfg = INSTANCES.get(name) + if not cfg: + available = ", ".join(INSTANCES.keys()) + raise ValueError(f"Instance '{name}' not found in config.json. Available: {available}") + url = cfg["url"].rstrip("/") + headers = { + "Authorization": f"token {cfg['token']}", + "Content-Type": "application/json", + "Accept": "application/json", + } + return f"{url}/api/v1", headers + +def _req(method: str, api_base: str, headers: dict, path: str, **kwargs) -> Any: + url = f"{api_base}{path}" + r = _http.request(method, url, headers=headers, **kwargs) + if r.status_code == 204: + return {"success": True} + if not r.is_success: + try: + detail = r.json() + except Exception: + detail = r.text + raise RuntimeError(f"Gitea API {r.status_code}: {detail}") + if not r.text: + return {"success": True} + return r.json() + +def _call(method: str, instance: str, path: str, **kwargs) -> Any: + api, headers = _get_instance(instance) + return _req(method, api, headers, path, **kwargs) + +def _strip(d: dict) -> dict: + return {k: v for k, v in d.items() if v is not None} + +def GET(instance, path, params=None): return _call("GET", instance, path, params=params) +def POST(instance, path, body=None): return _call("POST", instance, path, json=body) +def PATCH(instance, path, body=None): return _call("PATCH", instance, path, json=body) +def PUT(instance, path, body=None): return _call("PUT", instance, path, json=body) +def DELETE(instance, path, body=None): + return _call("DELETE", instance, path, **({} if body is None else {"json": body})) + +# ── MCP server ───────────────────────────────────────────────────────────────── + +mcp = FastMCP("gitea") + +# ══════════════════════════════════════════════════════════════════════════════ +# META +# ══════════════════════════════════════════════════════════════════════════════ + +@mcp.tool() +def list_instances() -> Any: + """List all configured Gitea instances and show which one is default.""" + return { + "instances": list(INSTANCES.keys()), + "default": DEFAULT_INSTANCE, + } + +@mcp.tool() +def get_server_info(instance: str = "") -> Any: + """Get Gitea server version and info.""" + return GET(instance, "/version") + +@mcp.tool() +def get_current_user(instance: str = "") -> Any: + """Get information about the authenticated user.""" + return GET(instance, "/user") + +# ══════════════════════════════════════════════════════════════════════════════ +# REPOSITORIES +# ══════════════════════════════════════════════════════════════════════════════ + +@mcp.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() +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() +def get_repo(owner: str, repo: str, instance: str = "") -> Any: + """Get details of a repository.""" + return GET(instance, f"/repos/{owner}/{repo}") + +@mcp.tool() +def create_repo( + name: str, + description: str = "", + private: bool = True, + auto_init: bool = True, + default_branch: str = "main", + gitignores: str = "", + license: str = "", + instance: str = "", +) -> Any: + """Create a new repository for the authenticated user.""" + return POST(instance, "/user/repos", _strip({ + "name": name, + "description": description or None, + "private": private, + "auto_init": auto_init, + "default_branch": default_branch, + "gitignores": gitignores or None, + "license": license or None, + })) + +@mcp.tool() +def create_org_repo( + org: str, + name: str, + description: str = "", + private: bool = True, + auto_init: bool = True, + default_branch: str = "main", + instance: str = "", +) -> Any: + """Create a new repository in an organization.""" + return POST(instance, f"/orgs/{org}/repos", _strip({ + "name": name, + "description": description or None, + "private": private, + "auto_init": auto_init, + "default_branch": default_branch, + })) + +@mcp.tool() +def update_repo( + owner: str, repo: str, + description: str = "", private: bool | None = None, website: str = "", + instance: str = "", +) -> Any: + """Update repository settings.""" + return PATCH(instance, f"/repos/{owner}/{repo}", _strip({ + "description": description or None, + "private": private, + "website": website or None, + })) + +@mcp.tool() +def delete_repo(owner: str, repo: str, instance: str = "") -> Any: + """Delete a repository permanently.""" + return DELETE(instance, f"/repos/{owner}/{repo}") + +@mcp.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({ + "organization": organization or None, + "name": new_repo_name or None, + })) + +@mcp.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}) + +# ══════════════════════════════════════════════════════════════════════════════ +# BRANCHES & TAGS +# ══════════════════════════════════════════════════════════════════════════════ + +@mcp.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() +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() +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({ + "new_branch_name": new_branch_name, + "old_branch_name": old_branch_name or None, + })) + +@mcp.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() +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}) + +# ══════════════════════════════════════════════════════════════════════════════ +# FILES & CONTENTS +# ══════════════════════════════════════════════════════════════════════════════ + +@mcp.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() +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() +def create_file( + owner: str, repo: str, filepath: str, + content: str, message: str, + branch: str = "", instance: str = "", +) -> Any: + """Create a new text file. content is plain text — encoding handled automatically.""" + encoded = base64.b64encode(content.encode()).decode() + return POST(instance, f"/repos/{owner}/{repo}/contents/{filepath}", _strip({ + "content": encoded, + "message": message, + "branch": branch or None, + })) + +@mcp.tool() +def update_file( + owner: str, repo: str, filepath: str, + content: str, message: str, sha: str, + branch: str = "", instance: str = "", +) -> Any: + """Update an existing file. sha is the current blob SHA (from get_file). content is plain text.""" + encoded = base64.b64encode(content.encode()).decode() + return PUT(instance, f"/repos/{owner}/{repo}/contents/{filepath}", _strip({ + "content": encoded, + "message": message, + "sha": sha, + "branch": branch or None, + })) + +@mcp.tool() +def delete_file( + owner: str, repo: str, filepath: str, + message: str, sha: str, + branch: str = "", instance: str = "", +) -> Any: + """Delete a file. sha is the current blob SHA (from get_file).""" + return DELETE(instance, f"/repos/{owner}/{repo}/contents/{filepath}", _strip({ + "message": message, "sha": sha, "branch": branch or None, + })) + +# ══════════════════════════════════════════════════════════════════════════════ +# ISSUES +# ══════════════════════════════════════════════════════════════════════════════ + +@mcp.tool() +def list_issues( + owner: str, repo: str, + state: str = "open", issue_type: str = "issues", + labels: str = "", assignee: str = "", + limit: int = 20, page: int = 1, + instance: str = "", +) -> Any: + """List issues or PRs. state: open|closed|all. issue_type: issues|pulls.""" + return GET(instance, f"/repos/{owner}/{repo}/issues", _strip({ + "state": state, "type": issue_type, + "labels": labels or None, "assignee": assignee or None, + "limit": limit, "page": page, + })) + +@mcp.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() +def create_issue( + owner: str, repo: str, title: str, body: str = "", + assignees: list[str] | None = None, labels: list[int] | None = None, milestone: int = 0, + instance: str = "", +) -> Any: + """Create a new issue.""" + return POST(instance, f"/repos/{owner}/{repo}/issues", _strip({ + "title": title, + "body": body or None, + "assignees": assignees or None, + "labels": labels or None, + "milestone": milestone or None, + })) + +@mcp.tool() +def update_issue( + owner: str, repo: str, index: int, + title: str = "", body: str = "", state: str = "", + instance: str = "", +) -> Any: + """Update an issue. state: open|closed.""" + return PATCH(instance, f"/repos/{owner}/{repo}/issues/{index}", _strip({ + "title": title or None, "body": body or None, "state": state or None, + })) + +@mcp.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() +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() +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() +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() +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() +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}") + +# ══════════════════════════════════════════════════════════════════════════════ +# LABELS & MILESTONES +# ══════════════════════════════════════════════════════════════════════════════ + +@mcp.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() +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() +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() +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() +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({ + "title": title, "description": description or None, "due_on": due_on or None, + })) + +# ══════════════════════════════════════════════════════════════════════════════ +# PULL REQUESTS +# ══════════════════════════════════════════════════════════════════════════════ + +@mcp.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() +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() +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, + instance: str = "", +) -> Any: + """Create a pull request. head = source branch, base = target branch.""" + return POST(instance, f"/repos/{owner}/{repo}/pulls", _strip({ + "title": title, "head": head, "base": base, + "body": body or None, + "assignees": assignees or None, + "labels": labels or None, + })) + +@mcp.tool() +def update_pull_request( + owner: str, repo: str, index: int, + title: str = "", body: str = "", state: str = "", + instance: str = "", +) -> Any: + """Update a pull request.""" + return PATCH(instance, f"/repos/{owner}/{repo}/pulls/{index}", _strip({ + "title": title or None, "body": body or None, "state": state or None, + })) + +@mcp.tool() +def merge_pull_request( + owner: str, repo: str, index: int, + merge_style: str = "merge", + commit_title: str = "", commit_message: str = "", + delete_branch_after_merge: bool = False, + instance: str = "", +) -> Any: + """Merge a pull request. merge_style: merge|rebase|squash|fast-forward-only.""" + return POST(instance, f"/repos/{owner}/{repo}/pulls/{index}/merge", _strip({ + "Do": merge_style, + "MergeTitleField": commit_title or None, + "MergeMessageField": commit_message or None, + "delete_branch_after_merge": delete_branch_after_merge, + })) + +@mcp.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) + url = f"{api}/repos/{owner}/{repo}/pulls/{index}.diff" + r = _http.get(url, headers=headers) + if not r.is_success: + raise RuntimeError(f"Gitea API {r.status_code}: {r.text}") + return {"diff": r.text} + +@mcp.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() +def create_pr_review( + owner: str, repo: str, index: int, + state: str, body: str = "", + instance: str = "", +) -> Any: + """Submit a PR review. state: APPROVED|COMMENT|REQUEST_CHANGES.""" + return POST(instance, f"/repos/{owner}/{repo}/pulls/{index}/reviews", _strip({ + "event": state, "body": body or None, + })) + +# ══════════════════════════════════════════════════════════════════════════════ +# RELEASES +# ══════════════════════════════════════════════════════════════════════════════ + +@mcp.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() +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() +def create_release( + owner: str, repo: str, tag_name: str, name: str, + body: str = "", draft: bool = False, prerelease: bool = False, + target_commitish: str = "", + instance: str = "", +) -> Any: + """Create a release.""" + return POST(instance, f"/repos/{owner}/{repo}/releases", _strip({ + "tag_name": tag_name, "name": name, + "body": body or None, "draft": draft, "prerelease": prerelease, + "target_commitish": target_commitish or None, + })) + +@mcp.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}") + +# ══════════════════════════════════════════════════════════════════════════════ +# WEBHOOKS +# ══════════════════════════════════════════════════════════════════════════════ + +@mcp.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() +def create_repo_webhook( + owner: str, repo: str, url: str, events: list[str], + secret: str = "", active: bool = True, + instance: str = "", +) -> Any: + """Create a webhook. events: push, issues, pull_request, release, etc.""" + return POST(instance, f"/repos/{owner}/{repo}/hooks", { + "type": "gitea", + "config": _strip({"url": url, "content_type": "json", "secret": secret or None}), + "events": events, + "active": active, + }) + +@mcp.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}") + +# ══════════════════════════════════════════════════════════════════════════════ +# USERS & ORGANIZATIONS +# ══════════════════════════════════════════════════════════════════════════════ + +@mcp.tool() +def get_user(username: str, instance: str = "") -> Any: + """Get public information about a user.""" + return GET(instance, f"/users/{username}") + +@mcp.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() +def list_my_orgs(instance: str = "") -> Any: + """List organizations the authenticated user belongs to.""" + return GET(instance, "/user/orgs") + +@mcp.tool() +def get_org(org: str, instance: str = "") -> Any: + """Get information about an organization.""" + return GET(instance, f"/orgs/{org}") + +@mcp.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() +def list_org_members(org: str, instance: str = "") -> Any: + """List members of an organization.""" + return GET(instance, f"/orgs/{org}/members") + +@mcp.tool() +def list_org_teams(org: str, instance: str = "") -> Any: + """List teams in an organization.""" + return GET(instance, f"/orgs/{org}/teams") + +# ══════════════════════════════════════════════════════════════════════════════ +# COMMITS & HISTORY +# ══════════════════════════════════════════════════════════════════════════════ + +@mcp.tool() +def list_commits( + owner: str, repo: str, + sha: str = "", path: str = "", + limit: int = 20, page: int = 1, + instance: str = "", +) -> Any: + """List commits. sha can be branch/tag/commit hash.""" + return GET(instance, f"/repos/{owner}/{repo}/commits", _strip({ + "sha": sha or None, "path": path or None, + "limit": limit, "page": page, + })) + +@mcp.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() +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}) + +# ══════════════════════════════════════════════════════════════════════════════ +# NOTIFICATIONS +# ══════════════════════════════════════════════════════════════════════════════ + +@mcp.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() +def mark_all_notifications_read(instance: str = "") -> Any: + """Mark all notifications as read.""" + return PUT(instance, "/notifications", {}) + +# ────────────────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + mcp.run() diff --git a/skills/gitea/SKILL.md b/skills/gitea/SKILL.md new file mode 100644 index 0000000..a6988ea --- /dev/null +++ b/skills/gitea/SKILL.md @@ -0,0 +1,104 @@ +--- +name: gitea +description: > + Use this skill when the user asks to work with their Gitea server: + "покажи мои репозитории", "создай репозиторий", "открой issue", "закрой issue", + "создай pull request", "смерджи PR", "добавь вебхук", "покажи релизы", + "создай ветку", "покажи коммиты", "сравни ветки", "список организаций", + "переключись на 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.2.0" +--- + +# Gitea Platform Assistant + +Full Gitea REST API integration. All tools: `mcp__gitea__`. + +## First Use + +Always start by checking the connection and listing available instances: +``` +mcp__gitea__list_instances() → shows configured instances + default +mcp__gitea__get_current_user() → verifies auth works +``` + +If these fail, tell the user to fill in `config.json` in the plugin folder. + +## Instance Selection + +Every tool has an optional `instance` parameter (defaults to `""` = use default from config). + +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). + +## Tool Categories + +### Meta & Health +- `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)` + +### 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)` + +### 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 + +### 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` + +### Labels & Milestones +- `list_labels / create_label(name, color #rrggbb) / add_issue_labels` +- `list_milestones / create_milestone(title, due_on)` + +### 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)` + +### Releases +- `list_releases / get_latest_release / create_release / delete_release` + +### Webhooks +- `list_repo_webhooks / create_repo_webhook(url, events) / delete_repo_webhook` + +### 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` + +### Commits & Activity +- `list_commits(owner, repo, sha, path, limit)` +- `compare_branches(owner, repo, base, head)` — diff + commit list +- `list_repo_contributors` + +### Notifications +- `list_notifications(all) / mark_all_notifications_read`