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", "name": "gitea-platform-mcp",
"version": "0.1.0", "version": "0.3.0",
"description": "Full Gitea platform management — repositories, issues, PRs, branches, releases, webhooks, organizations", "description": "Full Gitea platform management — repositories, issues, PRs, branches, releases, webhooks, organizations",
"author": { "author": {
"name": "Andrey Limasov" "name": "Andrey Limasov"
+6
View File
@@ -10,6 +10,12 @@ __pycache__/
dist/ dist/
.env .env
# Test / coverage artifacts
.coverage
.coverage.*
.pytest_cache/
htmlcov/
# Gitea API spec — large auto-generated file, download from your Gitea instance # Gitea API spec — large auto-generated file, download from your Gitea instance
gitea-api.yaml gitea-api.yaml
+21 -5
View File
@@ -2,8 +2,19 @@
## Project overview ## Project overview
MCP server exposing Gitea REST API as 66 tools. Single source file: `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). Run via `uv run 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 ## Commit style
@@ -12,20 +23,25 @@ Config in `config.json` (gitignored). Run via `uv run server/gitea_mcp.py`.
## Adding tools ## 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 ```python
@mcp.tool() @tool
def my_tool(owner: str, repo: str, instance: str = "") -> Any: def my_tool(owner: str, repo: str, instance: str = "") -> Any:
"""One-line docstring — becomes the tool description shown to the LLM.""" """One-line docstring — becomes the tool description shown to the LLM."""
return GET(instance, f"/repos/{owner}/{repo}/something") return GET(instance, f"/repos/{owner}/{repo}/something")
``` ```
Rules: Rules:
- Use `@tool`, not `@mcp.tool()` — the former also registers the CLI subcommand.
- `instance: str = ""` is always the last parameter - `instance: str = ""` is always the last parameter
- Use `_strip({...})` to drop `None` values before sending - Use `_strip({...})` to drop `None` values before sending
- URL-encode path segments that may contain slashes via `_b(value)` - 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. 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) ## Setup (one-time)
### 1. Edit config.json ### 1. Edit config.json
+2 -2
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "gitea-platform-mcp" name = "gitea-platform-mcp"
version = "0.2.0" version = "0.3.0"
description = "Full Gitea platform management via MCP" description = "Full Gitea platform management — MCP server and CLI skill in one file"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"mcp[cli]>=1.0", "mcp[cli]>=1.0",
+233 -68
View File
@@ -8,6 +8,9 @@ import base64
import os import os
import sys import sys
import json import json
import types
import inspect
import typing
import argparse import argparse
import httpx import httpx
from typing import Any from typing import Any
@@ -107,11 +110,27 @@ def DELETE(instance, path, body=None):
mcp = FastMCP("gitea") 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 # META
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
@mcp.tool() @tool
def list_instances() -> Any: def list_instances() -> Any:
"""List all configured Gitea instances and show which one is default.""" """List all configured Gitea instances and show which one is default."""
return { return {
@@ -119,12 +138,12 @@ def list_instances() -> Any:
"default": DEFAULT_INSTANCE, "default": DEFAULT_INSTANCE,
} }
@mcp.tool() @tool
def get_server_info(instance: str = "") -> Any: def get_server_info(instance: str = "") -> Any:
"""Get Gitea server version and info.""" """Get Gitea server version and info."""
return GET(instance, "/version") return GET(instance, "/version")
@mcp.tool() @tool
def get_current_user(instance: str = "") -> Any: def get_current_user(instance: str = "") -> Any:
"""Get information about the authenticated user.""" """Get information about the authenticated user."""
return GET(instance, "/user") return GET(instance, "/user")
@@ -133,22 +152,22 @@ def get_current_user(instance: str = "") -> Any:
# REPOSITORIES # REPOSITORIES
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
@mcp.tool() @tool
def list_my_repos(limit: int = 50, page: int = 1, instance: str = "") -> Any: def list_my_repos(limit: int = 50, page: int = 1, instance: str = "") -> Any:
"""List repositories of the authenticated user.""" """List repositories of the authenticated user."""
return GET(instance, "/user/repos", {"limit": limit, "page": page}) 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: def search_repos(query: str, limit: int = 20, page: int = 1, instance: str = "") -> Any:
"""Search repositories across the Gitea instance.""" """Search repositories across the Gitea instance."""
return GET(instance, "/repos/search", {"q": query, "limit": limit, "page": page}) return GET(instance, "/repos/search", {"q": query, "limit": limit, "page": page})
@mcp.tool() @tool
def get_repo(owner: str, repo: str, instance: str = "") -> Any: def get_repo(owner: str, repo: str, instance: str = "") -> Any:
"""Get details of a repository.""" """Get details of a repository."""
return GET(instance, f"/repos/{owner}/{repo}") return GET(instance, f"/repos/{owner}/{repo}")
@mcp.tool() @tool
def create_repo( def create_repo(
name: str, name: str,
description: str = "", description: str = "",
@@ -170,7 +189,7 @@ def create_repo(
"license": license or None, "license": license or None,
})) }))
@mcp.tool() @tool
def create_org_repo( def create_org_repo(
org: str, org: str,
name: str, name: str,
@@ -189,7 +208,7 @@ def create_org_repo(
"default_branch": default_branch, "default_branch": default_branch,
})) }))
@mcp.tool() @tool
def update_repo( def update_repo(
owner: str, repo: str, owner: str, repo: str,
description: str = "", private: bool | None = None, website: str = "", description: str = "", private: bool | None = None, website: str = "",
@@ -202,12 +221,12 @@ def update_repo(
"website": website or None, "website": website or None,
})) }))
@mcp.tool() @tool
def delete_repo(owner: str, repo: str, instance: str = "") -> Any: def delete_repo(owner: str, repo: str, instance: str = "") -> Any:
"""Delete a repository permanently.""" """Delete a repository permanently."""
return DELETE(instance, f"/repos/{owner}/{repo}") 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: def fork_repo(owner: str, repo: str, organization: str = "", new_repo_name: str = "", instance: str = "") -> Any:
"""Fork a repository.""" """Fork a repository."""
return POST(instance, f"/repos/{owner}/{repo}/forks", _strip({ 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, "name": new_repo_name or None,
})) }))
@mcp.tool() @tool
def set_repo_topics(owner: str, repo: str, topics: list[str], instance: str = "") -> Any: def set_repo_topics(owner: str, repo: str, topics: list[str], instance: str = "") -> Any:
"""Replace all topics of a repository.""" """Replace all topics of a repository."""
return PUT(instance, f"/repos/{owner}/{repo}/topics", {"topics": topics}) 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 # BRANCHES & TAGS
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
@mcp.tool() @tool
def list_branches(owner: str, repo: str, limit: int = 50, instance: str = "") -> Any: def list_branches(owner: str, repo: str, limit: int = 50, instance: str = "") -> Any:
"""List all branches in a repository.""" """List all branches in a repository."""
return GET(instance, f"/repos/{owner}/{repo}/branches", {"limit": limit}) 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: def get_branch(owner: str, repo: str, branch: str, instance: str = "") -> Any:
"""Get details of a branch including its latest commit.""" """Get details of a branch including its latest commit."""
return GET(instance, f"/repos/{owner}/{repo}/branches/{_b(branch)}") 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: 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.""" """Create a branch. old_branch_name defaults to the repo's default branch."""
return POST(instance, f"/repos/{owner}/{repo}/branches", _strip({ 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, "old_branch_name": old_branch_name or None,
})) }))
@mcp.tool() @tool
def delete_branch(owner: str, repo: str, branch: str, instance: str = "") -> Any: def delete_branch(owner: str, repo: str, branch: str, instance: str = "") -> Any:
"""Delete a branch.""" """Delete a branch."""
return DELETE(instance, f"/repos/{owner}/{repo}/branches/{_b(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: def list_tags(owner: str, repo: str, limit: int = 20, instance: str = "") -> Any:
"""List tags in a repository.""" """List tags in a repository."""
return GET(instance, f"/repos/{owner}/{repo}/tags", {"limit": limit}) 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 # FILES & CONTENTS
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
@mcp.tool() @tool
def list_directory(owner: str, repo: str, path: str = "", ref: str = "", instance: str = "") -> Any: 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.""" """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})) 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: def get_file(owner: str, repo: str, filepath: str, ref: str = "", instance: str = "") -> Any:
"""Get file content and metadata. Returns base64-encoded content + SHA.""" """Get file content and metadata. Returns base64-encoded content + SHA."""
return GET(instance, f"/repos/{owner}/{repo}/contents/{filepath}", _strip({"ref": ref or None})) return GET(instance, f"/repos/{owner}/{repo}/contents/{filepath}", _strip({"ref": ref or None}))
@mcp.tool() @tool
def create_file( def create_file(
owner: str, repo: str, filepath: str, owner: str, repo: str, filepath: str,
content: str, message: str, content: str, message: str,
@@ -280,7 +299,7 @@ def create_file(
"branch": branch or None, "branch": branch or None,
})) }))
@mcp.tool() @tool
def update_file( def update_file(
owner: str, repo: str, filepath: str, owner: str, repo: str, filepath: str,
content: str, message: str, sha: str, content: str, message: str, sha: str,
@@ -295,7 +314,7 @@ def update_file(
"branch": branch or None, "branch": branch or None,
})) }))
@mcp.tool() @tool
def delete_file( def delete_file(
owner: str, repo: str, filepath: str, owner: str, repo: str, filepath: str,
message: str, sha: str, message: str, sha: str,
@@ -310,7 +329,7 @@ def delete_file(
# ISSUES # ISSUES
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
@mcp.tool() @tool
def list_issues( def list_issues(
owner: str, repo: str, owner: str, repo: str,
state: str = "open", issue_type: str = "issues", state: str = "open", issue_type: str = "issues",
@@ -325,12 +344,12 @@ def list_issues(
"limit": limit, "page": page, "limit": limit, "page": page,
})) }))
@mcp.tool() @tool
def get_issue(owner: str, repo: str, index: int, instance: str = "") -> Any: def get_issue(owner: str, repo: str, index: int, instance: str = "") -> Any:
"""Get a single issue by number.""" """Get a single issue by number."""
return GET(instance, f"/repos/{owner}/{repo}/issues/{index}") return GET(instance, f"/repos/{owner}/{repo}/issues/{index}")
@mcp.tool() @tool
def create_issue( def create_issue(
owner: str, repo: str, title: str, body: str = "", owner: str, repo: str, title: str, body: str = "",
assignees: list[str] | None = None, labels: list[int] | None = None, milestone: int = 0, assignees: list[str] | None = None, labels: list[int] | None = None, milestone: int = 0,
@@ -345,7 +364,7 @@ def create_issue(
"milestone": milestone or None, "milestone": milestone or None,
})) }))
@mcp.tool() @tool
def update_issue( def update_issue(
owner: str, repo: str, index: int, owner: str, repo: str, index: int,
title: str = "", body: str = "", state: str = "", 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, "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: def close_issue(owner: str, repo: str, index: int, instance: str = "") -> Any:
"""Close an issue.""" """Close an issue."""
return PATCH(instance, f"/repos/{owner}/{repo}/issues/{index}", {"state": "closed"}) 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: def reopen_issue(owner: str, repo: str, index: int, instance: str = "") -> Any:
"""Reopen a closed issue.""" """Reopen a closed issue."""
return PATCH(instance, f"/repos/{owner}/{repo}/issues/{index}", {"state": "open"}) 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: def list_issue_comments(owner: str, repo: str, index: int, instance: str = "") -> Any:
"""List comments on an issue.""" """List comments on an issue."""
return GET(instance, f"/repos/{owner}/{repo}/issues/{index}/comments") 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: def add_issue_comment(owner: str, repo: str, index: int, body: str, instance: str = "") -> Any:
"""Add a comment to an issue or PR.""" """Add a comment to an issue or PR."""
return POST(instance, f"/repos/{owner}/{repo}/issues/{index}/comments", {"body": body}) 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: def edit_issue_comment(owner: str, repo: str, comment_id: int, body: str, instance: str = "") -> Any:
"""Edit an existing comment.""" """Edit an existing comment."""
return PATCH(instance, f"/repos/{owner}/{repo}/issues/comments/{comment_id}", {"body": body}) 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: def delete_issue_comment(owner: str, repo: str, comment_id: int, instance: str = "") -> Any:
"""Delete a comment.""" """Delete a comment."""
return DELETE(instance, f"/repos/{owner}/{repo}/issues/comments/{comment_id}") 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 # LABELS & MILESTONES
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
@mcp.tool() @tool
def list_labels(owner: str, repo: str, instance: str = "") -> Any: def list_labels(owner: str, repo: str, instance: str = "") -> Any:
"""List all labels in a repository.""" """List all labels in a repository."""
return GET(instance, f"/repos/{owner}/{repo}/labels") 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: def create_label(owner: str, repo: str, name: str, color: str, description: str = "", instance: str = "") -> Any:
"""Create a label. color is hex like #ff0000.""" """Create a label. color is hex like #ff0000."""
return POST(instance, f"/repos/{owner}/{repo}/labels", _strip({ return POST(instance, f"/repos/{owner}/{repo}/labels", _strip({
"name": name, "color": color, "description": description or None, "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: def add_issue_labels(owner: str, repo: str, index: int, label_ids: list[int], instance: str = "") -> Any:
"""Add labels to an issue.""" """Add labels to an issue."""
return POST(instance, f"/repos/{owner}/{repo}/issues/{index}/labels", {"labels": label_ids}) 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: def list_milestones(owner: str, repo: str, state: str = "open", instance: str = "") -> Any:
"""List milestones. state: open|closed|all.""" """List milestones. state: open|closed|all."""
return GET(instance, f"/repos/{owner}/{repo}/milestones", {"state": state}) 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: 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""" """Create a milestone. due_on: 2024-12-31T00:00:00Z"""
return POST(instance, f"/repos/{owner}/{repo}/milestones", _strip({ 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 # PULL REQUESTS
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
@mcp.tool() @tool
def list_pull_requests(owner: str, repo: str, state: str = "open", limit: int = 20, instance: str = "") -> Any: def list_pull_requests(owner: str, repo: str, state: str = "open", limit: int = 20, instance: str = "") -> Any:
"""List pull requests. state: open|closed|all.""" """List pull requests. state: open|closed|all."""
return GET(instance, f"/repos/{owner}/{repo}/pulls", {"state": state, "limit": limit}) 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: def get_pull_request(owner: str, repo: str, index: int, instance: str = "") -> Any:
"""Get a pull request by index.""" """Get a pull request by index."""
return GET(instance, f"/repos/{owner}/{repo}/pulls/{index}") return GET(instance, f"/repos/{owner}/{repo}/pulls/{index}")
@mcp.tool() @tool
def create_pull_request( def create_pull_request(
owner: str, repo: str, title: str, head: str, base: str, owner: str, repo: str, title: str, head: str, base: str,
body: str = "", assignees: list[str] | None = None, labels: list[int] | None = None, body: str = "", assignees: list[str] | None = None, labels: list[int] | None = None,
@@ -447,7 +466,7 @@ def create_pull_request(
"labels": labels or None, "labels": labels or None,
})) }))
@mcp.tool() @tool
def update_pull_request( def update_pull_request(
owner: str, repo: str, index: int, owner: str, repo: str, index: int,
title: str = "", body: str = "", state: str = "", 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, "title": title or None, "body": body or None, "state": state or None,
})) }))
@mcp.tool() @tool
def merge_pull_request( def merge_pull_request(
owner: str, repo: str, index: int, owner: str, repo: str, index: int,
merge_style: str = "merge", merge_style: str = "merge",
@@ -474,7 +493,7 @@ def merge_pull_request(
"delete_branch_after_merge": delete_branch_after_merge, "delete_branch_after_merge": delete_branch_after_merge,
})) }))
@mcp.tool() @tool
def get_pr_diff(owner: str, repo: str, index: int, instance: str = "") -> Any: def get_pr_diff(owner: str, repo: str, index: int, instance: str = "") -> Any:
"""Get the diff of a pull request as text.""" """Get the diff of a pull request as text."""
api, headers = _get_instance(instance) 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}") raise RuntimeError(f"Gitea API {r.status_code}: {r.text}")
return {"diff": r.text} return {"diff": r.text}
@mcp.tool() @tool
def list_pr_files( def list_pr_files(
owner: str, repo: str, index: int, owner: str, repo: str, index: int,
limit: int = 50, page: int = 1, limit: int = 50, page: int = 1,
@@ -497,12 +516,12 @@ def list_pr_files(
"whitespace": whitespace or None, "whitespace": whitespace or None,
})) }))
@mcp.tool() @tool
def list_pr_reviews(owner: str, repo: str, index: int, instance: str = "") -> Any: def list_pr_reviews(owner: str, repo: str, index: int, instance: str = "") -> Any:
"""List reviews on a pull request.""" """List reviews on a pull request."""
return GET(instance, f"/repos/{owner}/{repo}/pulls/{index}/reviews") return GET(instance, f"/repos/{owner}/{repo}/pulls/{index}/reviews")
@mcp.tool() @tool
def create_pr_review( def create_pr_review(
owner: str, repo: str, index: int, owner: str, repo: str, index: int,
state: str, body: str = "", state: str, body: str = "",
@@ -517,17 +536,17 @@ def create_pr_review(
# RELEASES # RELEASES
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
@mcp.tool() @tool
def list_releases(owner: str, repo: str, limit: int = 10, instance: str = "") -> Any: def list_releases(owner: str, repo: str, limit: int = 10, instance: str = "") -> Any:
"""List releases.""" """List releases."""
return GET(instance, f"/repos/{owner}/{repo}/releases", {"limit": limit}) return GET(instance, f"/repos/{owner}/{repo}/releases", {"limit": limit})
@mcp.tool() @tool
def get_latest_release(owner: str, repo: str, instance: str = "") -> Any: def get_latest_release(owner: str, repo: str, instance: str = "") -> Any:
"""Get the latest release.""" """Get the latest release."""
return GET(instance, f"/repos/{owner}/{repo}/releases/latest") return GET(instance, f"/repos/{owner}/{repo}/releases/latest")
@mcp.tool() @tool
def create_release( def create_release(
owner: str, repo: str, tag_name: str, name: str, owner: str, repo: str, tag_name: str, name: str,
body: str = "", draft: bool = False, prerelease: bool = False, body: str = "", draft: bool = False, prerelease: bool = False,
@@ -541,7 +560,7 @@ def create_release(
"target_commitish": target_commitish or None, "target_commitish": target_commitish or None,
})) }))
@mcp.tool() @tool
def delete_release(owner: str, repo: str, release_id: int, instance: str = "") -> Any: def delete_release(owner: str, repo: str, release_id: int, instance: str = "") -> Any:
"""Delete a release.""" """Delete a release."""
return DELETE(instance, f"/repos/{owner}/{repo}/releases/{release_id}") 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 # WEBHOOKS
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
@mcp.tool() @tool
def list_repo_webhooks(owner: str, repo: str, instance: str = "") -> Any: def list_repo_webhooks(owner: str, repo: str, instance: str = "") -> Any:
"""List webhooks for a repository.""" """List webhooks for a repository."""
return GET(instance, f"/repos/{owner}/{repo}/hooks") return GET(instance, f"/repos/{owner}/{repo}/hooks")
@mcp.tool() @tool
def create_repo_webhook( def create_repo_webhook(
owner: str, repo: str, url: str, events: list[str], owner: str, repo: str, url: str, events: list[str],
secret: str = "", active: bool = True, secret: str = "", active: bool = True,
@@ -569,7 +588,7 @@ def create_repo_webhook(
"active": active, "active": active,
}) })
@mcp.tool() @tool
def delete_repo_webhook(owner: str, repo: str, hook_id: int, instance: str = "") -> Any: def delete_repo_webhook(owner: str, repo: str, hook_id: int, instance: str = "") -> Any:
"""Delete a webhook.""" """Delete a webhook."""
return DELETE(instance, f"/repos/{owner}/{repo}/hooks/{hook_id}") 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 # USERS & ORGANIZATIONS
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
@mcp.tool() @tool
def get_user(username: str, instance: str = "") -> Any: def get_user(username: str, instance: str = "") -> Any:
"""Get public information about a user.""" """Get public information about a user."""
return GET(instance, f"/users/{username}") return GET(instance, f"/users/{username}")
@mcp.tool() @tool
def search_users(query: str, limit: int = 10, instance: str = "") -> Any: def search_users(query: str, limit: int = 10, instance: str = "") -> Any:
"""Search for users.""" """Search for users."""
return GET(instance, "/users/search", {"q": query, "limit": limit}) return GET(instance, "/users/search", {"q": query, "limit": limit})
@mcp.tool() @tool
def list_my_orgs(instance: str = "") -> Any: def list_my_orgs(instance: str = "") -> Any:
"""List organizations the authenticated user belongs to.""" """List organizations the authenticated user belongs to."""
return GET(instance, "/user/orgs") return GET(instance, "/user/orgs")
@mcp.tool() @tool
def get_org(org: str, instance: str = "") -> Any: def get_org(org: str, instance: str = "") -> Any:
"""Get information about an organization.""" """Get information about an organization."""
return GET(instance, f"/orgs/{org}") return GET(instance, f"/orgs/{org}")
@mcp.tool() @tool
def list_org_repos(org: str, limit: int = 50, instance: str = "") -> Any: def list_org_repos(org: str, limit: int = 50, instance: str = "") -> Any:
"""List repositories of an organization.""" """List repositories of an organization."""
return GET(instance, f"/orgs/{org}/repos", {"limit": limit}) return GET(instance, f"/orgs/{org}/repos", {"limit": limit})
@mcp.tool() @tool
def list_org_members(org: str, instance: str = "") -> Any: def list_org_members(org: str, instance: str = "") -> Any:
"""List members of an organization.""" """List members of an organization."""
return GET(instance, f"/orgs/{org}/members") return GET(instance, f"/orgs/{org}/members")
@mcp.tool() @tool
def list_org_teams(org: str, instance: str = "") -> Any: def list_org_teams(org: str, instance: str = "") -> Any:
"""List teams in an organization.""" """List teams in an organization."""
return GET(instance, f"/orgs/{org}/teams") return GET(instance, f"/orgs/{org}/teams")
@@ -617,7 +636,7 @@ def list_org_teams(org: str, instance: str = "") -> Any:
# COMMITS & HISTORY # COMMITS & HISTORY
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
@mcp.tool() @tool
def list_commits( def list_commits(
owner: str, repo: str, owner: str, repo: str,
sha: str = "", path: str = "", sha: str = "", path: str = "",
@@ -630,7 +649,7 @@ def list_commits(
"limit": limit, "page": page, "limit": limit, "page": page,
})) }))
@mcp.tool() @tool
def get_commit( def get_commit(
owner: str, repo: str, sha: str, owner: str, repo: str, sha: str,
stat: bool = True, files: bool = True, verification: bool = False, stat: bool = True, files: bool = True, verification: bool = False,
@@ -641,12 +660,12 @@ def get_commit(
"stat": stat, "files": files, "verification": verification or None, "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: def compare_branches(owner: str, repo: str, base: str, head: str, instance: str = "") -> Any:
"""Compare two branches, tags, or commits.""" """Compare two branches, tags, or commits."""
return GET(instance, f"/repos/{owner}/{repo}/compare/{_b(base)}...{_b(head)}") 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: def list_repo_contributors(owner: str, repo: str, limit: int = 20, instance: str = "") -> Any:
"""List contributors to a repository.""" """List contributors to a repository."""
return GET(instance, f"/repos/{owner}/{repo}/contributors", {"limit": limit}) 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 # NOTIFICATIONS
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
@mcp.tool() @tool
def list_notifications(include_read: bool = False, limit: int = 20, instance: str = "") -> Any: def list_notifications(include_read: bool = False, limit: int = 20, instance: str = "") -> Any:
"""List notifications. include_read=True includes already-read notifications.""" """List notifications. include_read=True includes already-read notifications."""
return GET(instance, "/notifications", _strip({"all": include_read, "limit": limit})) return GET(instance, "/notifications", _strip({"all": include_read, "limit": limit}))
@mcp.tool() @tool
def mark_all_notifications_read(instance: str = "") -> Any: def mark_all_notifications_read(instance: str = "") -> Any:
"""Mark all notifications as read.""" """Mark all notifications as read."""
return PUT(instance, "/notifications", {}) 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__": if __name__ == "__main__":
mcp.run() main()
+133 -53
View File
@@ -9,96 +9,176 @@ description: >
"open PR", "merge pull request", "create release", "add webhook", "open PR", "merge pull request", "create release", "add webhook",
"list branches", "gitea notifications", "check gitea connection". "list branches", "gitea notifications", "check gitea connection".
metadata: metadata:
version: "0.2.0" version: "0.3.0"
--- ---
# Gitea Platform Assistant # 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 ## 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 uv run … "$SCRIPT" list_instances → configured instances + default
mcp__gitea__get_current_user() → verifies auth works 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 ## 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). - User says "на work" / "on the work server" → add `--instance work`.
When no instance is specified → pass `instance=""` (uses config default). - No instance mentioned → omit `--instance` (uses config default).
## Tool Categories ## Tool Categories
### Meta & Health ### Meta & Health
- `list_instances()` — show all configured instances - `list_instances` — show all configured instances
- `get_server_info(instance)` — Gitea version - `get_server_info --instance <name>` — Gitea version
- `get_current_user(instance)` — authenticated user - `get_current_user --instance <name>` — authenticated user
### Repositories ### Repositories
- `list_my_repos(limit, page, instance)` - `list_my_repos --limit 50 --page 1`
- `search_repos(query, instance)` - `search_repos --query <q>`
- `get_repo(owner, repo, instance)` - `get_repo --owner <o> --repo <r>`
- `create_repo(name, description, private, auto_init, default_branch, instance)` - `create_repo --name <n> --description <d> --private/--no-private --auto-init/--no-auto-init --default-branch main`
- `create_org_repo(org, name, ..., instance)` - `create_org_repo --org <org> --name <n> …`
- `update_repo(owner, repo, ..., instance)` - `update_repo --owner <o> --repo <r> --description … --no-private --website …`
- `delete_repo(owner, repo, instance)` ⚠️ permanent - `delete_repo --owner <o> --repo <r>` ⚠️ permanent
- `fork_repo(owner, repo, organization, new_repo_name, instance)` - `fork_repo --owner <o> --repo <r> --organization … --new-repo-name`
- `set_repo_topics(owner, repo, topics, instance)` - `set_repo_topics --owner <o> --repo <r> --topics ci backend`
### Branches & Tags ### Branches & Tags
- `list_branches(owner, repo, instance)` - `list_branches --owner <o> --repo <r>`
- `get_branch(owner, repo, branch, instance)` - `get_branch --owner <o> --repo <r> --branch <b>`
- `create_branch(owner, repo, new_branch_name, old_branch_name, instance)` - `create_branch --owner <o> --repo <r> --new-branch-name <b> --old-branch-name <src>`
- `delete_branch(owner, repo, branch, instance)` ⚠️ - `delete_branch --owner <o> --repo <r> --branch <b>` ⚠️
- `list_tags(owner, repo, instance)` - `list_tags --owner <o> --repo <r>`
### Files ### Files
- `list_directory(owner, repo, path, ref, instance)` — browse repo tree - `list_directory --owner <o> --repo <r> --path <p> --ref <branch>` — browse tree
- `get_file(owner, repo, filepath, ref, instance)` returns content + sha - `get_file --owner <o> --repo <r> --filepath <p> --ref <branch>` — content + sha
- `create_file(owner, repo, filepath, content, message, branch, instance)` — plain text input, auto-encoded - `create_file --owner <o> --repo <r> --filepath <p> --content "<text>" --message <msg> --branch <b>` — plain text, auto-encoded
- `update_file(owner, repo, filepath, content, message, sha, branch, instance)` — requires sha from get_file - `update_file … --content "<text>" --message <msg> --sha <sha> --branch <b>` sha from get_file
- `delete_file(owner, repo, filepath, message, sha, branch, instance)` — requires sha - `delete_file … --message <msg> --sha <sha> --branch <b>` sha required
### Issues ### Issues
- `list_issues(owner, repo, state, type, labels, assignee, limit, instance)` - `list_issues --owner <o> --repo <r> --state open|closed|all --issue-type issues|pulls --labels … --assignee … --limit 20`
- `get_issue(owner, repo, index, instance)` - `get_issue --owner <o> --repo <r> --index <n>`
- `create_issue(owner, repo, title, body, assignees, labels, instance)` - `create_issue --owner <o> --repo <r> --title <t> --body <b> --assignees alice bob --labels 1 2`
- `update_issue(owner, repo, index, title, body, state, instance)` - `update_issue --owner <o> --repo <r> --index <n> --title … --body … --state open|closed`
- `close_issue / reopen_issue(owner, repo, index, instance)` - `close_issue` / `reopen_issue --owner <o> --repo <r> --index <n>`
- `list_issue_comments / add_issue_comment / edit_issue_comment / delete_issue_comment` - `list_issue_comments` / `add_issue_comment --body <b>` / `edit_issue_comment --comment-id <id> --body <b>` / `delete_issue_comment --comment-id <id>`
### Labels & Milestones ### Labels & Milestones
- `list_labels / create_label(name, color #rrggbb) / add_issue_labels` - `list_labels` / `create_label --name <n> --color "#rrggbb"` / `add_issue_labels --index <n> --label-ids 1 2`
- `list_milestones / create_milestone(title, due_on)` - `list_milestones --state open|closed|all` / `create_milestone --title <t> --due-on 2024-12-31T00:00:00Z`
### Pull Requests ### Pull Requests
- `list_pull_requests(owner, repo, state, instance)` - `list_pull_requests --owner <o> --repo <r> --state open|closed|all`
- `get_pull_request(owner, repo, index, instance)` - `get_pull_request --owner <o> --repo <r> --index <n>`
- `create_pull_request(owner, repo, title, head, base, body, instance)` - `create_pull_request --owner <o> --repo <r> --title <t> --head <src-branch> --base <target-branch> --body <b>`
- `merge_pull_request(owner, repo, index, merge_style, instance)` — merge_style: merge|rebase|squash - `update_pull_request --owner <o> --repo <r> --index <n> --title … --body … --state …`
- `get_pr_diff(owner, repo, index, instance)` — returns diff text - `merge_pull_request --owner <o> --repo <r> --index <n> --merge-style merge|rebase|squash|fast-forward-only --delete-branch-after-merge`
- `list_pr_reviews / create_pr_review(state: APPROVED|COMMENT|REQUEST_CHANGES)` - `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 ### 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 ### 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 ### Users & Organizations
- `get_current_user / get_user(username) / search_users(query)` - `get_current_user` / `get_user --username <u>` / `search_users --query <q>`
- `list_my_orgs / get_org / list_org_repos / list_org_members / list_org_teams` - `list_my_orgs` / `get_org --org <org>` / `list_org_repos --org <org>` / `list_org_members --org <org>` / `list_org_teams --org <org>`
### Commits & Activity ### Commits & Activity
- `list_commits(owner, repo, sha, path, limit)` - `list_commits --owner <o> --repo <r> --sha <ref> --path <p> --limit 20`
- `compare_branches(owner, repo, base, head)` — diff + commit list - `get_commit --owner <o> --repo <r> --sha <sha>``--no-stat` / `--no-files` to slim output
- `list_repo_contributors` - `compare_branches --owner <o> --repo <r> --base <b> --head <h>` — diff + commit list
- `list_repo_contributors --owner <o> --repo <r>`
### Notifications ### 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]] [[package]]
name = "gitea-platform-mcp" name = "gitea-platform-mcp"
version = "0.2.0" version = "0.3.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },