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:
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user