feat: dual-mode — MCP server and CLI skill in one file

Expose the same 66 Gitea tools two ways from server/gitea_mcp.py:
- MCP server (no subcommand -> mcp.run()), as before
- CLI: `<tool> --flags` prints one JSON object {success, data/error}

A new @tool decorator registers each function in both FastMCP and a TOOLS
registry; a CLI dispatcher builds argparse from each function's signature and
wraps results in the repo's JSON+success contract. Adds `list-tools` for
discovery. No tool logic duplicated.

- Rewrite skills/gitea/SKILL.md for CLI-first usage (MCP kept as an option)
- Sync version to 0.3.0 across plugin.json, pyproject.toml, SKILL.md
- Add tests/unit/test_cli.py (dispatch routing, flag typing, errors, main)
- Document dual-mode in README.md and CLAUDE.md
- Ignore coverage artifacts

Tests: 119 passed, 93% coverage.
This commit is contained in:
2026-06-24 10:44:46 +03:00
parent a6162a855c
commit 9a5750ddca
9 changed files with 543 additions and 130 deletions
+1 -1
View File
@@ -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"
+6
View File
@@ -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
+21 -5
View File
@@ -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__<tool>`.
- **CLI skill:** `uv run server/gitea_mcp.py <tool> --flags` → one JSON object on
stdout (`{"success", "data"|"error"}`). Powers the `skills/gitea` Cline skill.
The split lives at the bottom of the file: every tool is registered in the `TOOLS`
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.
---
+20
View File
@@ -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__<tool>`.
This is how you connect it to an MCP-aware client.
- **CLI skill** — `uv run server/gitea_mcp.py <tool> --flags` prints one JSON
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
+2 -2
View File
@@ -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",
+233 -68
View File
@@ -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 — `<tool> --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()
+133 -53
View File
@@ -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__<tool_name>`.
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 <tool> --param value`
prints **one JSON object** on stdout. This is the default for Cline.
- **MCP server:** the same tools exposed as `mcp__gitea__<tool>` when the file is
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 = <plugin folder> # 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" <tool> [--flags]
```
Convert snake_case params to `--dashed` flags. Booleans use `--flag` / `--no-flag`
(e.g. `--private` / `--no-private`). List params take space-separated values
(e.g. `--topics ci backend`).
**Output contract:** exactly one JSON object on stdout. Always check `success`:
```json
{"success": true, "data": <Gitea response>}
{"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 <name>` — Gitea version
- `get_current_user --instance <name>` — 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 <q>`
- `get_repo --owner <o> --repo <r>`
- `create_repo --name <n> --description <d> --private/--no-private --auto-init/--no-auto-init --default-branch main`
- `create_org_repo --org <org> --name <n> …`
- `update_repo --owner <o> --repo <r> --description … --no-private --website …`
- `delete_repo --owner <o> --repo <r>` ⚠️ permanent
- `fork_repo --owner <o> --repo <r> --organization … --new-repo-name`
- `set_repo_topics --owner <o> --repo <r> --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 <o> --repo <r>`
- `get_branch --owner <o> --repo <r> --branch <b>`
- `create_branch --owner <o> --repo <r> --new-branch-name <b> --old-branch-name <src>`
- `delete_branch --owner <o> --repo <r> --branch <b>` ⚠️
- `list_tags --owner <o> --repo <r>`
### 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 <o> --repo <r> --path <p> --ref <branch>` — browse tree
- `get_file --owner <o> --repo <r> --filepath <p> --ref <branch>` — content + sha
- `create_file --owner <o> --repo <r> --filepath <p> --content "<text>" --message <msg> --branch <b>` — plain text, auto-encoded
- `update_file … --content "<text>" --message <msg> --sha <sha> --branch <b>` sha from get_file
- `delete_file … --message <msg> --sha <sha> --branch <b>` 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 <o> --repo <r> --state open|closed|all --issue-type issues|pulls --labels … --assignee … --limit 20`
- `get_issue --owner <o> --repo <r> --index <n>`
- `create_issue --owner <o> --repo <r> --title <t> --body <b> --assignees alice bob --labels 1 2`
- `update_issue --owner <o> --repo <r> --index <n> --title … --body … --state open|closed`
- `close_issue` / `reopen_issue --owner <o> --repo <r> --index <n>`
- `list_issue_comments` / `add_issue_comment --body <b>` / `edit_issue_comment --comment-id <id> --body <b>` / `delete_issue_comment --comment-id <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 <n> --color "#rrggbb"` / `add_issue_labels --index <n> --label-ids 1 2`
- `list_milestones --state open|closed|all` / `create_milestone --title <t> --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 <o> --repo <r> --state open|closed|all`
- `get_pull_request --owner <o> --repo <r> --index <n>`
- `create_pull_request --owner <o> --repo <r> --title <t> --head <src-branch> --base <target-branch> --body <b>`
- `update_pull_request --owner <o> --repo <r> --index <n> --title … --body … --state …`
- `merge_pull_request --owner <o> --repo <r> --index <n> --merge-style merge|rebase|squash|fast-forward-only --delete-branch-after-merge`
- `get_pr_diff --owner <o> --repo <r> --index <n>` — returns diff text
- `list_pr_files --owner <o> --repo <r> --index <n>`
- `list_pr_reviews` / `create_pr_review --index <n> --state APPROVED|COMMENT|REQUEST_CHANGES --body <b>`
### Releases
- `list_releases / get_latest_release / create_release / delete_release`
- `list_releases` / `get_latest_release` / `create_release --tag-name <t> --name <n> --body … --draft --prerelease` / `delete_release --release-id <id>`
### Webhooks
- `list_repo_webhooks / create_repo_webhook(url, events) / delete_repo_webhook`
- `list_repo_webhooks` / `create_repo_webhook --url <u> --events push issues pull_request --secret …` / `delete_repo_webhook --hook-id <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 <u>` / `search_users --query <q>`
- `list_my_orgs` / `get_org --org <org>` / `list_org_repos --org <org>` / `list_org_members --org <org>` / `list_org_teams --org <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 <o> --repo <r> --sha <ref> --path <p> --limit 20`
- `get_commit --owner <o> --repo <r> --sha <sha>``--no-stat` / `--no-files` to slim output
- `compare_branches --owner <o> --repo <r> --base <b> --head <h>` — diff + commit list
- `list_repo_contributors --owner <o> --repo <r>`
### 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 <sha-from-get_file>
# 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.
+126
View File
@@ -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
Generated
+1 -1
View File
@@ -351,7 +351,7 @@ wheels = [
[[package]]
name = "gitea-platform-mcp"
version = "0.2.0"
version = "0.3.0"
source = { virtual = "." }
dependencies = [
{ name = "httpx" },