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
+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()