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 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,5 @@
1
+ """MCP tool definitions, organized by Gitea resource family.
2
+
3
+ Each submodule registers its tools against the FastMCP instance defined in
4
+ ``gitea_mcp.server``.
5
+ """
@@ -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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gitea-mcp = gitea_mcp.server:main
@@ -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