gitea-mcp 0.1.1.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gitea_mcp/__init__.py +9 -0
- gitea_mcp/_version.py +1 -0
- gitea_mcp/client.py +92 -0
- gitea_mcp/config.py +54 -0
- gitea_mcp/server.py +64 -0
- gitea_mcp/tools/__init__.py +5 -0
- gitea_mcp/tools/issues.py +239 -0
- gitea_mcp/tools/releases.py +88 -0
- gitea_mcp/tools/repos.py +95 -0
- gitea_mcp-0.1.1.dev0.dist-info/METADATA +147 -0
- gitea_mcp-0.1.1.dev0.dist-info/RECORD +15 -0
- gitea_mcp-0.1.1.dev0.dist-info/WHEEL +5 -0
- gitea_mcp-0.1.1.dev0.dist-info/entry_points.txt +2 -0
- gitea_mcp-0.1.1.dev0.dist-info/licenses/LICENSE +21 -0
- gitea_mcp-0.1.1.dev0.dist-info/top_level.txt +1 -0
gitea_mcp/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""gitea-mcp — Model Context Protocol server for Gitea (and Forgejo, Codeberg)."""
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
from gitea_mcp._version import __version__
|
|
5
|
+
except ImportError:
|
|
6
|
+
# Package not installed in editable mode or version file not yet generated
|
|
7
|
+
__version__ = "0.0.0.dev0"
|
|
8
|
+
|
|
9
|
+
__all__ = ["__version__"]
|
gitea_mcp/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.1.1.dev0'
|
gitea_mcp/client.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Async HTTP client wrapper for the Gitea REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GiteaError(Exception):
|
|
11
|
+
"""Base exception for Gitea client errors."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GiteaAPIError(GiteaError):
|
|
15
|
+
"""Raised when the Gitea API returns a non-success response."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, status_code: int, message: str, method: str, url: str) -> None:
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
self.method = method
|
|
20
|
+
self.url = url
|
|
21
|
+
super().__init__(f"[{method} {url}] {status_code}: {message}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GiteaClient:
|
|
25
|
+
"""Async HTTP client for the Gitea REST API.
|
|
26
|
+
|
|
27
|
+
Uses Personal Access Token authentication via the
|
|
28
|
+
``Authorization: token <PAT>`` header (Gitea's convention; NOT Bearer).
|
|
29
|
+
One shared :class:`httpx.AsyncClient` per server lifetime.
|
|
30
|
+
|
|
31
|
+
All paths passed to the verb methods are appended under ``/api/v1``; pass
|
|
32
|
+
``/repos/{owner}/{repo}`` rather than the full URL.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, base_url: str, token: str, timeout: float = 30.0) -> None:
|
|
36
|
+
self._base_url = base_url.rstrip("/")
|
|
37
|
+
self._client = httpx.AsyncClient(
|
|
38
|
+
base_url=self._base_url,
|
|
39
|
+
headers={
|
|
40
|
+
"Authorization": f"token {token}",
|
|
41
|
+
"Accept": "application/json",
|
|
42
|
+
},
|
|
43
|
+
timeout=timeout,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
async def close(self) -> None:
|
|
47
|
+
"""Close the underlying HTTP client. Safe to call multiple times."""
|
|
48
|
+
await self._client.aclose()
|
|
49
|
+
|
|
50
|
+
async def get(
|
|
51
|
+
self, path: str, params: dict[str, Any] | None = None
|
|
52
|
+
) -> Any:
|
|
53
|
+
response = await self._client.get(self._api_path(path), params=params)
|
|
54
|
+
return self._handle(response, method="GET", path=path)
|
|
55
|
+
|
|
56
|
+
async def post(self, path: str, json: Any | None = None) -> Any:
|
|
57
|
+
response = await self._client.post(self._api_path(path), json=json)
|
|
58
|
+
return self._handle(response, method="POST", path=path)
|
|
59
|
+
|
|
60
|
+
async def put(self, path: str, json: Any | None = None) -> Any:
|
|
61
|
+
response = await self._client.put(self._api_path(path), json=json)
|
|
62
|
+
return self._handle(response, method="PUT", path=path)
|
|
63
|
+
|
|
64
|
+
async def patch(self, path: str, json: Any | None = None) -> Any:
|
|
65
|
+
response = await self._client.patch(self._api_path(path), json=json)
|
|
66
|
+
return self._handle(response, method="PATCH", path=path)
|
|
67
|
+
|
|
68
|
+
async def delete(self, path: str) -> Any:
|
|
69
|
+
response = await self._client.delete(self._api_path(path))
|
|
70
|
+
return self._handle(response, method="DELETE", path=path)
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _api_path(path: str) -> str:
|
|
74
|
+
"""Prefix a relative path with /api/v1, leaving absolute API paths intact."""
|
|
75
|
+
if path.startswith("/api/v1"):
|
|
76
|
+
return path
|
|
77
|
+
if path.startswith("/"):
|
|
78
|
+
return f"/api/v1{path}"
|
|
79
|
+
return f"/api/v1/{path}"
|
|
80
|
+
|
|
81
|
+
def _handle(self, response: httpx.Response, method: str, path: str) -> Any:
|
|
82
|
+
if response.is_success:
|
|
83
|
+
if response.status_code == 204 or not response.content:
|
|
84
|
+
return None
|
|
85
|
+
return response.json()
|
|
86
|
+
message = response.text.strip() or response.reason_phrase
|
|
87
|
+
raise GiteaAPIError(
|
|
88
|
+
status_code=response.status_code,
|
|
89
|
+
message=message,
|
|
90
|
+
method=method,
|
|
91
|
+
url=str(response.request.url),
|
|
92
|
+
)
|
gitea_mcp/config.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Configuration loaded from environment variables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Config:
|
|
11
|
+
"""Runtime configuration for gitea-mcp.
|
|
12
|
+
|
|
13
|
+
Loaded once at startup from environment variables. Immutable thereafter.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
base_url: str
|
|
17
|
+
token: str
|
|
18
|
+
timeout: float = 30.0
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def from_env(cls) -> Config:
|
|
22
|
+
"""Load configuration from environment variables.
|
|
23
|
+
|
|
24
|
+
Required:
|
|
25
|
+
GITEA_URL: Base URL of the Gitea instance (e.g. https://gitea.example.com)
|
|
26
|
+
GITEA_TOKEN: Personal Access Token
|
|
27
|
+
|
|
28
|
+
Optional:
|
|
29
|
+
GITEA_TIMEOUT: HTTP request timeout in seconds (default: 30)
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
RuntimeError: if required variables are missing.
|
|
33
|
+
"""
|
|
34
|
+
base_url = os.environ.get("GITEA_URL", "").strip()
|
|
35
|
+
if not base_url:
|
|
36
|
+
raise RuntimeError(
|
|
37
|
+
"GITEA_URL environment variable is required. "
|
|
38
|
+
"Set it to the base URL of your Gitea instance."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
token = os.environ.get("GITEA_TOKEN", "").strip()
|
|
42
|
+
if not token:
|
|
43
|
+
raise RuntimeError(
|
|
44
|
+
"GITEA_TOKEN environment variable is required. "
|
|
45
|
+
"Generate a Personal Access Token in Gitea: Settings -> Applications."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
timeout = float(os.environ.get("GITEA_TIMEOUT", "30"))
|
|
49
|
+
|
|
50
|
+
return cls(
|
|
51
|
+
base_url=base_url.rstrip("/"),
|
|
52
|
+
token=token,
|
|
53
|
+
timeout=timeout,
|
|
54
|
+
)
|
gitea_mcp/server.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""MCP server entry point. Registers tools and runs stdio transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
|
|
8
|
+
from fastmcp import FastMCP
|
|
9
|
+
|
|
10
|
+
from gitea_mcp.client import GiteaClient
|
|
11
|
+
from gitea_mcp.config import Config
|
|
12
|
+
|
|
13
|
+
# Module-level FastMCP instance so tool modules can register against it.
|
|
14
|
+
mcp: FastMCP = FastMCP("gitea-mcp")
|
|
15
|
+
|
|
16
|
+
# Singleton client populated at startup. Tool modules access via get_client().
|
|
17
|
+
_client: GiteaClient | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_client() -> GiteaClient:
|
|
21
|
+
"""Return the singleton :class:`GiteaClient`.
|
|
22
|
+
|
|
23
|
+
Must be called after :func:`main` has initialized the client.
|
|
24
|
+
"""
|
|
25
|
+
if _client is None:
|
|
26
|
+
raise RuntimeError(
|
|
27
|
+
"GiteaClient not initialized. The gitea-mcp server must be started "
|
|
28
|
+
"via the gitea-mcp entry point so the client is available before "
|
|
29
|
+
"any tools are called."
|
|
30
|
+
)
|
|
31
|
+
return _client
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Import tool modules so their @mcp.tool() registrations execute on module load.
|
|
35
|
+
# Ordering doesn't matter; each module registers its own tools against `mcp`.
|
|
36
|
+
from gitea_mcp.tools import issues, releases, repos # noqa: E402, F401
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main() -> None:
|
|
40
|
+
"""Console-script entry point.
|
|
41
|
+
|
|
42
|
+
1. Loads configuration from environment variables.
|
|
43
|
+
2. Initializes the singleton GiteaClient.
|
|
44
|
+
3. Runs the MCP server over stdio (blocks until the client disconnects).
|
|
45
|
+
4. Closes the GiteaClient on shutdown.
|
|
46
|
+
"""
|
|
47
|
+
global _client
|
|
48
|
+
|
|
49
|
+
config = Config.from_env()
|
|
50
|
+
_client = GiteaClient(
|
|
51
|
+
base_url=config.base_url,
|
|
52
|
+
token=config.token,
|
|
53
|
+
timeout=config.timeout,
|
|
54
|
+
)
|
|
55
|
+
try:
|
|
56
|
+
mcp.run()
|
|
57
|
+
finally:
|
|
58
|
+
# Best-effort cleanup. If the event loop is already closed, ignore.
|
|
59
|
+
with contextlib.suppress(RuntimeError):
|
|
60
|
+
asyncio.run(_client.close())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
main()
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""MCP tools for Gitea issues."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Any
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
from gitea_mcp.client import GiteaClient, GiteaError
|
|
10
|
+
from gitea_mcp.server import get_client, mcp
|
|
11
|
+
|
|
12
|
+
# ---- Internal helpers ------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def _list_all_labels(
|
|
16
|
+
client: GiteaClient, owner: str, repo: str
|
|
17
|
+
) -> list[dict[str, Any]]:
|
|
18
|
+
"""Page through every label defined in a repository."""
|
|
19
|
+
all_labels: list[dict[str, Any]] = []
|
|
20
|
+
page = 1
|
|
21
|
+
while True:
|
|
22
|
+
batch = await client.get(
|
|
23
|
+
f"/repos/{owner}/{repo}/labels",
|
|
24
|
+
params={"page": page, "limit": 50},
|
|
25
|
+
)
|
|
26
|
+
if not batch:
|
|
27
|
+
break
|
|
28
|
+
all_labels.extend(batch)
|
|
29
|
+
if len(batch) < 50:
|
|
30
|
+
break
|
|
31
|
+
page += 1
|
|
32
|
+
return all_labels
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def _resolve_label_ids(
|
|
36
|
+
client: GiteaClient, owner: str, repo: str, label_names: list[str]
|
|
37
|
+
) -> list[int]:
|
|
38
|
+
"""Resolve a list of label names to the integer IDs Gitea's issue API expects.
|
|
39
|
+
|
|
40
|
+
Gitea's create-issue and replace-issue-labels endpoints take ``labels`` as a
|
|
41
|
+
list of integer IDs, not names. This helper fetches the repo's labels once
|
|
42
|
+
and maps the names in. Raises :class:`GiteaError` if any name doesn't match
|
|
43
|
+
a defined label, including the available label names in the error message
|
|
44
|
+
so the caller knows what's valid.
|
|
45
|
+
"""
|
|
46
|
+
if not label_names:
|
|
47
|
+
return []
|
|
48
|
+
all_labels = await _list_all_labels(client, owner, repo)
|
|
49
|
+
name_to_id = {label["name"]: label["id"] for label in all_labels}
|
|
50
|
+
missing = [name for name in label_names if name not in name_to_id]
|
|
51
|
+
if missing:
|
|
52
|
+
raise GiteaError(
|
|
53
|
+
f"Labels not found in {owner}/{repo}: {missing}. "
|
|
54
|
+
f"Available labels: {sorted(name_to_id.keys())}"
|
|
55
|
+
)
|
|
56
|
+
return [name_to_id[name] for name in label_names]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---- Tools -----------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@mcp.tool()
|
|
63
|
+
async def create_issue(
|
|
64
|
+
owner: Annotated[str, Field(description="Repository owner (user or organization name)")],
|
|
65
|
+
repo: Annotated[str, Field(description="Repository name")],
|
|
66
|
+
title: Annotated[str, Field(description="Issue title")],
|
|
67
|
+
body: Annotated[str, Field(description="Issue body in Markdown")] = "",
|
|
68
|
+
labels: Annotated[
|
|
69
|
+
list[str] | None,
|
|
70
|
+
Field(description="Label names to apply to the new issue (resolved to IDs automatically)"),
|
|
71
|
+
] = None,
|
|
72
|
+
assignees: Annotated[
|
|
73
|
+
list[str] | None,
|
|
74
|
+
Field(description="Usernames to assign to the new issue"),
|
|
75
|
+
] = None,
|
|
76
|
+
milestone: Annotated[int | None, Field(description="Milestone ID to attach")] = None,
|
|
77
|
+
) -> dict[str, Any]:
|
|
78
|
+
"""Create a new issue in a Gitea repository.
|
|
79
|
+
|
|
80
|
+
Returns the full Gitea Issue object including the assigned number, URL, and
|
|
81
|
+
metadata. Label names are resolved to IDs against the repository's label
|
|
82
|
+
set; an unknown label name fails the call cleanly with the list of valid
|
|
83
|
+
names.
|
|
84
|
+
"""
|
|
85
|
+
client = get_client()
|
|
86
|
+
payload: dict[str, Any] = {"title": title, "body": body}
|
|
87
|
+
if assignees:
|
|
88
|
+
payload["assignees"] = assignees
|
|
89
|
+
if milestone is not None:
|
|
90
|
+
payload["milestone"] = milestone
|
|
91
|
+
if labels:
|
|
92
|
+
payload["labels"] = await _resolve_label_ids(client, owner, repo, labels)
|
|
93
|
+
issue: dict[str, Any] = await client.post(
|
|
94
|
+
f"/repos/{owner}/{repo}/issues", json=payload
|
|
95
|
+
)
|
|
96
|
+
return issue
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@mcp.tool()
|
|
100
|
+
async def list_issues(
|
|
101
|
+
owner: Annotated[str, Field(description="Repository owner (user or organization name)")],
|
|
102
|
+
repo: Annotated[str, Field(description="Repository name")],
|
|
103
|
+
state: Annotated[
|
|
104
|
+
str, Field(description="Filter by state: 'open', 'closed', or 'all'")
|
|
105
|
+
] = "open",
|
|
106
|
+
labels: Annotated[
|
|
107
|
+
str | None,
|
|
108
|
+
Field(description="Comma-separated label names to filter by"),
|
|
109
|
+
] = None,
|
|
110
|
+
assignee: Annotated[
|
|
111
|
+
str | None,
|
|
112
|
+
Field(description="Filter to issues assigned to this username"),
|
|
113
|
+
] = None,
|
|
114
|
+
page: Annotated[int, Field(description="Page number (1-indexed)")] = 1,
|
|
115
|
+
limit: Annotated[int, Field(description="Items per page (max 50)")] = 30,
|
|
116
|
+
) -> list[dict[str, Any]]:
|
|
117
|
+
"""List issues in a Gitea repository.
|
|
118
|
+
|
|
119
|
+
Pull requests are excluded; only true issues are returned. Filters compose
|
|
120
|
+
(state AND labels AND assignee).
|
|
121
|
+
"""
|
|
122
|
+
client = get_client()
|
|
123
|
+
params: dict[str, Any] = {
|
|
124
|
+
"state": state,
|
|
125
|
+
"type": "issues", # exclude pull requests
|
|
126
|
+
"page": page,
|
|
127
|
+
"limit": limit,
|
|
128
|
+
}
|
|
129
|
+
if labels:
|
|
130
|
+
params["labels"] = labels
|
|
131
|
+
if assignee:
|
|
132
|
+
params["assigned_by"] = assignee
|
|
133
|
+
issues: list[dict[str, Any]] = await client.get(
|
|
134
|
+
f"/repos/{owner}/{repo}/issues", params=params
|
|
135
|
+
)
|
|
136
|
+
return issues
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@mcp.tool()
|
|
140
|
+
async def get_issue(
|
|
141
|
+
owner: Annotated[str, Field(description="Repository owner")],
|
|
142
|
+
repo: Annotated[str, Field(description="Repository name")],
|
|
143
|
+
issue_number: Annotated[int, Field(description="Issue number (the #N in the URL)")],
|
|
144
|
+
) -> dict[str, Any]:
|
|
145
|
+
"""Get a single issue by number, including all of its comments.
|
|
146
|
+
|
|
147
|
+
The returned object is the standard Gitea Issue payload, with an additional
|
|
148
|
+
``comments_list`` field containing the full list of Comment objects. The
|
|
149
|
+
existing top-level ``comments`` integer field (comment count) is preserved.
|
|
150
|
+
"""
|
|
151
|
+
client = get_client()
|
|
152
|
+
issue: dict[str, Any] = await client.get(
|
|
153
|
+
f"/repos/{owner}/{repo}/issues/{issue_number}"
|
|
154
|
+
)
|
|
155
|
+
issue["comments_list"] = await client.get(
|
|
156
|
+
f"/repos/{owner}/{repo}/issues/{issue_number}/comments"
|
|
157
|
+
)
|
|
158
|
+
return issue
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@mcp.tool()
|
|
162
|
+
async def update_issue(
|
|
163
|
+
owner: Annotated[str, Field(description="Repository owner")],
|
|
164
|
+
repo: Annotated[str, Field(description="Repository name")],
|
|
165
|
+
issue_number: Annotated[int, Field(description="Issue number")],
|
|
166
|
+
title: Annotated[str | None, Field(description="New title")] = None,
|
|
167
|
+
body: Annotated[str | None, Field(description="New body (Markdown)")] = None,
|
|
168
|
+
state: Annotated[
|
|
169
|
+
str | None,
|
|
170
|
+
Field(description="New state: 'open' or 'closed'"),
|
|
171
|
+
] = None,
|
|
172
|
+
labels: Annotated[
|
|
173
|
+
list[str] | None,
|
|
174
|
+
Field(description="Replace labels with this exact set (names; pass [] to clear)"),
|
|
175
|
+
] = None,
|
|
176
|
+
assignees: Annotated[
|
|
177
|
+
list[str] | None,
|
|
178
|
+
Field(description="Replace assignees with this exact set of usernames"),
|
|
179
|
+
] = None,
|
|
180
|
+
milestone: Annotated[
|
|
181
|
+
int | None,
|
|
182
|
+
Field(description="Milestone ID to attach; pass 0 to clear the milestone"),
|
|
183
|
+
] = None,
|
|
184
|
+
) -> dict[str, Any]:
|
|
185
|
+
"""Update an existing issue's title, body, state, assignees, milestone, or labels.
|
|
186
|
+
|
|
187
|
+
Each argument is independent — pass only the fields you want to change.
|
|
188
|
+
Labels are replaced atomically against the new set (passing ``[]`` removes
|
|
189
|
+
all labels). Other list fields (assignees) follow the same replace semantics.
|
|
190
|
+
"""
|
|
191
|
+
client = get_client()
|
|
192
|
+
payload: dict[str, Any] = {}
|
|
193
|
+
if title is not None:
|
|
194
|
+
payload["title"] = title
|
|
195
|
+
if body is not None:
|
|
196
|
+
payload["body"] = body
|
|
197
|
+
if state is not None:
|
|
198
|
+
payload["state"] = state
|
|
199
|
+
if assignees is not None:
|
|
200
|
+
payload["assignees"] = assignees
|
|
201
|
+
if milestone is not None:
|
|
202
|
+
# Gitea convention: milestone=0 in the request clears the milestone.
|
|
203
|
+
# We send null in that case, which Gitea also accepts and is unambiguous.
|
|
204
|
+
payload["milestone"] = milestone if milestone > 0 else None
|
|
205
|
+
|
|
206
|
+
issue: dict[str, Any]
|
|
207
|
+
if payload:
|
|
208
|
+
issue = await client.patch(
|
|
209
|
+
f"/repos/{owner}/{repo}/issues/{issue_number}", json=payload
|
|
210
|
+
)
|
|
211
|
+
else:
|
|
212
|
+
# No PATCH-level changes — fetch the current issue so the caller still
|
|
213
|
+
# gets the up-to-date object after the labels update below.
|
|
214
|
+
issue = await client.get(f"/repos/{owner}/{repo}/issues/{issue_number}")
|
|
215
|
+
|
|
216
|
+
if labels is not None:
|
|
217
|
+
label_ids = await _resolve_label_ids(client, owner, repo, labels)
|
|
218
|
+
issue["labels"] = await client.put(
|
|
219
|
+
f"/repos/{owner}/{repo}/issues/{issue_number}/labels",
|
|
220
|
+
json={"labels": label_ids},
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return issue
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@mcp.tool()
|
|
227
|
+
async def add_comment(
|
|
228
|
+
owner: Annotated[str, Field(description="Repository owner")],
|
|
229
|
+
repo: Annotated[str, Field(description="Repository name")],
|
|
230
|
+
issue_number: Annotated[int, Field(description="Issue number")],
|
|
231
|
+
body: Annotated[str, Field(description="Comment body (Markdown)")],
|
|
232
|
+
) -> dict[str, Any]:
|
|
233
|
+
"""Add a comment to an existing issue. Returns the created Comment object."""
|
|
234
|
+
client = get_client()
|
|
235
|
+
comment: dict[str, Any] = await client.post(
|
|
236
|
+
f"/repos/{owner}/{repo}/issues/{issue_number}/comments",
|
|
237
|
+
json={"body": body},
|
|
238
|
+
)
|
|
239
|
+
return comment
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""MCP tools for Gitea releases."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Any
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
from gitea_mcp.server import get_client, mcp
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@mcp.tool()
|
|
13
|
+
async def list_releases(
|
|
14
|
+
owner: Annotated[str, Field(description="Repository owner")],
|
|
15
|
+
repo: Annotated[str, Field(description="Repository name")],
|
|
16
|
+
page: Annotated[int, Field(description="Page number (1-indexed)")] = 1,
|
|
17
|
+
limit: Annotated[int, Field(description="Items per page (max 50)")] = 30,
|
|
18
|
+
) -> list[dict[str, Any]]:
|
|
19
|
+
"""List releases for a repository.
|
|
20
|
+
|
|
21
|
+
Returns the standard Gitea Release object array, including drafts and
|
|
22
|
+
pre-releases. Sort order is newest first.
|
|
23
|
+
"""
|
|
24
|
+
client = get_client()
|
|
25
|
+
releases: list[dict[str, Any]] = await client.get(
|
|
26
|
+
f"/repos/{owner}/{repo}/releases",
|
|
27
|
+
params={"page": page, "limit": limit},
|
|
28
|
+
)
|
|
29
|
+
return releases
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@mcp.tool()
|
|
33
|
+
async def create_release(
|
|
34
|
+
owner: Annotated[str, Field(description="Repository owner")],
|
|
35
|
+
repo: Annotated[str, Field(description="Repository name")],
|
|
36
|
+
tag_name: Annotated[
|
|
37
|
+
str,
|
|
38
|
+
Field(
|
|
39
|
+
description=(
|
|
40
|
+
"Tag this release is based on. If the tag does not already exist "
|
|
41
|
+
"in the repository, Gitea creates it at the time of release."
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
],
|
|
45
|
+
name: Annotated[str, Field(description="Release title")],
|
|
46
|
+
body: Annotated[str, Field(description="Release notes in Markdown")] = "",
|
|
47
|
+
target_commitish: Annotated[
|
|
48
|
+
str | None,
|
|
49
|
+
Field(
|
|
50
|
+
description=(
|
|
51
|
+
"Branch name or commit SHA the tag should point at. "
|
|
52
|
+
"Defaults to the repository's default branch. "
|
|
53
|
+
"Ignored if the tag already exists."
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
] = None,
|
|
57
|
+
draft: Annotated[
|
|
58
|
+
bool,
|
|
59
|
+
Field(description="Save as draft without publishing"),
|
|
60
|
+
] = False,
|
|
61
|
+
prerelease: Annotated[
|
|
62
|
+
bool,
|
|
63
|
+
Field(description="Mark as a pre-release"),
|
|
64
|
+
] = False,
|
|
65
|
+
) -> dict[str, Any]:
|
|
66
|
+
"""Create a new release in a repository.
|
|
67
|
+
|
|
68
|
+
.. warning::
|
|
69
|
+
|
|
70
|
+
Side effects: if ``tag_name`` does not already exist in the repository,
|
|
71
|
+
Gitea creates the tag at the current ``target_commitish`` (or default
|
|
72
|
+
branch). Creating a draft does NOT skip tag creation — both drafts and
|
|
73
|
+
published releases will leave a tag in the repo.
|
|
74
|
+
"""
|
|
75
|
+
client = get_client()
|
|
76
|
+
payload: dict[str, Any] = {
|
|
77
|
+
"tag_name": tag_name,
|
|
78
|
+
"name": name,
|
|
79
|
+
"body": body,
|
|
80
|
+
"draft": draft,
|
|
81
|
+
"prerelease": prerelease,
|
|
82
|
+
}
|
|
83
|
+
if target_commitish is not None:
|
|
84
|
+
payload["target_commitish"] = target_commitish
|
|
85
|
+
release: dict[str, Any] = await client.post(
|
|
86
|
+
f"/repos/{owner}/{repo}/releases", json=payload
|
|
87
|
+
)
|
|
88
|
+
return release
|
gitea_mcp/tools/repos.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""MCP tools for Gitea repository metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Any
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
from gitea_mcp.client import GiteaAPIError
|
|
10
|
+
from gitea_mcp.server import get_client, mcp
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@mcp.tool()
|
|
14
|
+
async def list_repos(
|
|
15
|
+
owner: Annotated[
|
|
16
|
+
str | None,
|
|
17
|
+
Field(
|
|
18
|
+
description=(
|
|
19
|
+
"Username or organization to list repos for. "
|
|
20
|
+
"Leave empty to list repositories accessible to the authenticated user."
|
|
21
|
+
),
|
|
22
|
+
),
|
|
23
|
+
] = None,
|
|
24
|
+
page: Annotated[int, Field(description="Page number (1-indexed)")] = 1,
|
|
25
|
+
limit: Annotated[int, Field(description="Items per page (max 50)")] = 30,
|
|
26
|
+
) -> list[dict[str, Any]]:
|
|
27
|
+
"""List repositories.
|
|
28
|
+
|
|
29
|
+
Three modes:
|
|
30
|
+
|
|
31
|
+
- ``owner`` empty: returns repositories accessible to the authenticated user
|
|
32
|
+
(``GET /user/repos``).
|
|
33
|
+
- ``owner`` is a user: returns that user's repositories
|
|
34
|
+
(``GET /users/{owner}/repos``).
|
|
35
|
+
- ``owner`` is an organization: returns that org's repositories
|
|
36
|
+
(``GET /orgs/{owner}/repos`` — automatically tried as a fallback when the
|
|
37
|
+
user endpoint 404s, so callers don't need to know which it is).
|
|
38
|
+
"""
|
|
39
|
+
client = get_client()
|
|
40
|
+
params: dict[str, Any] = {"page": page, "limit": limit}
|
|
41
|
+
|
|
42
|
+
result: list[dict[str, Any]]
|
|
43
|
+
if not owner:
|
|
44
|
+
result = await client.get("/user/repos", params=params)
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
result = await client.get(f"/users/{owner}/repos", params=params)
|
|
49
|
+
return result
|
|
50
|
+
except GiteaAPIError as e:
|
|
51
|
+
if e.status_code == 404:
|
|
52
|
+
# Owner is likely an organization — fall back transparently.
|
|
53
|
+
result = await client.get(f"/orgs/{owner}/repos", params=params)
|
|
54
|
+
return result
|
|
55
|
+
raise
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@mcp.tool()
|
|
59
|
+
async def list_labels(
|
|
60
|
+
owner: Annotated[str, Field(description="Repository owner")],
|
|
61
|
+
repo: Annotated[str, Field(description="Repository name")],
|
|
62
|
+
page: Annotated[int, Field(description="Page number (1-indexed)")] = 1,
|
|
63
|
+
limit: Annotated[int, Field(description="Items per page (max 50)")] = 30,
|
|
64
|
+
) -> list[dict[str, Any]]:
|
|
65
|
+
"""List labels defined in a repository.
|
|
66
|
+
|
|
67
|
+
Returns the standard Gitea Label object: ``{id, name, color, description, ...}``.
|
|
68
|
+
Use the ``id`` values when calling tools that take ``label_ids`` directly;
|
|
69
|
+
most tools accept label *names* and resolve to IDs internally.
|
|
70
|
+
"""
|
|
71
|
+
client = get_client()
|
|
72
|
+
labels: list[dict[str, Any]] = await client.get(
|
|
73
|
+
f"/repos/{owner}/{repo}/labels",
|
|
74
|
+
params={"page": page, "limit": limit},
|
|
75
|
+
)
|
|
76
|
+
return labels
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@mcp.tool()
|
|
80
|
+
async def list_milestones(
|
|
81
|
+
owner: Annotated[str, Field(description="Repository owner")],
|
|
82
|
+
repo: Annotated[str, Field(description="Repository name")],
|
|
83
|
+
state: Annotated[
|
|
84
|
+
str, Field(description="Filter by state: 'open', 'closed', or 'all'")
|
|
85
|
+
] = "open",
|
|
86
|
+
page: Annotated[int, Field(description="Page number (1-indexed)")] = 1,
|
|
87
|
+
limit: Annotated[int, Field(description="Items per page (max 50)")] = 30,
|
|
88
|
+
) -> list[dict[str, Any]]:
|
|
89
|
+
"""List milestones in a repository, optionally filtered by state."""
|
|
90
|
+
client = get_client()
|
|
91
|
+
milestones: list[dict[str, Any]] = await client.get(
|
|
92
|
+
f"/repos/{owner}/{repo}/milestones",
|
|
93
|
+
params={"state": state, "page": page, "limit": limit},
|
|
94
|
+
)
|
|
95
|
+
return milestones
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gitea-mcp
|
|
3
|
+
Version: 0.1.1.dev0
|
|
4
|
+
Summary: Model Context Protocol server for Gitea (and Forgejo, Codeberg).
|
|
5
|
+
Author-email: Sam Ware <samuel@waretech.services>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/werebear73/gitea-mcp
|
|
8
|
+
Project-URL: Issues, https://github.com/werebear73/gitea-mcp/issues
|
|
9
|
+
Project-URL: Source, https://github.com/werebear73/gitea-mcp
|
|
10
|
+
Keywords: mcp,gitea,forgejo,codeberg,model-context-protocol,llm,ai
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: fastmcp>=2.0.0
|
|
25
|
+
Requires-Dist: httpx>=0.27.0
|
|
26
|
+
Requires-Dist: pydantic>=2.0.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-httpx>=0.30; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
32
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# gitea-mcp
|
|
36
|
+
|
|
37
|
+
A [Model Context Protocol](https://modelcontextprotocol.io) server for [Gitea](https://gitea.io) — lets AI assistants (Claude, ChatGPT, Copilot, and anything else that speaks MCP) read, create, and manage issues, repositories, and releases on any Gitea instance you can reach.
|
|
38
|
+
|
|
39
|
+
Also works against **[Forgejo](https://forgejo.org)** and **[Codeberg](https://codeberg.org)** (API-compatible).
|
|
40
|
+
|
|
41
|
+
## Why
|
|
42
|
+
|
|
43
|
+
Self-hosted Gitea is a popular GitHub alternative for solo developers, small teams, and privacy-conscious organizations. With this MCP server installed, your AI assistant can:
|
|
44
|
+
|
|
45
|
+
- File audit findings or refactor notes as Gitea issues without you leaving the chat
|
|
46
|
+
- Triage a repo's open issues in natural language
|
|
47
|
+
- Cut a release at the end of a coding session
|
|
48
|
+
- Comment on issues across multiple repos in one pass
|
|
49
|
+
|
|
50
|
+
## Features
|
|
51
|
+
|
|
52
|
+
| Resource | Tools |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| Issues | `create_issue`, `list_issues`, `get_issue`, `update_issue`, `add_comment` |
|
|
55
|
+
| Repos | `list_repos`, `list_labels`, `list_milestones` |
|
|
56
|
+
| Releases | `list_releases`, `create_release` |
|
|
57
|
+
|
|
58
|
+
- Bearer authentication via Personal Access Token (PAT)
|
|
59
|
+
- Async HTTP via `httpx` and `FastMCP`
|
|
60
|
+
- Works with self-hosted Gitea, Forgejo, and Codeberg
|
|
61
|
+
|
|
62
|
+
## Quick Start
|
|
63
|
+
|
|
64
|
+
### 1. Install
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install gitea-mcp
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Or with [`uv`](https://docs.astral.sh/uv/):
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
uv pip install gitea-mcp
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 2. Generate a Personal Access Token
|
|
77
|
+
|
|
78
|
+
In your Gitea instance, go to **Settings → Applications → Generate New Token** and grant at least:
|
|
79
|
+
|
|
80
|
+
- `read:repository`
|
|
81
|
+
- `write:issue`
|
|
82
|
+
- `read:user`
|
|
83
|
+
|
|
84
|
+
Add `write:repository` if you also want to create releases.
|
|
85
|
+
|
|
86
|
+
### 3. Configure your MCP client
|
|
87
|
+
|
|
88
|
+
Add `gitea-mcp` to your MCP client configuration:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"mcpServers": {
|
|
93
|
+
"gitea": {
|
|
94
|
+
"command": "gitea-mcp",
|
|
95
|
+
"env": {
|
|
96
|
+
"GITEA_URL": "https://your-gitea-instance.example.com",
|
|
97
|
+
"GITEA_TOKEN": "your-personal-access-token"
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
See [`mcp.json`](mcp.json) for a complete example. The same shape works for Claude Desktop, VS Code, Cowork, Claude Code, and any other MCP-compatible client.
|
|
105
|
+
|
|
106
|
+
## Configuration
|
|
107
|
+
|
|
108
|
+
Configuration is read from environment variables.
|
|
109
|
+
|
|
110
|
+
| Variable | Required | Default | Description |
|
|
111
|
+
| --- | --- | --- | --- |
|
|
112
|
+
| `GITEA_URL` | Yes | — | Base URL of your Gitea instance (e.g., `https://gitea.example.com`) |
|
|
113
|
+
| `GITEA_TOKEN` | Yes | — | Personal Access Token from your Gitea user settings |
|
|
114
|
+
| `GITEA_TIMEOUT` | No | `30` | HTTP request timeout in seconds |
|
|
115
|
+
|
|
116
|
+
## Compatibility
|
|
117
|
+
|
|
118
|
+
| Server | Status |
|
|
119
|
+
| --- | --- |
|
|
120
|
+
| Gitea (self-hosted) | ✅ Primary target |
|
|
121
|
+
| Forgejo | ✅ Expected to work (API-compatible) |
|
|
122
|
+
| Codeberg | ✅ Expected to work (Codeberg runs Forgejo) |
|
|
123
|
+
|
|
124
|
+
## Development
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
git clone https://github.com/werebear73/gitea-mcp.git
|
|
128
|
+
cd gitea-mcp
|
|
129
|
+
pip install -e ".[dev]"
|
|
130
|
+
pytest
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Versioning
|
|
134
|
+
|
|
135
|
+
Semantic versioning, derived from git tags via `setuptools_scm`. See [`VERSIONING.md`](VERSIONING.md) for the release process.
|
|
136
|
+
|
|
137
|
+
## Contributing
|
|
138
|
+
|
|
139
|
+
Issues and pull requests welcome. For substantial changes, please open an issue first to discuss the approach.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
[MIT](LICENSE) — use it however you like, including commercial products.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
Built by [Waretech Services](https://waretech.services).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
gitea_mcp/__init__.py,sha256=bZFvKlD5VDuhyzuInqhYGOEhcfK2BT4I3q-OcuSHnCk,295
|
|
2
|
+
gitea_mcp/_version.py,sha256=oPswspFxAXNlLuTZyMi9XRnaPJZToIRg6GOAdbA5WuM,27
|
|
3
|
+
gitea_mcp/client.py,sha256=qaLWrGmTCa2331gAuVzmY_cm3sBfkXIzr_Mrx9iAp7U,3350
|
|
4
|
+
gitea_mcp/config.py,sha256=ozY7Gp-K0jVLHto6OyA2F2A53t-H90e-S1U4Y3aNUUY,1539
|
|
5
|
+
gitea_mcp/server.py,sha256=2V_CgDK-Z3lPO_XcsUOxWbrjpTLdp4jJBH_QS18IXjM,1854
|
|
6
|
+
gitea_mcp/tools/__init__.py,sha256=2G_XMRLzAB4wLjVBawMuVxSYhlu2wWi8AkzbLTHJxXM,163
|
|
7
|
+
gitea_mcp/tools/issues.py,sha256=azN3aHXHIeAw9QhCLaSEOkgV9LjDOn6NpcAz74qv3xc,8802
|
|
8
|
+
gitea_mcp/tools/releases.py,sha256=zvWzm2JjTbUIQW8SZSSBjhMiqcEo-At3TMlJnAPX1DI,2826
|
|
9
|
+
gitea_mcp/tools/repos.py,sha256=d_NeJG-l7Y8PqVeuLYiUHNfOP68dPJ_rGJAs__CCbco,3378
|
|
10
|
+
gitea_mcp-0.1.1.dev0.dist-info/licenses/LICENSE,sha256=-haC-gxLVoaxywPnh135rjt4qb55WDqOp7n3-I6STns,1065
|
|
11
|
+
gitea_mcp-0.1.1.dev0.dist-info/METADATA,sha256=184_kNsj-AgZIQJpymdSQbeedsVcO3mohyoniyYFd1Y,4684
|
|
12
|
+
gitea_mcp-0.1.1.dev0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
gitea_mcp-0.1.1.dev0.dist-info/entry_points.txt,sha256=PjRHlfQDMINFlBt4maFz_PWZ0p8t38Rw3br6-w-hZis,52
|
|
14
|
+
gitea_mcp-0.1.1.dev0.dist-info/top_level.txt,sha256=anwGYTKslQDgerLVaH7Ded7ivajJbXfUAjcnmZeslWk,10
|
|
15
|
+
gitea_mcp-0.1.1.dev0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sam Ware
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gitea_mcp
|