Initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
# Local credentials
|
||||||
|
gitea.env
|
||||||
|
|
||||||
|
# Python runtime artifacts
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Gitea MCP 本机备份
|
||||||
|
|
||||||
|
备份日期:2026-08-07
|
||||||
|
|
||||||
|
## 内容
|
||||||
|
|
||||||
|
- `gitea-mcp.ps1`:Codex 当前实际使用的包装脚本,原路径为 `%USERPROFILE%\.codex\scripts\gitea-mcp.ps1`。
|
||||||
|
- `gitea.env.example`:不含真实凭据的 Gitea 配置模板。
|
||||||
|
- `gitea.env`:本机私有 Gitea 配置(被 Git 忽略),原路径为 `%USERPROFILE%\.codex\gitea.env`。
|
||||||
|
- `package/gitea_mcp/`:本机维护的 `gitea-mcp 0.5.2.dev4` Python 运行包,包含 Gitea Wiki 读写工具,不含 `__pycache__`。
|
||||||
|
- `package/gitea_mcp-0.5.2.dev4.dist-info/`:对应版本的 Wheel 安装元数据。
|
||||||
|
- `reference/gitea-mcp-hardened.ps1`:`harness_coding_docs` 中固定 `0.5.1`、支持显式 HTTP 风险开关的通用加固版包装脚本。
|
||||||
|
|
||||||
|
## 恢复
|
||||||
|
|
||||||
|
1. 把 `gitea-mcp.ps1` 复制到 `%USERPROFILE%\.codex\scripts\gitea-mcp.ps1`。
|
||||||
|
2. 参考 `gitea.env.example` 创建 `%USERPROFILE%\.codex\gitea.env`,填入实际地址和 Token;如有独立的安全备份,也可直接恢复该文件。
|
||||||
|
3. 确认 Codex `config.toml` 的 Gitea MCP 命令仍指向上述包装脚本。
|
||||||
|
4. 确认本机维护仓库及其虚拟环境位于 `D:\chengma\gitea-mcp`,或按实际位置修改包装脚本中的 `giteaMcpExecutable`。
|
||||||
|
5. 通过包装脚本执行连接检查,并确认工具列表包含 6 个 Wiki 工具。
|
||||||
|
|
||||||
|
## 安全提示
|
||||||
|
|
||||||
|
`gitea.env` 含明文 Token,已通过 `.gitignore` 排除。不要强制提交、同步到公共云盘或发送给其他人;Token 轮换后应更新独立的安全备份。
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$envFile = Join-Path (Split-Path -Parent $PSScriptRoot) 'gitea.env'
|
||||||
|
if (-not (Test-Path -LiteralPath $envFile -PathType Leaf)) {
|
||||||
|
throw "Gitea MCP configuration file not found: $envFile"
|
||||||
|
}
|
||||||
|
|
||||||
|
$values = @{}
|
||||||
|
foreach ($rawLine in Get-Content -LiteralPath $envFile) {
|
||||||
|
$line = $rawLine.Trim()
|
||||||
|
if (-not $line -or $line.StartsWith('#')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = $line -split '=', 2
|
||||||
|
if ($parts.Count -ne 2) {
|
||||||
|
throw "Invalid line in $envFile. Use KEY=VALUE format."
|
||||||
|
}
|
||||||
|
|
||||||
|
$values[$parts[0].Trim()] = $parts[1].Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($name in @('GITEA_URL', 'GITEA_TOKEN')) {
|
||||||
|
if (-not $values.ContainsKey($name) -or [string]::IsNullOrWhiteSpace($values[$name])) {
|
||||||
|
throw "$name is missing or empty in $envFile"
|
||||||
|
}
|
||||||
|
Set-Item -Path "Env:$name" -Value $values[$name]
|
||||||
|
}
|
||||||
|
|
||||||
|
# This self-hosted Gitea instance must not be sent through the system SOCKS proxy.
|
||||||
|
$noProxyHost = 'ilaer.eicp.net'
|
||||||
|
$existingNoProxy = @($env:NO_PROXY, $env:no_proxy) |
|
||||||
|
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
|
||||||
|
Select-Object -First 1
|
||||||
|
$noProxyEntries = @($existingNoProxy -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||||
|
if ($noProxyEntries -notcontains $noProxyHost) {
|
||||||
|
$noProxyEntries += $noProxyHost
|
||||||
|
}
|
||||||
|
$env:NO_PROXY = $noProxyEntries -join ','
|
||||||
|
$env:no_proxy = $env:NO_PROXY
|
||||||
|
|
||||||
|
# httpx initializes configured SOCKS transports before evaluating NO_PROXY.
|
||||||
|
# Clear proxy variables only for this MCP child process so it connects directly.
|
||||||
|
foreach ($proxyVariable in @('ALL_PROXY', 'all_proxy', 'HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy')) {
|
||||||
|
Remove-Item -Path "Env:$proxyVariable" -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run the locally maintained build so custom Wiki tools are not replaced by
|
||||||
|
# uvx resolving the latest published package on the next process start.
|
||||||
|
$giteaMcpExecutable = 'D:\chengma\gitea-mcp\.venv\Scripts\gitea-mcp.exe'
|
||||||
|
if (-not (Test-Path -LiteralPath $giteaMcpExecutable -PathType Leaf)) {
|
||||||
|
throw "Local Gitea MCP executable not found: $giteaMcpExecutable"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep stdout exclusively for MCP JSON-RPC. FastMCP diagnostics are noisy on stderr.
|
||||||
|
& $giteaMcpExecutable 2>> (Join-Path $PSScriptRoot ("gitea-mcp-$PID.stderr.log"))
|
||||||
|
exit $LASTEXITCODE
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
GITEA_URL=https://gitea.example.com
|
||||||
|
GITEA_TOKEN=your-personal-access-token
|
||||||
|
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: gitea-mcp
|
||||||
|
Version: 0.5.2.dev4
|
||||||
|
Summary: Model Context Protocol server for Gitea (and Forgejo, Codeberg).
|
||||||
|
Author-email: Sam Ware <samuel@waretech.services>
|
||||||
|
License: MIT
|
||||||
|
Project-URL: Homepage, https://github.com/werebear73/gitea-mcp
|
||||||
|
Project-URL: Issues, https://github.com/werebear73/gitea-mcp/issues
|
||||||
|
Project-URL: Source, https://github.com/werebear73/gitea-mcp
|
||||||
|
Keywords: mcp,gitea,forgejo,codeberg,model-context-protocol,llm,ai
|
||||||
|
Classifier: Development Status :: 3 - Alpha
|
||||||
|
Classifier: Intended Audience :: Developers
|
||||||
|
Classifier: License :: OSI Approved :: MIT License
|
||||||
|
Classifier: Operating System :: OS Independent
|
||||||
|
Classifier: Programming Language :: Python :: 3
|
||||||
|
Classifier: Programming Language :: Python :: 3.11
|
||||||
|
Classifier: Programming Language :: Python :: 3.12
|
||||||
|
Classifier: Programming Language :: Python :: 3.13
|
||||||
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
||||||
|
Classifier: Topic :: Software Development :: Version Control :: Git
|
||||||
|
Requires-Python: >=3.11
|
||||||
|
Description-Content-Type: text/markdown
|
||||||
|
License-File: LICENSE
|
||||||
|
Requires-Dist: fastmcp<3.0,>=2.0
|
||||||
|
Requires-Dist: httpx>=0.27.0
|
||||||
|
Requires-Dist: pydantic>=2.0.0
|
||||||
|
Provides-Extra: dev
|
||||||
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
||||||
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
||||||
|
Requires-Dist: pytest-httpx>=0.30; extra == "dev"
|
||||||
|
Requires-Dist: ruff>=0.5; extra == "dev"
|
||||||
|
Requires-Dist: mypy>=1.10; extra == "dev"
|
||||||
|
Requires-Dist: pre-commit>=3.5; extra == "dev"
|
||||||
|
Requires-Dist: build>=1.0; extra == "dev"
|
||||||
|
Requires-Dist: twine>=5.0; extra == "dev"
|
||||||
|
Dynamic: license-file
|
||||||
|
|
||||||
|
# gitea-mcp
|
||||||
|
|
||||||
|
<!-- mcp-name: io.github.werebear73/gitea-mcp -->
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Also works against **[Forgejo](https://forgejo.org)** and **[Codeberg](https://codeberg.org)** (API-compatible).
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
- File audit findings or refactor notes as Gitea issues without you leaving the chat
|
||||||
|
- Triage a repo's open issues in natural language
|
||||||
|
- Cut a release at the end of a coding session
|
||||||
|
- Comment on issues across multiple repos in one pass
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| Resource | Tools |
|
||||||
|
| --- | --- |
|
||||||
|
| Issues | `create_issue`, `list_issues`, `get_issue`, `update_issue`, `add_comment` |
|
||||||
|
| Repos | `list_repos`, `list_labels`, `list_milestones`, `list_branches` |
|
||||||
|
| Pulls | `list_pull_requests`, `get_pull_request`, `add_comment_on_pr`, `create_pr`, `merge_pr` |
|
||||||
|
| Files | `read_file`, `commit_changes`, `create_branch` |
|
||||||
|
| Releases | `list_releases`, `create_release` |
|
||||||
|
| Wiki | `list_wiki_pages`, `get_wiki_page`, `list_wiki_revisions`, `create_wiki_page`, `update_wiki_page`, `delete_wiki_page` |
|
||||||
|
| Meta | `get_server_info`, `get_server_version` |
|
||||||
|
|
||||||
|
- Bearer authentication via Personal Access Token (PAT)
|
||||||
|
- Async HTTP via `httpx` and `FastMCP`
|
||||||
|
- Works with self-hosted Gitea, Forgejo, and Codeberg
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install gitea-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with [`uv`](https://docs.astral.sh/uv/):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv pip install gitea-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Generate a Personal Access Token
|
||||||
|
|
||||||
|
In your Gitea instance, go to **Settings → Applications → Generate New Token** and grant at least:
|
||||||
|
|
||||||
|
- `read:repository`
|
||||||
|
- `write:issue`
|
||||||
|
- `read:user`
|
||||||
|
|
||||||
|
Add `write:repository` if you also want to create releases or write wiki pages.
|
||||||
|
|
||||||
|
### 3. Configure your MCP client
|
||||||
|
|
||||||
|
**Claude Desktop (interactive):** run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitea-mcp init
|
||||||
|
```
|
||||||
|
|
||||||
|
It prompts for the Gitea URL and Personal Access Token, verifies the connection, and writes (or merges into) the right `claude_desktop_config.json` for your OS. Restart Claude Desktop and you're done.
|
||||||
|
|
||||||
|
To check that the server can reach your Gitea instance at any time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitea-mcp doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
`doctor` reads `GITEA_URL` and `GITEA_TOKEN` from the environment, runs a `GET /api/v1/user`, and reports the authenticated username plus the state of the MCP tool surface. Exit `0` = ready; exit `1` = connection/load failure; exit `2` = missing config.
|
||||||
|
|
||||||
|
**Any MCP client (manual):** add `gitea-mcp` to the client's MCP config. The recommended form uses `uvx` so the client launches the latest published wheel in an isolated env without needing `gitea-mcp` on its own PATH (this is what `gitea-mcp init` writes):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"gitea": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["gitea-mcp"],
|
||||||
|
"env": {
|
||||||
|
"GITEA_URL": "https://your-gitea-instance.example.com",
|
||||||
|
"GITEA_TOKEN": "your-personal-access-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If you'd rather use a globally pip-installed `gitea-mcp` binary, drop `args` and set `command` to `"gitea-mcp"` directly — works as long as the binary is on the MCP client's PATH at launch time.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configuration is read from environment variables.
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `GITEA_URL` | Yes | — | Base URL of your Gitea instance (e.g., `https://gitea.example.com`) |
|
||||||
|
| `GITEA_TOKEN` | Yes | — | Personal Access Token from your Gitea user settings |
|
||||||
|
| `GITEA_TIMEOUT` | No | `30` | HTTP request timeout in seconds |
|
||||||
|
| `GITEA_MAX_RETRIES` | No | `3` | Max retries for transient failures on idempotent methods (`GET`/`PUT`/`DELETE`). Set to `0` to disable retries. `POST` and `PATCH` are never auto-retried — they could create duplicate issues, comments, or releases. `429 Too Many Requests` is retried for **any** method, honoring `Retry-After` when present. |
|
||||||
|
| `GITEA_RETRY_BASE_DELAY` | No | `0.5` | Base delay (seconds) for exponential backoff between retries. Effective delay grows as `base * 2^attempt` with jitter, capped at 4 seconds. |
|
||||||
|
|
||||||
|
## Self-hosting / HTTP transport
|
||||||
|
|
||||||
|
By default `gitea-mcp` runs in stdio mode — each MCP client (Claude Desktop, Cowork, etc.) launches its own subprocess on demand. For self-hosting one instance that multiple clients connect to over the network, use the streamable-HTTP transport:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitea-mcp serve --transport http --host 0.0.0.0 --port 8000 --path /mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
All four flags can also be provided via environment variables (handy for Docker / systemd units):
|
||||||
|
|
||||||
|
| Variable | Default | Flag |
|
||||||
|
| ------------------------- | ------------ | -------------- |
|
||||||
|
| `GITEA_MCP_TRANSPORT` | `stdio` | `--transport` |
|
||||||
|
| `GITEA_MCP_HOST` | `127.0.0.1` | `--host` |
|
||||||
|
| `GITEA_MCP_PORT` | `8000` | `--port` |
|
||||||
|
| `GITEA_MCP_PATH` | `/mcp` | `--path` |
|
||||||
|
|
||||||
|
MCP clients connect to the resulting URL (e.g. `https://gitea-mcp.example.com/mcp`) just like they would to a local stdio server, except they share the one running instance.
|
||||||
|
|
||||||
|
**Auth model (this release).** The server reads `GITEA_TOKEN` from its own environment, so any client that reaches the URL acts as that one Gitea user. Run it for yourself behind your own access controls (firewall, reverse-proxy auth, VPN, Tailscale). Multi-tenant bring-your-own-token is on the roadmap.
|
||||||
|
|
||||||
|
The no-args invocation (`gitea-mcp` with no subcommand) still runs in stdio mode, so existing Claude Desktop / Cowork / Claude Code integrations are unaffected by this addition.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
| Server | Status |
|
||||||
|
| --- | --- |
|
||||||
|
| Gitea (self-hosted) | ✅ Primary target |
|
||||||
|
| Forgejo | ✅ Expected to work (API-compatible) |
|
||||||
|
| Codeberg | ✅ Expected to work (Codeberg runs Forgejo) |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/werebear73/gitea-mcp.git
|
||||||
|
cd gitea-mcp
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
pre-commit install # commit-stage hooks (ruff + mypy)
|
||||||
|
pre-commit install --hook-type pre-push # push-stage hooks (pytest + build check)
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
The two-stage pre-commit policy keeps the commit loop snappy (lint + type only) while making `git push` block on the slow stuff that's actually caught CI/release bugs in the past — the full test suite and `python -m build && twine check dist/*`, which surfaces `setuptools_scm` version surprises before they reach a tag push.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
See [`docs/ROADMAP.md`](docs/ROADMAP.md) for what's shipped, what's next, and what's out of scope.
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
- MCP Registry metadata is tracked in [`server.json`](server.json).
|
||||||
|
- Smithery + MCP Registry publication steps are documented in [`docs/PUBLISHING.md`](docs/PUBLISHING.md).
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
Semantic versioning, derived from git tags via `setuptools_scm`. See [`VERSIONING.md`](VERSIONING.md) for the release process.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Issues and pull requests welcome. For substantial changes, please open an issue first to discuss the approach.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE) — use it however you like, including commercial products.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Built by [Waretech Services](https://waretech.services).
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
gitea_mcp/__init__.py,sha256=9KYUxsefJXdcgx13lOdMKDTwtqW39J0oxAcNl4xM7n8,304
|
||||||
|
gitea_mcp/_app.py,sha256=mNYrUKQi-o9DpkFJQLwUmM421Kj0EZBBdmOREFxifOU,1852
|
||||||
|
gitea_mcp/_version.py,sha256=2JFIzAqJlZTk7JhAGeHGs4qJryKff5sFiNkbAeDnhEI,28
|
||||||
|
gitea_mcp/client.py,sha256=eqSJFOFIPX6DlTVLzG0phHeAP41EorbfMMnGbDy0WL8,12123
|
||||||
|
gitea_mcp/config.py,sha256=2QrZ7A5ZEI2QckWpc8HL8rJ5cAHx8k9L8QRD-24IlVw,2218
|
||||||
|
gitea_mcp/doctor.py,sha256=5FGCyGwP6it-u3tsH0IV87T1lOzkg0BwGLefJgRmhOs,3707
|
||||||
|
gitea_mcp/init.py,sha256=7X5vE84Q-H-CUE189zZXy3ru7BDUDWwM1_-URw0XjBQ,11373
|
||||||
|
gitea_mcp/serve.py,sha256=0Un3iDYHnetwNKcXAQzABuCKtJRNqx2muEuZ7zToFmw,4920
|
||||||
|
gitea_mcp/server.py,sha256=v63QSvmMyCefR9pjdYnrivGm-I1Tc1KZHsFHvUkjk0I,4711
|
||||||
|
gitea_mcp/tools/__init__.py,sha256=XyQ5iUrz5alYHvWUH03LcGQ1dFJ_1aZ5lhof8XU8_K0,166
|
||||||
|
gitea_mcp/tools/files.py,sha256=jV0RRlRCdNMi1DiOW2L2zp_i989bi3XTZCVger8rR-8,8612
|
||||||
|
gitea_mcp/tools/issues.py,sha256=gVSFSzELoXh0-F8ceZGcvoLAI1vr21meFhowcI10APQ,9656
|
||||||
|
gitea_mcp/tools/pulls.py,sha256=bZvySbFta7QiykMvVwOFWN71XFbKFKtwns2EpOHxgp4,7137
|
||||||
|
gitea_mcp/tools/releases.py,sha256=i37PQ_AUz8zMmj4NvDK1eIbQQMqZUE-xvbA9fR0y8w0,3306
|
||||||
|
gitea_mcp/tools/repos.py,sha256=od_xe3RHxgRN3bexRWUOW27OnQXIegIbGGXODt3GcN0,3423
|
||||||
|
gitea_mcp/tools/server_info.py,sha256=PO8RmNwE7ENHcFon5ijvvZQV3pd-xjggXKLtWgrhAx0,3279
|
||||||
|
gitea_mcp/tools/wiki.py,sha256=7h2CqK6E-FrJUGc1SG2QQbBxBY8hsMG2OBInWfP9EH0,9759
|
||||||
|
gitea_mcp-0.5.2.dev4.dist-info/licenses/LICENSE,sha256=d23Qx0gy7rKXSx1QjoTq_i7-OBXtQNdQCzShSPgqiAI,1086
|
||||||
|
gitea_mcp-0.5.2.dev4.dist-info/METADATA,sha256=wHZq5T9yVUCb9nLInn-kmOVr_NBHw6K1ozbSRW31XKs,9329
|
||||||
|
gitea_mcp-0.5.2.dev4.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
||||||
|
gitea_mcp-0.5.2.dev4.dist-info/entry_points.txt,sha256=PjRHlfQDMINFlBt4maFz_PWZ0p8t38Rw3br6-w-hZis,52
|
||||||
|
gitea_mcp-0.5.2.dev4.dist-info/top_level.txt,sha256=anwGYTKslQDgerLVaH7Ded7ivajJbXfUAjcnmZeslWk,10
|
||||||
|
gitea_mcp-0.5.2.dev4.dist-info/RECORD,,
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Wheel-Version: 1.0
|
||||||
|
Generator: setuptools (83.0.0)
|
||||||
|
Root-Is-Purelib: true
|
||||||
|
Tag: py3-none-any
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[console_scripts]
|
||||||
|
gitea-mcp = gitea_mcp.server:main
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Sam Ware
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
gitea_mcp
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""gitea-mcp — Model Context Protocol server for Gitea (and Forgejo, Codeberg)."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from gitea_mcp._version import __version__
|
||||||
|
except ImportError:
|
||||||
|
# Package not installed in editable mode or version file not yet generated
|
||||||
|
__version__ = "0.0.0.dev0"
|
||||||
|
|
||||||
|
__all__ = ["__version__"]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Internal seam holding the FastMCP singleton and client slot.
|
||||||
|
|
||||||
|
This module exists so the ``FastMCP`` instance and the ``GiteaClient`` singleton
|
||||||
|
live in a module that is imported exactly once, regardless of how the package
|
||||||
|
entry point is launched.
|
||||||
|
|
||||||
|
Why this matters: when a user runs ``python -m gitea_mcp.server``, Python loads
|
||||||
|
``server.py`` as ``__main__``. Any module that later imports
|
||||||
|
``gitea_mcp.server`` (e.g. one of the tool modules) causes Python to load
|
||||||
|
``server.py`` a *second* time, registered under its real name. If the
|
||||||
|
``FastMCP`` instance lived in ``server.py`` the tool decorators would register
|
||||||
|
against the second instance while the entry point's ``mcp.run()`` ran the
|
||||||
|
first — empty tools list, no errors. Keeping the singleton here means both
|
||||||
|
loads see the same ``mcp`` object.
|
||||||
|
|
||||||
|
Tool modules should always import from here, not from ``gitea_mcp.server``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
from gitea_mcp.client import GiteaClient
|
||||||
|
|
||||||
|
# Module-level FastMCP instance. Tool modules register against this via
|
||||||
|
# ``@mcp.tool()`` at import time.
|
||||||
|
mcp: FastMCP = FastMCP("gitea-mcp")
|
||||||
|
|
||||||
|
# Singleton client populated at startup by ``gitea_mcp.server.main()``.
|
||||||
|
# Tool modules access it via ``get_client()``.
|
||||||
|
_client: GiteaClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_client() -> GiteaClient:
|
||||||
|
"""Return the singleton :class:`GiteaClient`.
|
||||||
|
|
||||||
|
Must be called after :func:`gitea_mcp.server.main` has initialized the
|
||||||
|
client. Tool functions call this at request time, not at import time.
|
||||||
|
"""
|
||||||
|
if _client is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"GiteaClient not initialized. The gitea-mcp server must be started "
|
||||||
|
"via the gitea-mcp entry point so the client is available before "
|
||||||
|
"any tools are called."
|
||||||
|
)
|
||||||
|
return _client
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
__version__ = '0.5.2.dev4'
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
"""Async HTTP client wrapper for the Gitea REST API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# HTTP methods that are safe to retry on transient failures.
|
||||||
|
# POST and PATCH are excluded because retrying them could create duplicate
|
||||||
|
# issues / comments / releases — better to surface the failure to the caller.
|
||||||
|
_IDEMPOTENT_METHODS = frozenset({"GET", "PUT", "DELETE"})
|
||||||
|
|
||||||
|
# HTTP status codes that indicate transient server-side issues worth retrying
|
||||||
|
# (for idempotent methods only).
|
||||||
|
_RETRYABLE_STATUS_CODES = frozenset({502, 503, 504})
|
||||||
|
|
||||||
|
# Maximum backoff between retries, in seconds.
|
||||||
|
_MAX_BACKOFF_DELAY = 4.0
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaError(Exception):
|
||||||
|
"""Base exception for Gitea client errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaAPIError(GiteaError):
|
||||||
|
"""Raised when the Gitea API returns a non-success response."""
|
||||||
|
|
||||||
|
def __init__(self, status_code: int, message: str, method: str, url: str) -> None:
|
||||||
|
self.status_code = status_code
|
||||||
|
self.method = method
|
||||||
|
self.url = url
|
||||||
|
super().__init__(f"[{method} {url}] {status_code}: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaClient:
|
||||||
|
"""Async HTTP client for the Gitea REST API.
|
||||||
|
|
||||||
|
Uses Personal Access Token authentication via the
|
||||||
|
``Authorization: token <PAT>`` header (Gitea's convention; NOT Bearer).
|
||||||
|
One shared :class:`httpx.AsyncClient` per server lifetime.
|
||||||
|
|
||||||
|
All paths passed to the verb methods are appended under ``/api/v1``; pass
|
||||||
|
``/repos/{owner}/{repo}`` rather than the full URL.
|
||||||
|
|
||||||
|
Two layers of verb methods:
|
||||||
|
|
||||||
|
* **Untyped verbs** (``get``, ``post``, ``patch``, ``put``, ``delete``)
|
||||||
|
return the decoded JSON as :class:`typing.Any`. Use when the caller
|
||||||
|
doesn't care about the shape, or when the shape varies.
|
||||||
|
* **Typed verbs** (``get_json``, ``get_list``, ``post_json``,
|
||||||
|
``patch_json``, ``put_json``, ``put_list``) wrap the untyped verbs
|
||||||
|
with a shape assertion and return ``dict[str, Any]`` or
|
||||||
|
``list[dict[str, Any]]``. Use these in tool implementations so the
|
||||||
|
return type flows cleanly to the tool's annotated return without
|
||||||
|
needing a typed-intermediate variable (the pattern PR #4 had to
|
||||||
|
apply to every tool to satisfy strict mypy).
|
||||||
|
|
||||||
|
**Retry policy** (applied by all verb methods):
|
||||||
|
|
||||||
|
* Idempotent methods (``GET``, ``PUT``, ``DELETE``) are retried on
|
||||||
|
transient network errors (``httpx.ConnectError`` /
|
||||||
|
``ReadTimeout`` / ``WriteTimeout``) and on 502 / 503 / 504 responses.
|
||||||
|
* ``POST`` and ``PATCH`` are **not** retried automatically — a retry
|
||||||
|
could create duplicate issues, comments, or releases.
|
||||||
|
* ``429 Too Many Requests`` is retried for **any** method, honoring the
|
||||||
|
``Retry-After`` header if present; otherwise using the same
|
||||||
|
exponential backoff schedule.
|
||||||
|
* Backoff is exponential with jitter, capped at ``_MAX_BACKOFF_DELAY``
|
||||||
|
seconds. Max attempts and base delay are constructor-configurable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
token: str,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
max_retries: int = 3,
|
||||||
|
retry_base_delay: float = 0.5,
|
||||||
|
) -> None:
|
||||||
|
self._base_url = base_url.rstrip("/")
|
||||||
|
self._max_retries = max_retries
|
||||||
|
self._retry_base_delay = retry_base_delay
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
base_url=self._base_url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"token {token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the underlying HTTP client. Safe to call multiple times."""
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_url(self) -> str:
|
||||||
|
"""The Gitea instance base URL this client is configured against.
|
||||||
|
|
||||||
|
Read-only — set at construction time. Useful for tools that report
|
||||||
|
server identity (e.g. ``get_server_info``).
|
||||||
|
"""
|
||||||
|
return self._base_url
|
||||||
|
|
||||||
|
# ---- Untyped verbs (return Any) ----------------------------------------
|
||||||
|
|
||||||
|
async def get(
|
||||||
|
self, path: str, params: dict[str, Any] | None = None
|
||||||
|
) -> Any:
|
||||||
|
response = await self._request_with_retry("GET", path, params=params)
|
||||||
|
return self._handle(response, method="GET", path=path)
|
||||||
|
|
||||||
|
async def post(self, path: str, json: Any | None = None) -> Any:
|
||||||
|
response = await self._request_with_retry("POST", path, json=json)
|
||||||
|
return self._handle(response, method="POST", path=path)
|
||||||
|
|
||||||
|
async def put(self, path: str, json: Any | None = None) -> Any:
|
||||||
|
response = await self._request_with_retry("PUT", path, json=json)
|
||||||
|
return self._handle(response, method="PUT", path=path)
|
||||||
|
|
||||||
|
async def patch(self, path: str, json: Any | None = None) -> Any:
|
||||||
|
response = await self._request_with_retry("PATCH", path, json=json)
|
||||||
|
return self._handle(response, method="PATCH", path=path)
|
||||||
|
|
||||||
|
async def delete(self, path: str) -> Any:
|
||||||
|
response = await self._request_with_retry("DELETE", path)
|
||||||
|
return self._handle(response, method="DELETE", path=path)
|
||||||
|
|
||||||
|
# ---- Typed verbs (shape-asserted) --------------------------------------
|
||||||
|
|
||||||
|
async def get_json(
|
||||||
|
self, path: str, params: dict[str, Any] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""GET and decode as a JSON object. Raises :class:`GiteaError` on
|
||||||
|
non-object response."""
|
||||||
|
return self._as_object(await self.get(path, params), method="GET", path=path)
|
||||||
|
|
||||||
|
async def get_list(
|
||||||
|
self, path: str, params: dict[str, Any] | None = None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""GET and decode as a JSON array of objects. Raises
|
||||||
|
:class:`GiteaError` on non-array response or non-object items."""
|
||||||
|
return self._as_list(await self.get(path, params), method="GET", path=path)
|
||||||
|
|
||||||
|
async def post_json(self, path: str, json: Any | None = None) -> dict[str, Any]:
|
||||||
|
"""POST and decode the response as a JSON object."""
|
||||||
|
return self._as_object(await self.post(path, json=json), method="POST", path=path)
|
||||||
|
|
||||||
|
async def patch_json(self, path: str, json: Any | None = None) -> dict[str, Any]:
|
||||||
|
"""PATCH and decode the response as a JSON object."""
|
||||||
|
return self._as_object(await self.patch(path, json=json), method="PATCH", path=path)
|
||||||
|
|
||||||
|
async def put_json(self, path: str, json: Any | None = None) -> dict[str, Any]:
|
||||||
|
"""PUT and decode the response as a JSON object."""
|
||||||
|
return self._as_object(await self.put(path, json=json), method="PUT", path=path)
|
||||||
|
|
||||||
|
async def put_list(
|
||||||
|
self, path: str, json: Any | None = None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""PUT and decode the response as a JSON array of objects.
|
||||||
|
|
||||||
|
Specific to Gitea's ``PUT /repos/{owner}/{repo}/issues/{n}/labels``,
|
||||||
|
which returns the new label list (an array) rather than the issue.
|
||||||
|
"""
|
||||||
|
return self._as_list(await self.put(path, json=json), method="PUT", path=path)
|
||||||
|
|
||||||
|
# ---- Retry & helpers ---------------------------------------------------
|
||||||
|
|
||||||
|
async def _request_with_retry(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
json: Any | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
"""Issue a request, retrying transient failures per the class policy.
|
||||||
|
|
||||||
|
Returns the final :class:`httpx.Response` (which may be a non-success
|
||||||
|
response — non-retryable errors and exhausted retries both return the
|
||||||
|
response so the caller's ``_handle`` can produce a proper
|
||||||
|
:class:`GiteaAPIError`).
|
||||||
|
"""
|
||||||
|
idempotent = method in _IDEMPOTENT_METHODS
|
||||||
|
url = self._api_path(path)
|
||||||
|
last_network_exc: Exception | None = None
|
||||||
|
|
||||||
|
for attempt in range(self._max_retries + 1):
|
||||||
|
try:
|
||||||
|
response = await self._client.request(
|
||||||
|
method, url, params=params, json=json
|
||||||
|
)
|
||||||
|
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as exc:
|
||||||
|
last_network_exc = exc
|
||||||
|
if not idempotent or attempt >= self._max_retries:
|
||||||
|
raise
|
||||||
|
await asyncio.sleep(self._backoff_delay(attempt))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 429 honored for ANY method — the server is asking us to slow down.
|
||||||
|
if response.status_code == 429 and attempt < self._max_retries:
|
||||||
|
retry_after = self._parse_retry_after(response)
|
||||||
|
delay = retry_after if retry_after is not None else self._backoff_delay(attempt)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Transient 5xx: retry only for idempotent methods.
|
||||||
|
if (
|
||||||
|
response.status_code in _RETRYABLE_STATUS_CODES
|
||||||
|
and idempotent
|
||||||
|
and attempt < self._max_retries
|
||||||
|
):
|
||||||
|
await asyncio.sleep(self._backoff_delay(attempt))
|
||||||
|
continue
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Loop body either returns a response or re-raises. Reaching here means
|
||||||
|
# the last iteration was a network exception that was already re-raised.
|
||||||
|
assert last_network_exc is not None
|
||||||
|
raise last_network_exc
|
||||||
|
|
||||||
|
def _backoff_delay(self, attempt: int) -> float:
|
||||||
|
"""Exponential backoff with jitter, capped at :data:`_MAX_BACKOFF_DELAY`."""
|
||||||
|
base: float = min(_MAX_BACKOFF_DELAY, self._retry_base_delay * (2**attempt))
|
||||||
|
jitter: float = random.uniform(0, base * 0.1)
|
||||||
|
result: float = base + jitter
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_retry_after(response: httpx.Response) -> float | None:
|
||||||
|
"""Parse the ``Retry-After`` header value (seconds-int form only).
|
||||||
|
|
||||||
|
Returns ``None`` if absent or in HTTP-date form (in which case the
|
||||||
|
caller falls back to its normal backoff schedule).
|
||||||
|
"""
|
||||||
|
raw = response.headers.get("retry-after")
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _api_path(path: str) -> str:
|
||||||
|
"""Prefix a relative path with /api/v1, leaving absolute API paths intact."""
|
||||||
|
if path.startswith("/api/v1"):
|
||||||
|
return path
|
||||||
|
if path.startswith("/"):
|
||||||
|
return f"/api/v1{path}"
|
||||||
|
return f"/api/v1/{path}"
|
||||||
|
|
||||||
|
def _handle(self, response: httpx.Response, method: str, path: str) -> Any:
|
||||||
|
if response.is_success:
|
||||||
|
if response.status_code == 204 or not response.content:
|
||||||
|
return None
|
||||||
|
return response.json()
|
||||||
|
message = response.text.strip() or response.reason_phrase
|
||||||
|
raise GiteaAPIError(
|
||||||
|
status_code=response.status_code,
|
||||||
|
message=message,
|
||||||
|
method=method,
|
||||||
|
url=str(response.request.url),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_object(raw: Any, *, method: str, path: str) -> dict[str, Any]:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise GiteaError(
|
||||||
|
f"[{method} {path}] expected a JSON object response, got "
|
||||||
|
f"{type(raw).__name__}"
|
||||||
|
)
|
||||||
|
result: dict[str, Any] = raw
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_list(raw: Any, *, method: str, path: str) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise GiteaError(
|
||||||
|
f"[{method} {path}] expected a JSON array response, got "
|
||||||
|
f"{type(raw).__name__}"
|
||||||
|
)
|
||||||
|
for i, item in enumerate(raw):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise GiteaError(
|
||||||
|
f"[{method} {path}] expected a JSON array of objects, "
|
||||||
|
f"item {i} is a {type(item).__name__}"
|
||||||
|
)
|
||||||
|
result: list[dict[str, Any]] = raw
|
||||||
|
return result
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Configuration loaded from environment variables."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
"""Runtime configuration for gitea-mcp.
|
||||||
|
|
||||||
|
Loaded once at startup from environment variables. Immutable thereafter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
base_url: str
|
||||||
|
token: str
|
||||||
|
timeout: float = 30.0
|
||||||
|
max_retries: int = 3
|
||||||
|
retry_base_delay: float = 0.5
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> Config:
|
||||||
|
"""Load configuration from environment variables.
|
||||||
|
|
||||||
|
Required:
|
||||||
|
GITEA_URL: Base URL of the Gitea instance (e.g. https://gitea.example.com)
|
||||||
|
GITEA_TOKEN: Personal Access Token
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
GITEA_TIMEOUT: HTTP request timeout in seconds (default: 30)
|
||||||
|
GITEA_MAX_RETRIES: Max retries for transient failures on idempotent
|
||||||
|
methods (GET/PUT/DELETE). Default 3. Set to 0 to disable retries.
|
||||||
|
GITEA_RETRY_BASE_DELAY: Base delay (seconds) for exponential backoff
|
||||||
|
between retries. Default 0.5. Effective delay is capped at 4s.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: if required variables are missing.
|
||||||
|
"""
|
||||||
|
base_url = os.environ.get("GITEA_URL", "").strip()
|
||||||
|
if not base_url:
|
||||||
|
raise RuntimeError(
|
||||||
|
"GITEA_URL environment variable is required. "
|
||||||
|
"Set it to the base URL of your Gitea instance."
|
||||||
|
)
|
||||||
|
|
||||||
|
token = os.environ.get("GITEA_TOKEN", "").strip()
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError(
|
||||||
|
"GITEA_TOKEN environment variable is required. "
|
||||||
|
"Generate a Personal Access Token in Gitea: Settings -> Applications."
|
||||||
|
)
|
||||||
|
|
||||||
|
timeout = float(os.environ.get("GITEA_TIMEOUT", "30"))
|
||||||
|
max_retries = int(os.environ.get("GITEA_MAX_RETRIES", "3"))
|
||||||
|
retry_base_delay = float(os.environ.get("GITEA_RETRY_BASE_DELAY", "0.5"))
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
base_url=base_url.rstrip("/"),
|
||||||
|
token=token,
|
||||||
|
timeout=timeout,
|
||||||
|
max_retries=max_retries,
|
||||||
|
retry_base_delay=retry_base_delay,
|
||||||
|
)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Preflight check for gitea-mcp: verify GITEA_URL + PAT, report status.
|
||||||
|
|
||||||
|
Run via ``gitea-mcp doctor``. Reads configuration from the same environment
|
||||||
|
variables the server uses (``GITEA_URL``, ``GITEA_TOKEN``, ``GITEA_TIMEOUT``)
|
||||||
|
and performs a ``GET /api/v1/user`` against the Gitea instance to confirm the
|
||||||
|
URL is reachable, the token works, and the response shape looks like a Gitea
|
||||||
|
user object. Then loads all tool modules to confirm the MCP surface is intact.
|
||||||
|
|
||||||
|
Returns exit ``0`` on success, ``1`` on connection or load failure, ``2`` on
|
||||||
|
missing configuration. Useful before pointing Claude Desktop at the server, or
|
||||||
|
when an MCP client reports an empty tools list and you need to know whether
|
||||||
|
the problem is the connection or the integration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from gitea_mcp import __version__
|
||||||
|
from gitea_mcp.init import check_connection
|
||||||
|
|
||||||
|
|
||||||
|
def run(args: argparse.Namespace) -> int:
|
||||||
|
"""Execute the doctor flow. Returns a process exit code."""
|
||||||
|
url = (args.url or os.environ.get("GITEA_URL", "")).strip().rstrip("/")
|
||||||
|
if not url:
|
||||||
|
print(
|
||||||
|
"Error: GITEA_URL not set. Provide --url or set the GITEA_URL env var.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
token = (args.token or os.environ.get("GITEA_TOKEN", "")).strip()
|
||||||
|
if not token:
|
||||||
|
print(
|
||||||
|
"Error: GITEA_TOKEN not set. Provide --token or set the GITEA_TOKEN env var.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
timeout = float(os.environ.get("GITEA_TIMEOUT", "30"))
|
||||||
|
|
||||||
|
print(f"gitea-mcp {__version__}")
|
||||||
|
print(f" Gitea URL : {url}")
|
||||||
|
print(f" Timeout : {timeout}s")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(f"[1/2] Verifying connection to {url} ...")
|
||||||
|
try:
|
||||||
|
username = check_connection(url, token, timeout=timeout)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
print(f" FAILED: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f" OK — authenticated as '{username}'.")
|
||||||
|
|
||||||
|
print("[2/2] Loading MCP tool modules ...")
|
||||||
|
try:
|
||||||
|
# Importing each tool module triggers its @mcp.tool() registrations
|
||||||
|
# against the FastMCP singleton in gitea_mcp._app. If any module fails
|
||||||
|
# to import, the server would also fail to start — catch it here so
|
||||||
|
# the user sees the real exception rather than a silent empty toolset.
|
||||||
|
from gitea_mcp.tools import issues, releases, repos, wiki # noqa: F401
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" FAILED: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(" OK — all tool modules loaded.")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("All checks passed. gitea-mcp is ready to wire into your MCP client.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
"""argparse parser for the ``doctor`` subcommand."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="gitea-mcp doctor",
|
||||||
|
description=(
|
||||||
|
"Preflight check: verify the Gitea URL + Personal Access Token and "
|
||||||
|
"report status. Reads GITEA_URL and GITEA_TOKEN from the environment "
|
||||||
|
"by default."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--url",
|
||||||
|
help="Override the Gitea base URL (default: GITEA_URL env var).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--token",
|
||||||
|
help="Override the Personal Access Token (default: GITEA_TOKEN env var).",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""Entry point for ``gitea-mcp doctor``."""
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
return run(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"""Interactive setup for adding gitea-mcp to Claude Desktop's config.
|
||||||
|
|
||||||
|
Run via ``gitea-mcp init``. Prompts for the Gitea instance URL and Personal
|
||||||
|
Access Token, optionally verifies the connection, then merges a ``gitea``
|
||||||
|
entry into the user's ``claude_desktop_config.json``. The existing file is
|
||||||
|
backed up with a timestamped ``.bak.*`` suffix before any write, and other
|
||||||
|
MCP servers in the same file are preserved.
|
||||||
|
|
||||||
|
Non-interactive use: pass ``--url``, ``--token``, and ``--yes`` to skip the
|
||||||
|
prompts and confirmation (useful for CI or dotfile bootstrap scripts).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import getpass
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# ---- Config path detection -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def claude_desktop_config_path() -> Path:
|
||||||
|
"""Return the OS-specific path to Claude Desktop's config file.
|
||||||
|
|
||||||
|
Windows: ``%APPDATA%\\Claude\\claude_desktop_config.json``
|
||||||
|
macOS: ``~/Library/Application Support/Claude/claude_desktop_config.json``
|
||||||
|
Linux: ``~/.config/Claude/claude_desktop_config.json``
|
||||||
|
|
||||||
|
Neither the file nor its parent directory are guaranteed to exist; callers
|
||||||
|
must handle creation. The path is returned even on platforms where Claude
|
||||||
|
Desktop is not officially supported.
|
||||||
|
"""
|
||||||
|
if sys.platform.startswith("win"):
|
||||||
|
base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
||||||
|
return Path(base) / "Claude" / "claude_desktop_config.json"
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
return (
|
||||||
|
Path.home()
|
||||||
|
/ "Library"
|
||||||
|
/ "Application Support"
|
||||||
|
/ "Claude"
|
||||||
|
/ "claude_desktop_config.json"
|
||||||
|
)
|
||||||
|
return Path.home() / ".config" / "Claude" / "claude_desktop_config.json"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Command auto-detection ------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def detect_command(prefer: str | None = None) -> tuple[str, list[str]]:
|
||||||
|
"""Choose the ``(command, args)`` pair Claude Desktop should use to launch
|
||||||
|
gitea-mcp.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
|
||||||
|
1. If ``prefer == "uvx"``: return ``("uvx", ["gitea-mcp"])`` unconditionally.
|
||||||
|
2. If ``gitea-mcp`` is on PATH (the console script installed in a venv):
|
||||||
|
return its absolute path. This is the most reliable option on Windows,
|
||||||
|
where Claude Desktop's launch environment frequently does not match the
|
||||||
|
PATH the user sees in their shell.
|
||||||
|
3. Fallback: ``(sys.executable, ["-m", "gitea_mcp.server"])``. Works
|
||||||
|
wherever the package itself is importable from the chosen Python.
|
||||||
|
"""
|
||||||
|
if prefer == "uvx":
|
||||||
|
return "uvx", ["gitea-mcp"]
|
||||||
|
on_path = shutil.which("gitea-mcp")
|
||||||
|
if on_path:
|
||||||
|
return on_path, []
|
||||||
|
return sys.executable, ["-m", "gitea_mcp.server"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Connection check ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def check_connection(url: str, token: str, timeout: float = 10.0) -> str:
|
||||||
|
"""Verify the Gitea URL + PAT by calling ``GET /api/v1/user``.
|
||||||
|
|
||||||
|
Returns the authenticated username on success. Raises ``RuntimeError`` with
|
||||||
|
a user-readable message on any failure (network, auth, malformed response).
|
||||||
|
"""
|
||||||
|
base = url.rstrip("/")
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=timeout) as client:
|
||||||
|
response = client.get(
|
||||||
|
f"{base}/api/v1/user",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"token {token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise RuntimeError(f"Could not reach {base}: {exc}") from exc
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Authentication failed at {base}. Check the Personal Access Token."
|
||||||
|
)
|
||||||
|
if not response.is_success:
|
||||||
|
message = response.text.strip() or response.reason_phrase
|
||||||
|
raise RuntimeError(f"{base} returned HTTP {response.status_code}: {message}")
|
||||||
|
payload: Any = response.json()
|
||||||
|
if not isinstance(payload, dict) or not isinstance(payload.get("login"), str):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{base} responded but the payload did not look like a Gitea user "
|
||||||
|
f"object (no 'login' field). Is the URL really a Gitea instance?"
|
||||||
|
)
|
||||||
|
username: str = payload["login"]
|
||||||
|
return username
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Config file manipulation ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path) -> dict[str, Any]:
|
||||||
|
"""Read an existing Claude Desktop config; return ``{}`` if missing or empty."""
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
text = path.read_text(encoding="utf-8").strip()
|
||||||
|
if not text:
|
||||||
|
return {}
|
||||||
|
loaded: Any = json.loads(text)
|
||||||
|
if not isinstance(loaded, dict):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{path} exists but does not contain a JSON object at the top level."
|
||||||
|
)
|
||||||
|
result: dict[str, Any] = loaded
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def backup_config(path: Path) -> Path:
|
||||||
|
"""Copy the existing config aside with a timestamped suffix.
|
||||||
|
|
||||||
|
Returns the backup path. No-op (returns the original path unchanged) if the
|
||||||
|
source file does not exist.
|
||||||
|
"""
|
||||||
|
if not path.exists():
|
||||||
|
return path
|
||||||
|
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
||||||
|
backup = path.with_name(f"{path.name}.bak.{timestamp}")
|
||||||
|
shutil.copy2(path, backup)
|
||||||
|
return backup
|
||||||
|
|
||||||
|
|
||||||
|
def merge_server_entry(
|
||||||
|
config: dict[str, Any],
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
command: str,
|
||||||
|
args: list[str],
|
||||||
|
env: dict[str, str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Insert or replace the named server entry under ``mcpServers``.
|
||||||
|
|
||||||
|
Other servers under ``mcpServers`` are preserved. The input dict is mutated
|
||||||
|
in place and also returned for convenience.
|
||||||
|
"""
|
||||||
|
servers_obj: Any = config.setdefault("mcpServers", {})
|
||||||
|
if not isinstance(servers_obj, dict):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Existing claude_desktop_config.json has an 'mcpServers' key that "
|
||||||
|
"is not a JSON object. Refusing to overwrite — inspect and fix the "
|
||||||
|
"file manually."
|
||||||
|
)
|
||||||
|
servers: dict[str, Any] = servers_obj
|
||||||
|
entry: dict[str, Any] = {"command": command, "env": env}
|
||||||
|
if args:
|
||||||
|
entry["args"] = args
|
||||||
|
servers[name] = entry
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def write_config(path: Path, config: dict[str, Any]) -> None:
|
||||||
|
"""Write the config with two-space indent and a trailing newline."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- CLI -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt(label: str) -> str:
|
||||||
|
"""Read a non-empty line from stdin, re-prompting on empty input."""
|
||||||
|
while True:
|
||||||
|
raw = input(f"{label}: ").strip()
|
||||||
|
if raw:
|
||||||
|
return raw
|
||||||
|
print(" (value is required)")
|
||||||
|
|
||||||
|
|
||||||
|
def run(args: argparse.Namespace) -> int:
|
||||||
|
"""Execute the init flow. Returns a process exit code."""
|
||||||
|
url = (args.url or _prompt("Gitea base URL (e.g. https://gitea.example.com)")).rstrip("/")
|
||||||
|
if args.token:
|
||||||
|
token = args.token
|
||||||
|
else:
|
||||||
|
token = getpass.getpass("Personal Access Token (input hidden): ").strip()
|
||||||
|
if not token:
|
||||||
|
print("Error: no token provided.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
config_path = Path(args.config_path) if args.config_path else claude_desktop_config_path()
|
||||||
|
server_name: str = args.name
|
||||||
|
prefer = None if args.command == "auto" else args.command
|
||||||
|
command, command_args = detect_command(prefer=prefer)
|
||||||
|
|
||||||
|
if not args.skip_check:
|
||||||
|
print(f"Checking {url} ...")
|
||||||
|
try:
|
||||||
|
username = check_connection(url, token)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
print(f" Connection check FAILED: {exc}", file=sys.stderr)
|
||||||
|
print(" Re-run with --skip-check to write the config anyway.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f" OK — authenticated as '{username}'.")
|
||||||
|
|
||||||
|
env = {"GITEA_URL": url, "GITEA_TOKEN": token}
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"Will write the following entry to {config_path}:")
|
||||||
|
print(f" mcpServers.{server_name}.command = {command}")
|
||||||
|
if command_args:
|
||||||
|
print(f" mcpServers.{server_name}.args = {command_args}")
|
||||||
|
print(f" mcpServers.{server_name}.env = {{GITEA_URL=..., GITEA_TOKEN=***}}")
|
||||||
|
if not args.yes:
|
||||||
|
confirm = input("Proceed? [y/N]: ").strip().lower()
|
||||||
|
if confirm not in {"y", "yes"}:
|
||||||
|
print("Aborted.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = load_config(config_path)
|
||||||
|
except (json.JSONDecodeError, RuntimeError) as exc:
|
||||||
|
print(f"Error reading {config_path}: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
backup = backup_config(config_path)
|
||||||
|
merge_server_entry(
|
||||||
|
config,
|
||||||
|
name=server_name,
|
||||||
|
command=command,
|
||||||
|
args=command_args,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
write_config(config_path, config)
|
||||||
|
|
||||||
|
print()
|
||||||
|
if backup != config_path:
|
||||||
|
print(f"Backed up existing config to {backup}.")
|
||||||
|
print(f"Wrote {config_path}.")
|
||||||
|
print(
|
||||||
|
"Note: GITEA_TOKEN is stored in plaintext at the path above. "
|
||||||
|
"Restrict file permissions if this is a shared machine."
|
||||||
|
)
|
||||||
|
print("Restart Claude Desktop to load the new server.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
"""argparse parser for the ``init`` subcommand."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="gitea-mcp init",
|
||||||
|
description=(
|
||||||
|
"Add gitea-mcp to Claude Desktop's claude_desktop_config.json. "
|
||||||
|
"Prompts interactively unless --url, --token, and --yes are provided."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument("--url", help="Gitea base URL (e.g. https://gitea.example.com)")
|
||||||
|
parser.add_argument("--token", help="Personal Access Token")
|
||||||
|
parser.add_argument(
|
||||||
|
"--name",
|
||||||
|
default="gitea",
|
||||||
|
help="Server key under mcpServers (default: gitea). Use a unique value "
|
||||||
|
"if you run multiple Gitea instances.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--command",
|
||||||
|
choices=["auto", "uvx"],
|
||||||
|
default="auto",
|
||||||
|
help="Launch command to write. 'auto' (default) uses the gitea-mcp "
|
||||||
|
"console script if on PATH, else 'python -m gitea_mcp.server'. "
|
||||||
|
"'uvx' writes 'uvx gitea-mcp' (requires gitea-mcp on PyPI).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--config-path",
|
||||||
|
help="Override the Claude Desktop config path (default: OS-specific).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--skip-check",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip the GET /api/v1/user connection check before writing.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--yes",
|
||||||
|
"-y",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip the final confirmation prompt.",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""Entry point for ``gitea-mcp init``."""
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
return run(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Transport-selectable runner for the gitea-mcp MCP server.
|
||||||
|
|
||||||
|
Adds a ``gitea-mcp serve`` subcommand that lets the operator pick between
|
||||||
|
stdio (the default — what Claude Desktop and other local MCP clients use)
|
||||||
|
and HTTP transport (for self-hosting one running instance that multiple
|
||||||
|
clients connect to over the network).
|
||||||
|
|
||||||
|
The no-args invocation ``gitea-mcp`` continues to run in stdio mode via the
|
||||||
|
dispatcher in :mod:`gitea_mcp.server` so existing Claude Desktop / Cowork /
|
||||||
|
Claude Code integrations and the dual-load regression test are unaffected.
|
||||||
|
|
||||||
|
**Auth model for HTTP transport (v0.5.0).** Single-user — the server reads
|
||||||
|
``GITEA_TOKEN`` from its own environment exactly as the stdio mode does, and
|
||||||
|
any client that reaches the URL acts as that one user against Gitea. This is
|
||||||
|
appropriate for self-hosted personal use behind your own access controls
|
||||||
|
(firewall, reverse-proxy auth, VPN). Multi-tenant bring-your-own-token is a
|
||||||
|
real auth-integration project deferred to a later release.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from gitea_mcp import _app
|
||||||
|
from gitea_mcp._app import mcp
|
||||||
|
from gitea_mcp.client import GiteaClient
|
||||||
|
from gitea_mcp.config import Config
|
||||||
|
|
||||||
|
# Tool modules must be imported so their @mcp.tool() decorators fire and
|
||||||
|
# register against the singleton in _app. Importing them here (in addition
|
||||||
|
# to in server.py) keeps `python -m gitea_mcp.serve` viable as a direct
|
||||||
|
# invocation path, though the canonical entry is `gitea-mcp serve`.
|
||||||
|
from gitea_mcp.tools import ( # noqa: F401
|
||||||
|
files,
|
||||||
|
issues,
|
||||||
|
pulls,
|
||||||
|
releases,
|
||||||
|
repos,
|
||||||
|
server_info,
|
||||||
|
wiki,
|
||||||
|
)
|
||||||
|
|
||||||
|
_DEFAULT_HOST = "127.0.0.1"
|
||||||
|
_DEFAULT_PORT = 8000
|
||||||
|
_DEFAULT_PATH = "/mcp"
|
||||||
|
|
||||||
|
|
||||||
|
def run(args: argparse.Namespace) -> int:
|
||||||
|
"""Execute the serve flow. Returns a process exit code."""
|
||||||
|
config = Config.from_env()
|
||||||
|
client = GiteaClient(
|
||||||
|
base_url=config.base_url,
|
||||||
|
token=config.token,
|
||||||
|
timeout=config.timeout,
|
||||||
|
max_retries=config.max_retries,
|
||||||
|
retry_base_delay=config.retry_base_delay,
|
||||||
|
)
|
||||||
|
_app._client = client
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.transport == "stdio":
|
||||||
|
mcp.run()
|
||||||
|
else:
|
||||||
|
# FastMCP accepts "http" / "streamable-http" / "sse" — we expose
|
||||||
|
# the friendlier "http" alias on the CLI which FastMCP maps to
|
||||||
|
# streamable-http internally.
|
||||||
|
mcp.run(
|
||||||
|
transport="http",
|
||||||
|
host=args.host,
|
||||||
|
port=args.port,
|
||||||
|
path=args.path,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Best-effort cleanup. If the event loop is already closed, ignore.
|
||||||
|
with contextlib.suppress(RuntimeError):
|
||||||
|
asyncio.run(client.close())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
"""argparse parser for the ``serve`` subcommand."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="gitea-mcp serve",
|
||||||
|
description=(
|
||||||
|
"Start the gitea-mcp MCP server with the chosen transport. "
|
||||||
|
"Defaults match the no-args `gitea-mcp` invocation (stdio) so "
|
||||||
|
"existing Claude Desktop / Cowork integrations are unaffected; "
|
||||||
|
"pass --transport http to self-host one instance for multiple clients."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--transport",
|
||||||
|
choices=["stdio", "http"],
|
||||||
|
default=os.environ.get("GITEA_MCP_TRANSPORT", "stdio"),
|
||||||
|
help=(
|
||||||
|
"Transport to run. 'stdio' (default) for local MCP-client launch; "
|
||||||
|
"'http' for self-hosted streamable-HTTP. "
|
||||||
|
"Env: GITEA_MCP_TRANSPORT."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--host",
|
||||||
|
default=os.environ.get("GITEA_MCP_HOST", _DEFAULT_HOST),
|
||||||
|
help=(
|
||||||
|
f"Bind address for HTTP transport (default: {_DEFAULT_HOST}). "
|
||||||
|
"Use 0.0.0.0 to listen on all interfaces (Docker, public hosting). "
|
||||||
|
"Env: GITEA_MCP_HOST. Ignored for stdio."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--port",
|
||||||
|
type=int,
|
||||||
|
default=int(os.environ.get("GITEA_MCP_PORT", _DEFAULT_PORT)),
|
||||||
|
help=(
|
||||||
|
f"TCP port for HTTP transport (default: {_DEFAULT_PORT}). "
|
||||||
|
"Env: GITEA_MCP_PORT. Ignored for stdio."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--path",
|
||||||
|
default=os.environ.get("GITEA_MCP_PATH", _DEFAULT_PATH),
|
||||||
|
help=(
|
||||||
|
f"URL path the MCP endpoint serves at (default: {_DEFAULT_PATH}). "
|
||||||
|
"Env: GITEA_MCP_PATH. Ignored for stdio."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""Entry point for ``gitea-mcp serve``."""
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
return run(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""MCP server entry point. Initializes the client and runs stdio transport.
|
||||||
|
|
||||||
|
The ``FastMCP`` singleton lives in :mod:`gitea_mcp._app`, not here. This module
|
||||||
|
is intentionally a thin launcher so that ``python -m gitea_mcp.server`` is safe
|
||||||
|
to load twice (once as ``__main__``, once under its real name when a tool
|
||||||
|
module indirectly imports it). See :mod:`gitea_mcp._app` for the full rationale.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from gitea_mcp import __version__, _app
|
||||||
|
from gitea_mcp._app import mcp
|
||||||
|
from gitea_mcp.client import GiteaClient
|
||||||
|
from gitea_mcp.config import Config
|
||||||
|
|
||||||
|
# Import tool modules so their @mcp.tool() registrations execute on module
|
||||||
|
# load. Ordering doesn't matter; each module registers against the shared
|
||||||
|
# ``mcp`` instance in ``_app``.
|
||||||
|
from gitea_mcp.tools import ( # noqa: E402, F401
|
||||||
|
files,
|
||||||
|
issues,
|
||||||
|
pulls,
|
||||||
|
releases,
|
||||||
|
repos,
|
||||||
|
server_info,
|
||||||
|
wiki,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Console-script entry point.
|
||||||
|
|
||||||
|
Dispatches based on the first positional argument:
|
||||||
|
|
||||||
|
* No arguments (the default Claude Desktop / Claude Code invocation):
|
||||||
|
runs the MCP server over stdio. This path is byte-identical to the
|
||||||
|
pre-v0.1.2 behavior and is guarded by ``tests/test_subprocess_launch``.
|
||||||
|
* ``--help`` / ``-h``: prints top-level help.
|
||||||
|
* ``--version`` / ``-V``: prints ``gitea-mcp <version>``.
|
||||||
|
* ``init``: hands off to :func:`gitea_mcp.init.main` for interactive setup.
|
||||||
|
* ``doctor``: hands off to :func:`gitea_mcp.doctor.main` for the preflight.
|
||||||
|
* Anything else: prints an unknown-subcommand error and exits 2.
|
||||||
|
|
||||||
|
Subcommand-level flag parsing (e.g. ``gitea-mcp init --help``) lives in
|
||||||
|
the subcommand modules' own argparse parsers; this dispatcher passes the
|
||||||
|
remaining argv through unchanged.
|
||||||
|
"""
|
||||||
|
argv = sys.argv[1:]
|
||||||
|
|
||||||
|
if not argv:
|
||||||
|
_run_server()
|
||||||
|
return
|
||||||
|
|
||||||
|
first = argv[0]
|
||||||
|
|
||||||
|
if first in ("-h", "--help"):
|
||||||
|
_print_help()
|
||||||
|
return
|
||||||
|
|
||||||
|
if first in ("-V", "--version"):
|
||||||
|
print(f"gitea-mcp {__version__}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if first == "init":
|
||||||
|
from gitea_mcp.init import main as init_main
|
||||||
|
|
||||||
|
sys.exit(init_main(argv[1:]))
|
||||||
|
|
||||||
|
if first == "doctor":
|
||||||
|
from gitea_mcp.doctor import main as doctor_main
|
||||||
|
|
||||||
|
sys.exit(doctor_main(argv[1:]))
|
||||||
|
|
||||||
|
if first == "serve":
|
||||||
|
from gitea_mcp.serve import main as serve_main
|
||||||
|
|
||||||
|
sys.exit(serve_main(argv[1:]))
|
||||||
|
|
||||||
|
print(f"Error: unknown subcommand '{first}'.", file=sys.stderr)
|
||||||
|
print("Run 'gitea-mcp --help' for usage.", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_help() -> None:
|
||||||
|
"""Print the top-level help text."""
|
||||||
|
print(
|
||||||
|
"gitea-mcp — Model Context Protocol server for Gitea (and Forgejo, Codeberg)\n"
|
||||||
|
"\n"
|
||||||
|
"Usage:\n"
|
||||||
|
" gitea-mcp Start the MCP server (stdio transport).\n"
|
||||||
|
" This is what Claude Desktop / Claude Code\n"
|
||||||
|
" invoke. Requires GITEA_URL and GITEA_TOKEN\n"
|
||||||
|
" environment variables.\n"
|
||||||
|
" gitea-mcp serve [opts] Start the server with a chosen transport\n"
|
||||||
|
" (stdio or HTTP). Use --transport http for\n"
|
||||||
|
" self-hosting one instance for multiple clients.\n"
|
||||||
|
" Run 'gitea-mcp serve --help' for options.\n"
|
||||||
|
" gitea-mcp init [opts] Interactive setup: add gitea-mcp to\n"
|
||||||
|
" claude_desktop_config.json.\n"
|
||||||
|
" Run 'gitea-mcp init --help' for options.\n"
|
||||||
|
" gitea-mcp doctor [opts] Preflight check: verify GITEA_URL + token\n"
|
||||||
|
" and report status.\n"
|
||||||
|
" Run 'gitea-mcp doctor --help' for options.\n"
|
||||||
|
" gitea-mcp --version, -V Print version and exit.\n"
|
||||||
|
" gitea-mcp --help, -h This message.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_server() -> None:
|
||||||
|
config = Config.from_env()
|
||||||
|
client = GiteaClient(
|
||||||
|
base_url=config.base_url,
|
||||||
|
token=config.token,
|
||||||
|
timeout=config.timeout,
|
||||||
|
max_retries=config.max_retries,
|
||||||
|
retry_base_delay=config.retry_base_delay,
|
||||||
|
)
|
||||||
|
_app._client = client
|
||||||
|
try:
|
||||||
|
mcp.run()
|
||||||
|
finally:
|
||||||
|
# Best-effort cleanup. If the event loop is already closed, ignore.
|
||||||
|
with contextlib.suppress(RuntimeError):
|
||||||
|
asyncio.run(client.close())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""MCP tool definitions, organized by Gitea resource family.
|
||||||
|
|
||||||
|
Each submodule registers its tools against the FastMCP instance defined in
|
||||||
|
``gitea_mcp._app``.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"""MCP tools for Gitea file operations and pull-request creation.
|
||||||
|
|
||||||
|
This is the surface that lets the LLM actually *change code* via gitea-mcp,
|
||||||
|
not just read and discuss it. Four tools form a complete edit workflow:
|
||||||
|
|
||||||
|
1. :func:`read_file` — read a file's current content (optionally pinned to a
|
||||||
|
specific branch or commit).
|
||||||
|
2. :func:`create_branch` — open a feature branch off the default branch (or
|
||||||
|
any specified base).
|
||||||
|
3. :func:`commit_changes` — write a single file's new content on a branch;
|
||||||
|
automatically detects whether the file is new or existing (GET to fetch
|
||||||
|
the current SHA; 404 → POST create; existing → PUT update).
|
||||||
|
4. :func:`create_pr` — open a pull request from the feature branch back to
|
||||||
|
the base.
|
||||||
|
|
||||||
|
Multi-file commits via the Git Trees API are deliberately out of scope for
|
||||||
|
this MVP — single-file `commit_changes` is the simpler, safer pattern.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from gitea_mcp._app import get_client, mcp
|
||||||
|
from gitea_mcp.client import GiteaAPIError
|
||||||
|
|
||||||
|
_READ_ONLY = ToolAnnotations(readOnlyHint=True, openWorldHint=True)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(annotations=_READ_ONLY)
|
||||||
|
async def read_file(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
path: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="Path within the repository, e.g. 'src/gitea_mcp/server.py'"),
|
||||||
|
],
|
||||||
|
ref: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description=(
|
||||||
|
"Branch name, tag name, or commit SHA to read from. "
|
||||||
|
"Omit to read from the repository's default branch."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Read a file's content from a Gitea repository.
|
||||||
|
|
||||||
|
Returns Gitea's ``ContentsResponse`` shape extended with a ``text`` field
|
||||||
|
containing the decoded file content as a UTF-8 string (or ``None`` if the
|
||||||
|
content isn't valid UTF-8 — binary file). The raw base64 ``content`` and
|
||||||
|
Gitea's ``encoding`` are preserved so callers can re-decode if needed.
|
||||||
|
|
||||||
|
The ``sha`` field in the response is what :func:`commit_changes` would
|
||||||
|
need to update this file — but ``commit_changes`` fetches it internally,
|
||||||
|
so callers don't usually need to pass it forward.
|
||||||
|
"""
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
if ref is not None:
|
||||||
|
params["ref"] = ref
|
||||||
|
response = await get_client().get_json(
|
||||||
|
f"/repos/{owner}/{repo}/contents/{path}",
|
||||||
|
params=params or None,
|
||||||
|
)
|
||||||
|
text: str | None = None
|
||||||
|
encoding = response.get("encoding")
|
||||||
|
content = response.get("content")
|
||||||
|
if encoding == "base64" and isinstance(content, str):
|
||||||
|
try:
|
||||||
|
text = base64.b64decode(content).decode("utf-8")
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
text = None
|
||||||
|
response["text"] = text
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=False,
|
||||||
|
destructiveHint=False,
|
||||||
|
openWorldHint=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def create_branch(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
new_branch_name: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="Name for the new branch (must not already exist)"),
|
||||||
|
],
|
||||||
|
old_branch_name: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description=(
|
||||||
|
"Existing branch to fork from. Omit to use the repository's "
|
||||||
|
"default branch."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new branch in a repository.
|
||||||
|
|
||||||
|
Returns the created Gitea Branch object. Fails cleanly via
|
||||||
|
:class:`GiteaAPIError` if the new branch name already exists.
|
||||||
|
"""
|
||||||
|
payload: dict[str, Any] = {"new_branch_name": new_branch_name}
|
||||||
|
if old_branch_name is not None:
|
||||||
|
payload["old_branch_name"] = old_branch_name
|
||||||
|
return await get_client().post_json(
|
||||||
|
f"/repos/{owner}/{repo}/branches",
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=False,
|
||||||
|
# Overwrites existing file content (when updating). Reversible via git,
|
||||||
|
# but a user-visible state change worth gating on confirmation.
|
||||||
|
destructiveHint=True,
|
||||||
|
openWorldHint=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def commit_changes(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
branch: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="Branch to commit to (must exist; use create_branch first)"),
|
||||||
|
],
|
||||||
|
path: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="File path within the repository, e.g. 'README.md'"),
|
||||||
|
],
|
||||||
|
content: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="New file content (UTF-8 text; binary files not supported)"),
|
||||||
|
],
|
||||||
|
message: Annotated[str, Field(description="Commit message")],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create or update a single file on a branch in one commit.
|
||||||
|
|
||||||
|
Auto-detects whether the file exists:
|
||||||
|
|
||||||
|
- **File does not exist on the branch:** Gitea returns 404 to the SHA
|
||||||
|
lookup; we ``POST`` to create the file.
|
||||||
|
- **File exists:** we use its current SHA and ``PUT`` to update it.
|
||||||
|
|
||||||
|
Returns Gitea's ``FileResponse`` shape (the resulting commit + content
|
||||||
|
metadata). Raises :class:`GiteaAPIError` on conflicts (e.g. concurrent
|
||||||
|
update changed the SHA between our lookup and our write — caller should
|
||||||
|
re-read and retry).
|
||||||
|
|
||||||
|
Single-file only. Multi-file commits would require Gitea's Git Trees
|
||||||
|
API and are deliberately out of scope for this tool.
|
||||||
|
"""
|
||||||
|
client = get_client()
|
||||||
|
encoded_content = base64.b64encode(content.encode("utf-8")).decode("ascii")
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"content": encoded_content,
|
||||||
|
"message": message,
|
||||||
|
"branch": branch,
|
||||||
|
}
|
||||||
|
|
||||||
|
# SHA lookup — determines create vs update.
|
||||||
|
existing_sha: str | None = None
|
||||||
|
try:
|
||||||
|
existing = await client.get_json(
|
||||||
|
f"/repos/{owner}/{repo}/contents/{path}",
|
||||||
|
params={"ref": branch},
|
||||||
|
)
|
||||||
|
sha_value = existing.get("sha")
|
||||||
|
if isinstance(sha_value, str):
|
||||||
|
existing_sha = sha_value
|
||||||
|
except GiteaAPIError as exc:
|
||||||
|
if exc.status_code != 404:
|
||||||
|
raise
|
||||||
|
# 404 = file doesn't exist; fall through to create.
|
||||||
|
|
||||||
|
if existing_sha is not None:
|
||||||
|
payload["sha"] = existing_sha
|
||||||
|
return await client.put_json(
|
||||||
|
f"/repos/{owner}/{repo}/contents/{path}",
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
return await client.post_json(
|
||||||
|
f"/repos/{owner}/{repo}/contents/{path}",
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=False,
|
||||||
|
destructiveHint=False,
|
||||||
|
openWorldHint=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def create_pr(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
head: Annotated[
|
||||||
|
str,
|
||||||
|
Field(
|
||||||
|
description=(
|
||||||
|
"Source branch (the branch containing your changes). "
|
||||||
|
"Same-repo only; cross-fork PRs not supported by this tool."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
base: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="Target branch (where the PR should merge into, e.g. 'main')"),
|
||||||
|
],
|
||||||
|
title: Annotated[str, Field(description="Pull request title")],
|
||||||
|
body: Annotated[str, Field(description="PR description in Markdown")] = "",
|
||||||
|
draft: Annotated[
|
||||||
|
bool,
|
||||||
|
Field(description="Open as a draft PR (cannot be merged until marked ready)"),
|
||||||
|
] = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Open a new pull request from ``head`` into ``base``.
|
||||||
|
|
||||||
|
Returns the created Gitea PullRequest object (including the assigned
|
||||||
|
number, URL, and merge status). Raises :class:`GiteaAPIError` if the
|
||||||
|
head branch doesn't exist, there are no commits between head and base,
|
||||||
|
or an open PR already exists for this branch pair.
|
||||||
|
"""
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"head": head,
|
||||||
|
"base": base,
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
}
|
||||||
|
if draft:
|
||||||
|
payload["draft"] = True
|
||||||
|
return await get_client().post_json(
|
||||||
|
f"/repos/{owner}/{repo}/pulls",
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"""MCP tools for Gitea issues."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from gitea_mcp._app import get_client, mcp
|
||||||
|
from gitea_mcp.client import GiteaClient, GiteaError
|
||||||
|
|
||||||
|
# ---- Internal helpers ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_all_labels(
|
||||||
|
client: GiteaClient, owner: str, repo: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Page through every label defined in a repository."""
|
||||||
|
all_labels: list[dict[str, Any]] = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
batch = await client.get_list(
|
||||||
|
f"/repos/{owner}/{repo}/labels",
|
||||||
|
params={"page": page, "limit": 50},
|
||||||
|
)
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
all_labels.extend(batch)
|
||||||
|
if len(batch) < 50:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return all_labels
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_label_ids(
|
||||||
|
client: GiteaClient, owner: str, repo: str, label_names: list[str]
|
||||||
|
) -> list[int]:
|
||||||
|
"""Resolve a list of label names to the integer IDs Gitea's issue API expects.
|
||||||
|
|
||||||
|
Gitea's create-issue and replace-issue-labels endpoints take ``labels`` as a
|
||||||
|
list of integer IDs, not names. This helper fetches the repo's labels once
|
||||||
|
and maps the names in. Raises :class:`GiteaError` if any name doesn't match
|
||||||
|
a defined label, including the available label names in the error message
|
||||||
|
so the caller knows what's valid.
|
||||||
|
"""
|
||||||
|
if not label_names:
|
||||||
|
return []
|
||||||
|
all_labels = await _list_all_labels(client, owner, repo)
|
||||||
|
name_to_id = {label["name"]: label["id"] for label in all_labels}
|
||||||
|
missing = [name for name in label_names if name not in name_to_id]
|
||||||
|
if missing:
|
||||||
|
raise GiteaError(
|
||||||
|
f"Labels not found in {owner}/{repo}: {missing}. "
|
||||||
|
f"Available labels: {sorted(name_to_id.keys())}"
|
||||||
|
)
|
||||||
|
return [name_to_id[name] for name in label_names]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Tools -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=False,
|
||||||
|
destructiveHint=False,
|
||||||
|
openWorldHint=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def create_issue(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner (user or organization name)")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
title: Annotated[str, Field(description="Issue title")],
|
||||||
|
body: Annotated[str, Field(description="Issue body in Markdown")] = "",
|
||||||
|
labels: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Field(description="Label names to apply to the new issue (resolved to IDs automatically)"),
|
||||||
|
] = None,
|
||||||
|
assignees: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Field(description="Usernames to assign to the new issue"),
|
||||||
|
] = None,
|
||||||
|
milestone: Annotated[int | None, Field(description="Milestone ID to attach")] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new issue in a Gitea repository.
|
||||||
|
|
||||||
|
Returns the full Gitea Issue object including the assigned number, URL, and
|
||||||
|
metadata. Label names are resolved to IDs against the repository's label
|
||||||
|
set; an unknown label name fails the call cleanly with the list of valid
|
||||||
|
names.
|
||||||
|
"""
|
||||||
|
client = get_client()
|
||||||
|
payload: dict[str, Any] = {"title": title, "body": body}
|
||||||
|
if assignees:
|
||||||
|
payload["assignees"] = assignees
|
||||||
|
if milestone is not None:
|
||||||
|
payload["milestone"] = milestone
|
||||||
|
if labels:
|
||||||
|
payload["labels"] = await _resolve_label_ids(client, owner, repo, labels)
|
||||||
|
return await client.post_json(f"/repos/{owner}/{repo}/issues", json=payload)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True)
|
||||||
|
)
|
||||||
|
async def list_issues(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner (user or organization name)")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
state: Annotated[
|
||||||
|
str, Field(description="Filter by state: 'open', 'closed', or 'all'")
|
||||||
|
] = "open",
|
||||||
|
labels: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Comma-separated label names to filter by"),
|
||||||
|
] = None,
|
||||||
|
assignee: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Filter to issues assigned to this username"),
|
||||||
|
] = None,
|
||||||
|
page: Annotated[int, Field(description="Page number (1-indexed)")] = 1,
|
||||||
|
limit: Annotated[int, Field(description="Items per page (max 50)")] = 30,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""List issues in a Gitea repository.
|
||||||
|
|
||||||
|
Pull requests are excluded; only true issues are returned. Filters compose
|
||||||
|
(state AND labels AND assignee).
|
||||||
|
"""
|
||||||
|
client = get_client()
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"state": state,
|
||||||
|
"type": "issues", # exclude pull requests
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
}
|
||||||
|
if labels:
|
||||||
|
params["labels"] = labels
|
||||||
|
if assignee:
|
||||||
|
params["assigned_by"] = assignee
|
||||||
|
return await client.get_list(f"/repos/{owner}/{repo}/issues", params=params)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True)
|
||||||
|
)
|
||||||
|
async def get_issue(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
issue_number: Annotated[int, Field(description="Issue number (the #N in the URL)")],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get a single issue by number, including all of its comments.
|
||||||
|
|
||||||
|
The returned object is the standard Gitea Issue payload, with an additional
|
||||||
|
``comments_list`` field containing the full list of Comment objects. The
|
||||||
|
existing top-level ``comments`` integer field (comment count) is preserved.
|
||||||
|
"""
|
||||||
|
client = get_client()
|
||||||
|
issue = await client.get_json(f"/repos/{owner}/{repo}/issues/{issue_number}")
|
||||||
|
issue["comments_list"] = await client.get_list(
|
||||||
|
f"/repos/{owner}/{repo}/issues/{issue_number}/comments"
|
||||||
|
)
|
||||||
|
return issue
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=False,
|
||||||
|
# Can close the issue and clear labels — both reversible but
|
||||||
|
# user-visible side effects, so clients should gate on confirmation
|
||||||
|
# rather than auto-approve.
|
||||||
|
destructiveHint=True,
|
||||||
|
idempotentHint=True,
|
||||||
|
openWorldHint=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def update_issue(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
issue_number: Annotated[int, Field(description="Issue number")],
|
||||||
|
title: Annotated[str | None, Field(description="New title")] = None,
|
||||||
|
body: Annotated[str | None, Field(description="New body (Markdown)")] = None,
|
||||||
|
state: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="New state: 'open' or 'closed'"),
|
||||||
|
] = None,
|
||||||
|
labels: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Field(description="Replace labels with this exact set (names; pass [] to clear)"),
|
||||||
|
] = None,
|
||||||
|
assignees: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Field(description="Replace assignees with this exact set of usernames"),
|
||||||
|
] = None,
|
||||||
|
milestone: Annotated[
|
||||||
|
int | None,
|
||||||
|
Field(description="Milestone ID to attach; pass 0 to clear the milestone"),
|
||||||
|
] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Update an existing issue's title, body, state, assignees, milestone, or labels.
|
||||||
|
|
||||||
|
Each argument is independent — pass only the fields you want to change.
|
||||||
|
Labels are replaced atomically against the new set (passing ``[]`` removes
|
||||||
|
all labels). Other list fields (assignees) follow the same replace semantics.
|
||||||
|
"""
|
||||||
|
client = get_client()
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
if title is not None:
|
||||||
|
payload["title"] = title
|
||||||
|
if body is not None:
|
||||||
|
payload["body"] = body
|
||||||
|
if state is not None:
|
||||||
|
payload["state"] = state
|
||||||
|
if assignees is not None:
|
||||||
|
payload["assignees"] = assignees
|
||||||
|
if milestone is not None:
|
||||||
|
# Gitea convention: milestone=0 in the request clears the milestone.
|
||||||
|
# We send null in that case, which Gitea also accepts and is unambiguous.
|
||||||
|
payload["milestone"] = milestone if milestone > 0 else None
|
||||||
|
|
||||||
|
if payload:
|
||||||
|
issue = await client.patch_json(
|
||||||
|
f"/repos/{owner}/{repo}/issues/{issue_number}", json=payload
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No PATCH-level changes — fetch the current issue so the caller still
|
||||||
|
# gets the up-to-date object after the labels update below.
|
||||||
|
issue = await client.get_json(f"/repos/{owner}/{repo}/issues/{issue_number}")
|
||||||
|
|
||||||
|
if labels is not None:
|
||||||
|
label_ids = await _resolve_label_ids(client, owner, repo, labels)
|
||||||
|
issue["labels"] = await client.put_list(
|
||||||
|
f"/repos/{owner}/{repo}/issues/{issue_number}/labels",
|
||||||
|
json={"labels": label_ids},
|
||||||
|
)
|
||||||
|
|
||||||
|
return issue
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=False,
|
||||||
|
destructiveHint=False,
|
||||||
|
openWorldHint=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def add_comment(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
issue_number: Annotated[int, Field(description="Issue number")],
|
||||||
|
body: Annotated[str, Field(description="Comment body (Markdown)")],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Add a comment to an existing issue. Returns the created Comment object."""
|
||||||
|
return await get_client().post_json(
|
||||||
|
f"/repos/{owner}/{repo}/issues/{issue_number}/comments",
|
||||||
|
json={"body": body},
|
||||||
|
)
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""MCP tools for Gitea branches and pull requests.
|
||||||
|
|
||||||
|
Grouped together because the workflow is shared: inspecting branches, listing
|
||||||
|
PRs against them, reading a PR's discussion thread, and adding a comment to
|
||||||
|
move the conversation forward.
|
||||||
|
|
||||||
|
A note on the Gitea API: pull requests and issues share the same number
|
||||||
|
namespace and the same comments endpoint (``/repos/{owner}/{repo}/issues/{n}/comments``).
|
||||||
|
That's why ``add_comment_on_pr`` posts to ``/issues/{pull_number}/comments``
|
||||||
|
rather than a hypothetical ``/pulls/{pull_number}/comments`` — Gitea simply
|
||||||
|
doesn't have one for non-review comments. Inline review comments (those
|
||||||
|
attached to specific diff lines) live under ``/pulls/{n}/reviews`` and are
|
||||||
|
out of scope for this MVP.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from gitea_mcp._app import get_client, mcp
|
||||||
|
|
||||||
|
_READ_ONLY = ToolAnnotations(readOnlyHint=True, openWorldHint=True)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(annotations=_READ_ONLY)
|
||||||
|
async def list_branches(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner (user or organization name)")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
page: Annotated[int, Field(description="Page number (1-indexed)")] = 1,
|
||||||
|
limit: Annotated[int, Field(description="Items per page (max 50)")] = 30,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""List branches in a repository.
|
||||||
|
|
||||||
|
Returns the standard Gitea Branch object array: ``[{name, commit: {id, ...},
|
||||||
|
protected, ...}]``. Useful for inspecting available targets before creating
|
||||||
|
a release, opening a PR, or filing a fix against a specific branch.
|
||||||
|
"""
|
||||||
|
return await get_client().get_list(
|
||||||
|
f"/repos/{owner}/{repo}/branches",
|
||||||
|
params={"page": page, "limit": limit},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(annotations=_READ_ONLY)
|
||||||
|
async def list_pull_requests(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
state: Annotated[
|
||||||
|
str, Field(description="Filter by state: 'open', 'closed', or 'all'")
|
||||||
|
] = "open",
|
||||||
|
sort: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description=(
|
||||||
|
"Sort order: 'oldest', 'newest', 'leastupdate', 'mostupdate', "
|
||||||
|
"'leastcomment', 'mostcomment', 'priority'. Omit for Gitea's default."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
page: Annotated[int, Field(description="Page number (1-indexed)")] = 1,
|
||||||
|
limit: Annotated[int, Field(description="Items per page (max 50)")] = 30,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""List pull requests in a repository, optionally filtered by state.
|
||||||
|
|
||||||
|
Returns Gitea PullRequest objects (not Issue-style — the PR endpoint
|
||||||
|
returns richer head/base/mergeable info than the issues endpoint does
|
||||||
|
even when issues are filtered to ``type=pulls``).
|
||||||
|
"""
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"state": state,
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
}
|
||||||
|
if sort is not None:
|
||||||
|
params["sort"] = sort
|
||||||
|
return await get_client().get_list(
|
||||||
|
f"/repos/{owner}/{repo}/pulls",
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(annotations=_READ_ONLY)
|
||||||
|
async def get_pull_request(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
pull_number: Annotated[int, Field(description="Pull request number (the #N in the URL)")],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get a single pull request by number, including its discussion comments.
|
||||||
|
|
||||||
|
Mirrors :func:`gitea_mcp.tools.issues.get_issue`'s shape: returns the full
|
||||||
|
Gitea PullRequest object with an additional ``comments_list`` field
|
||||||
|
containing the issue-style comment thread. Inline review comments (those
|
||||||
|
attached to specific diff lines) are NOT included — those live under
|
||||||
|
``/pulls/{n}/reviews`` and are out of scope for this tool.
|
||||||
|
"""
|
||||||
|
client = get_client()
|
||||||
|
pr = await client.get_json(f"/repos/{owner}/{repo}/pulls/{pull_number}")
|
||||||
|
pr["comments_list"] = await client.get_list(
|
||||||
|
f"/repos/{owner}/{repo}/issues/{pull_number}/comments"
|
||||||
|
)
|
||||||
|
return pr
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=False,
|
||||||
|
destructiveHint=False,
|
||||||
|
openWorldHint=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def add_comment_on_pr(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
pull_number: Annotated[int, Field(description="Pull request number")],
|
||||||
|
body: Annotated[str, Field(description="Comment body (Markdown)")],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Add a comment to a pull request's discussion thread.
|
||||||
|
|
||||||
|
Posts to ``/repos/{owner}/{repo}/issues/{pull_number}/comments`` — Gitea's
|
||||||
|
issue-comments endpoint also serves PR conversation comments (PRs and
|
||||||
|
issues share the number namespace and comment infrastructure). For inline
|
||||||
|
diff-line review comments, a separate ``/pulls/{n}/reviews``-based tool
|
||||||
|
would be needed; that's deliberately out of scope here.
|
||||||
|
"""
|
||||||
|
return await get_client().post_json(
|
||||||
|
f"/repos/{owner}/{repo}/issues/{pull_number}/comments",
|
||||||
|
json={"body": body},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=False,
|
||||||
|
destructiveHint=True,
|
||||||
|
openWorldHint=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def merge_pr(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
pull_number: Annotated[int, Field(description="Pull request number")],
|
||||||
|
do: Annotated[
|
||||||
|
str,
|
||||||
|
Field(
|
||||||
|
description=(
|
||||||
|
"Merge strategy: 'merge', 'rebase', 'rebase-merge', or 'squash'."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
] = "merge",
|
||||||
|
merge_title_field: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description=(
|
||||||
|
"Optional merge commit title. Ignored by strategies that do not create "
|
||||||
|
"a merge commit."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
merge_message_field: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description=(
|
||||||
|
"Optional merge commit message body. Ignored by strategies that do not "
|
||||||
|
"create a merge commit."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Merge a pull request.
|
||||||
|
|
||||||
|
Calls ``POST /repos/{owner}/{repo}/pulls/{pull_number}/merge`` using the
|
||||||
|
requested merge strategy and optional commit-message overrides. This is
|
||||||
|
tagged ``destructiveHint=True`` because it changes repository history and
|
||||||
|
closes the pull request.
|
||||||
|
"""
|
||||||
|
payload: dict[str, Any] = {"Do": do}
|
||||||
|
if merge_title_field is not None:
|
||||||
|
payload["MergeTitleField"] = merge_title_field
|
||||||
|
if merge_message_field is not None:
|
||||||
|
payload["MergeMessageField"] = merge_message_field
|
||||||
|
|
||||||
|
return await get_client().post_json(
|
||||||
|
f"/repos/{owner}/{repo}/pulls/{pull_number}/merge",
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""MCP tools for Gitea releases."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from gitea_mcp._app import get_client, mcp
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True)
|
||||||
|
)
|
||||||
|
async def list_releases(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
page: Annotated[int, Field(description="Page number (1-indexed)")] = 1,
|
||||||
|
limit: Annotated[int, Field(description="Items per page (max 50)")] = 30,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""List releases for a repository.
|
||||||
|
|
||||||
|
Returns the standard Gitea Release object array, including drafts and
|
||||||
|
pre-releases. Sort order is newest first.
|
||||||
|
"""
|
||||||
|
return await get_client().get_list(
|
||||||
|
f"/repos/{owner}/{repo}/releases",
|
||||||
|
params={"page": page, "limit": limit},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=False,
|
||||||
|
# Creates a release AND (per docstring warning) creates the underlying
|
||||||
|
# git tag if it doesn't exist. That tag-creation side effect is what
|
||||||
|
# makes this not idempotent (re-running with same tag_name errors).
|
||||||
|
destructiveHint=False,
|
||||||
|
idempotentHint=False,
|
||||||
|
openWorldHint=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def create_release(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
tag_name: Annotated[
|
||||||
|
str,
|
||||||
|
Field(
|
||||||
|
description=(
|
||||||
|
"Tag this release is based on. If the tag does not already exist "
|
||||||
|
"in the repository, Gitea creates it at the time of release."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
name: Annotated[str, Field(description="Release title")],
|
||||||
|
body: Annotated[str, Field(description="Release notes in Markdown")] = "",
|
||||||
|
target_commitish: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description=(
|
||||||
|
"Branch name or commit SHA the tag should point at. "
|
||||||
|
"Defaults to the repository's default branch. "
|
||||||
|
"Ignored if the tag already exists."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
draft: Annotated[
|
||||||
|
bool,
|
||||||
|
Field(description="Save as draft without publishing"),
|
||||||
|
] = False,
|
||||||
|
prerelease: Annotated[
|
||||||
|
bool,
|
||||||
|
Field(description="Mark as a pre-release"),
|
||||||
|
] = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new release in a repository.
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
Side effects: if ``tag_name`` does not already exist in the repository,
|
||||||
|
Gitea creates the tag at the current ``target_commitish`` (or default
|
||||||
|
branch). Creating a draft does NOT skip tag creation — both drafts and
|
||||||
|
published releases will leave a tag in the repo.
|
||||||
|
"""
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"tag_name": tag_name,
|
||||||
|
"name": name,
|
||||||
|
"body": body,
|
||||||
|
"draft": draft,
|
||||||
|
"prerelease": prerelease,
|
||||||
|
}
|
||||||
|
if target_commitish is not None:
|
||||||
|
payload["target_commitish"] = target_commitish
|
||||||
|
return await get_client().post_json(
|
||||||
|
f"/repos/{owner}/{repo}/releases", json=payload
|
||||||
|
)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""MCP tools for Gitea repository metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from gitea_mcp._app import get_client, mcp
|
||||||
|
from gitea_mcp.client import GiteaAPIError
|
||||||
|
|
||||||
|
_READ_ONLY = ToolAnnotations(readOnlyHint=True, openWorldHint=True)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(annotations=_READ_ONLY)
|
||||||
|
async def list_repos(
|
||||||
|
owner: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(
|
||||||
|
description=(
|
||||||
|
"Username or organization to list repos for. "
|
||||||
|
"Leave empty to list repositories accessible to the authenticated user."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
page: Annotated[int, Field(description="Page number (1-indexed)")] = 1,
|
||||||
|
limit: Annotated[int, Field(description="Items per page (max 50)")] = 30,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""List repositories.
|
||||||
|
|
||||||
|
Three modes:
|
||||||
|
|
||||||
|
- ``owner`` empty: returns repositories accessible to the authenticated user
|
||||||
|
(``GET /user/repos``).
|
||||||
|
- ``owner`` is a user: returns that user's repositories
|
||||||
|
(``GET /users/{owner}/repos``).
|
||||||
|
- ``owner`` is an organization: returns that org's repositories
|
||||||
|
(``GET /orgs/{owner}/repos`` — automatically tried as a fallback when the
|
||||||
|
user endpoint 404s, so callers don't need to know which it is).
|
||||||
|
"""
|
||||||
|
client = get_client()
|
||||||
|
params: dict[str, Any] = {"page": page, "limit": limit}
|
||||||
|
|
||||||
|
if not owner:
|
||||||
|
return await client.get_list("/user/repos", params=params)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await client.get_list(f"/users/{owner}/repos", params=params)
|
||||||
|
except GiteaAPIError as e:
|
||||||
|
if e.status_code == 404:
|
||||||
|
# Owner is likely an organization — fall back transparently.
|
||||||
|
return await client.get_list(f"/orgs/{owner}/repos", params=params)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(annotations=_READ_ONLY)
|
||||||
|
async def list_labels(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
page: Annotated[int, Field(description="Page number (1-indexed)")] = 1,
|
||||||
|
limit: Annotated[int, Field(description="Items per page (max 50)")] = 30,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""List labels defined in a repository.
|
||||||
|
|
||||||
|
Returns the standard Gitea Label object: ``{id, name, color, description, ...}``.
|
||||||
|
Use the ``id`` values when calling tools that take ``label_ids`` directly;
|
||||||
|
most tools accept label *names* and resolve to IDs internally.
|
||||||
|
"""
|
||||||
|
return await get_client().get_list(
|
||||||
|
f"/repos/{owner}/{repo}/labels",
|
||||||
|
params={"page": page, "limit": limit},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(annotations=_READ_ONLY)
|
||||||
|
async def list_milestones(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
state: Annotated[
|
||||||
|
str, Field(description="Filter by state: 'open', 'closed', or 'all'")
|
||||||
|
] = "open",
|
||||||
|
page: Annotated[int, Field(description="Page number (1-indexed)")] = 1,
|
||||||
|
limit: Annotated[int, Field(description="Items per page (max 50)")] = 30,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""List milestones in a repository, optionally filtered by state."""
|
||||||
|
return await get_client().get_list(
|
||||||
|
f"/repos/{owner}/{repo}/milestones",
|
||||||
|
params={"state": state, "page": page, "limit": limit},
|
||||||
|
)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""MCP tools for introspecting the running gitea-mcp server.
|
||||||
|
|
||||||
|
These are *runtime* introspection tools — callable from any MCP client (e.g.
|
||||||
|
Claude during a conversation) to ask the server about itself. Different from
|
||||||
|
the ``gitea-mcp doctor`` CLI command, which is a pre-launch check the human
|
||||||
|
runs in a shell before pointing an MCP client at the server.
|
||||||
|
|
||||||
|
Why this exists:
|
||||||
|
|
||||||
|
* **Multi-instance disambiguation.** A user may have several gitea-mcp
|
||||||
|
servers configured in their MCP client (e.g. personal Gitea + work Gitea).
|
||||||
|
Calling ``get_server_info`` lets Claude tell them apart by URL and user.
|
||||||
|
* **Version-aware debugging.** When a behavior diverges from the documented
|
||||||
|
contract, the LLM can ask the server its version directly rather than
|
||||||
|
guessing.
|
||||||
|
* **Self-awareness for upgrade prompts.** A future client can compare the
|
||||||
|
running version against the latest on PyPI to nudge users to upgrade.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
|
||||||
|
from gitea_mcp import __version__
|
||||||
|
from gitea_mcp._app import get_client, mcp
|
||||||
|
from gitea_mcp.client import GiteaAPIError
|
||||||
|
|
||||||
|
_READ_ONLY = ToolAnnotations(readOnlyHint=True, openWorldHint=True)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False))
|
||||||
|
async def get_server_version() -> dict[str, str]:
|
||||||
|
"""Return the running gitea-mcp server's version.
|
||||||
|
|
||||||
|
No network call — just reports the package version. ``openWorldHint`` is
|
||||||
|
``False`` for this tool only, since it doesn't touch the Gitea instance
|
||||||
|
or any external system.
|
||||||
|
"""
|
||||||
|
return {"gitea_mcp_version": __version__}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(annotations=_READ_ONLY)
|
||||||
|
async def get_server_info() -> dict[str, Any]:
|
||||||
|
"""Return information about the running gitea-mcp server and its Gitea connection.
|
||||||
|
|
||||||
|
Performs two Gitea API calls — ``GET /user`` (to identify the authenticated
|
||||||
|
user the PAT belongs to) and ``GET /version`` (to report the Gitea instance
|
||||||
|
version). If ``/version`` is unavailable (older Gitea, or the PAT lacks the
|
||||||
|
scope), ``gitea_version`` falls back to ``None`` rather than raising.
|
||||||
|
|
||||||
|
Returns a dict with:
|
||||||
|
|
||||||
|
* ``gitea_mcp_version`` — the gitea-mcp package version (e.g. ``"0.4.1"``).
|
||||||
|
* ``gitea_url`` — the Gitea base URL this server is configured against.
|
||||||
|
* ``gitea_user`` — the ``login`` of the authenticated Gitea user.
|
||||||
|
* ``gitea_version`` — the Gitea instance version string, or ``None`` if
|
||||||
|
unavailable.
|
||||||
|
"""
|
||||||
|
client = get_client()
|
||||||
|
user = await client.get_json("/user")
|
||||||
|
gitea_version: str | None = None
|
||||||
|
try:
|
||||||
|
version_payload = await client.get_json("/version")
|
||||||
|
raw_version = version_payload.get("version")
|
||||||
|
if isinstance(raw_version, str):
|
||||||
|
gitea_version = raw_version
|
||||||
|
except GiteaAPIError:
|
||||||
|
# /version isn't always available (older Gitea, or PAT lacks scope).
|
||||||
|
# Don't fail the whole introspection over it.
|
||||||
|
gitea_version = None
|
||||||
|
|
||||||
|
login = user.get("login")
|
||||||
|
return {
|
||||||
|
"gitea_mcp_version": __version__,
|
||||||
|
"gitea_url": client.base_url,
|
||||||
|
"gitea_user": login if isinstance(login, str) else None,
|
||||||
|
"gitea_version": gitea_version,
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""MCP tools for Gitea repository wikis."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
from typing import Annotated, Any
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from gitea_mcp._app import get_client, mcp
|
||||||
|
from gitea_mcp.client import GiteaAPIError
|
||||||
|
|
||||||
|
_READ_ONLY = ToolAnnotations(readOnlyHint=True, openWorldHint=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _wiki_page_path(owner: str, repo: str, page_name: str) -> str:
|
||||||
|
"""Build a wiki-page API path without allowing page names to add segments."""
|
||||||
|
encoded_page_name = quote(page_name, safe="")
|
||||||
|
return f"/repos/{owner}/{repo}/wiki/page/{encoded_page_name}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_wiki_page_name(owner: str, repo: str, page_name: str) -> str:
|
||||||
|
"""Resolve a display title to Gitea's canonical ``sub_url`` value."""
|
||||||
|
resolved_name, _ = await _resolve_wiki_page(owner, repo, page_name)
|
||||||
|
return resolved_name
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_wiki_page(owner: str, repo: str, page_name: str) -> tuple[str, str | None]:
|
||||||
|
"""Resolve a page name and return its canonical name and display title."""
|
||||||
|
client = get_client()
|
||||||
|
page_number = 1
|
||||||
|
while True:
|
||||||
|
pages = await client.get_list(
|
||||||
|
f"/repos/{owner}/{repo}/wiki/pages",
|
||||||
|
params={"page": page_number, "limit": 50},
|
||||||
|
)
|
||||||
|
for page in pages:
|
||||||
|
if page.get("title") == page_name or page.get("sub_url") == page_name:
|
||||||
|
sub_url = page.get("sub_url")
|
||||||
|
title = page.get("title")
|
||||||
|
resolved_name = sub_url if isinstance(sub_url, str) and sub_url else page_name
|
||||||
|
resolved_title = title if isinstance(title, str) and title else None
|
||||||
|
return resolved_name, resolved_title
|
||||||
|
if len(pages) < 50:
|
||||||
|
return page_name, None
|
||||||
|
page_number += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _with_decoded_content(page: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Add a best-effort UTF-8 ``text`` field to a Gitea WikiPage object."""
|
||||||
|
text: str | None = None
|
||||||
|
content = page.get("content_base64")
|
||||||
|
if isinstance(content, str):
|
||||||
|
try:
|
||||||
|
text = base64.b64decode(content, validate=True).decode("utf-8")
|
||||||
|
except (binascii.Error, UnicodeDecodeError):
|
||||||
|
text = None
|
||||||
|
page["text"] = text
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(annotations=_READ_ONLY)
|
||||||
|
async def list_wiki_pages(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
page: Annotated[int, Field(description="Page number (1-indexed)")] = 1,
|
||||||
|
limit: Annotated[int, Field(description="Items per page (max 50)")] = 30,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""List wiki pages in a repository.
|
||||||
|
|
||||||
|
Returns Gitea ``WikiPageMetaData`` objects. Page content is intentionally
|
||||||
|
omitted by the list endpoint; use :func:`get_wiki_page` for Markdown.
|
||||||
|
"""
|
||||||
|
client = get_client()
|
||||||
|
try:
|
||||||
|
return await client.get_list(
|
||||||
|
f"/repos/{owner}/{repo}/wiki/pages",
|
||||||
|
params={"page": page, "limit": limit},
|
||||||
|
)
|
||||||
|
except GiteaAPIError as exc:
|
||||||
|
if exc.status_code != 404:
|
||||||
|
raise
|
||||||
|
repository = await client.get_json(f"/repos/{owner}/{repo}")
|
||||||
|
if repository.get("has_wiki") is True:
|
||||||
|
# Gitea returns 404 until the wiki git repository is initialized.
|
||||||
|
return []
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(annotations=_READ_ONLY)
|
||||||
|
async def get_wiki_page(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
page_name: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="Wiki page name or title", min_length=1),
|
||||||
|
],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Read one wiki page.
|
||||||
|
|
||||||
|
Returns Gitea's complete ``WikiPage`` object plus ``text``, a best-effort
|
||||||
|
UTF-8 decoding of ``content_base64``. ``text`` is ``None`` if the content
|
||||||
|
is not valid Base64-encoded UTF-8.
|
||||||
|
"""
|
||||||
|
client = get_client()
|
||||||
|
try:
|
||||||
|
result = await client.get_json(_wiki_page_path(owner, repo, page_name))
|
||||||
|
except GiteaAPIError as exc:
|
||||||
|
if exc.status_code != 404:
|
||||||
|
raise
|
||||||
|
resolved_name = await _resolve_wiki_page_name(owner, repo, page_name)
|
||||||
|
if resolved_name == page_name:
|
||||||
|
raise
|
||||||
|
result = await client.get_json(_wiki_page_path(owner, repo, resolved_name))
|
||||||
|
return _with_decoded_content(result)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(annotations=_READ_ONLY)
|
||||||
|
async def list_wiki_revisions(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
page_name: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="Wiki page name or title", min_length=1),
|
||||||
|
],
|
||||||
|
page: Annotated[int, Field(description="Page number (1-indexed)")] = 1,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""List revision metadata for one wiki page.
|
||||||
|
|
||||||
|
Returns Gitea's ``WikiCommitList`` object containing ``count`` and the
|
||||||
|
current page of ``commits``. Gitea 1.25 exposes a page parameter but no
|
||||||
|
configurable page-size parameter for this endpoint.
|
||||||
|
"""
|
||||||
|
encoded_page_name = quote(page_name, safe="")
|
||||||
|
return await get_client().get_json(
|
||||||
|
f"/repos/{owner}/{repo}/wiki/revisions/{encoded_page_name}",
|
||||||
|
params={"page": page},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=False,
|
||||||
|
destructiveHint=False,
|
||||||
|
idempotentHint=False,
|
||||||
|
openWorldHint=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def create_wiki_page(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
title: Annotated[str, Field(description="New wiki page title", min_length=1)],
|
||||||
|
content: Annotated[str, Field(description="Wiki page content as UTF-8 Markdown")],
|
||||||
|
message: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="Optional commit message summarizing the change"),
|
||||||
|
] = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a wiki page.
|
||||||
|
|
||||||
|
The tool accepts plain UTF-8 Markdown and performs Gitea's required Base64
|
||||||
|
encoding locally. Repeating the same request is not idempotent because the
|
||||||
|
page already exists after the first successful call.
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"title": title,
|
||||||
|
"content_base64": base64.b64encode(content.encode("utf-8")).decode("ascii"),
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
result = await get_client().post_json(
|
||||||
|
f"/repos/{owner}/{repo}/wiki/new",
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
return _with_decoded_content(result)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=False,
|
||||||
|
destructiveHint=True,
|
||||||
|
idempotentHint=False,
|
||||||
|
openWorldHint=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def update_wiki_page(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
page_name: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="Current wiki page name or title", min_length=1),
|
||||||
|
],
|
||||||
|
content: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="Complete replacement content as UTF-8 Markdown"),
|
||||||
|
],
|
||||||
|
title: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Optional new page title; omit to keep the current title"),
|
||||||
|
] = None,
|
||||||
|
message: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="Optional commit message summarizing the change"),
|
||||||
|
] = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Replace a wiki page's content and optionally rename it.
|
||||||
|
|
||||||
|
``content`` is deliberately required: callers must first read the page and
|
||||||
|
submit the complete desired Markdown, preventing an omitted field from
|
||||||
|
accidentally clearing content on Gitea versions with different defaults.
|
||||||
|
"""
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"content_base64": base64.b64encode(content.encode("utf-8")).decode("ascii"),
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
client = get_client()
|
||||||
|
resolved_name = page_name
|
||||||
|
if title is None:
|
||||||
|
resolved_name, current_title = await _resolve_wiki_page(owner, repo, page_name)
|
||||||
|
# Gitea 1.25 renames a page to "unnamed" when PATCH omits title.
|
||||||
|
payload["title"] = current_title or page_name
|
||||||
|
else:
|
||||||
|
payload["title"] = title
|
||||||
|
try:
|
||||||
|
result = await client.patch_json(
|
||||||
|
_wiki_page_path(owner, repo, resolved_name),
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
except GiteaAPIError as exc:
|
||||||
|
if exc.status_code != 404:
|
||||||
|
raise
|
||||||
|
resolved_name = await _resolve_wiki_page_name(owner, repo, page_name)
|
||||||
|
if resolved_name == page_name:
|
||||||
|
raise
|
||||||
|
result = await client.patch_json(
|
||||||
|
_wiki_page_path(owner, repo, resolved_name),
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
return _with_decoded_content(result)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=False,
|
||||||
|
destructiveHint=True,
|
||||||
|
idempotentHint=True,
|
||||||
|
openWorldHint=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def delete_wiki_page(
|
||||||
|
owner: Annotated[str, Field(description="Repository owner")],
|
||||||
|
repo: Annotated[str, Field(description="Repository name")],
|
||||||
|
page_name: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="Wiki page name or title to delete", min_length=1),
|
||||||
|
],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Delete a wiki page.
|
||||||
|
|
||||||
|
This is a destructive operation and Gitea returns no response body. The
|
||||||
|
returned object confirms which page name was sent after the API succeeds.
|
||||||
|
"""
|
||||||
|
client = get_client()
|
||||||
|
try:
|
||||||
|
await client.delete(_wiki_page_path(owner, repo, page_name))
|
||||||
|
except GiteaAPIError as exc:
|
||||||
|
if exc.status_code != 404:
|
||||||
|
raise
|
||||||
|
resolved_name = await _resolve_wiki_page_name(owner, repo, page_name)
|
||||||
|
if resolved_name == page_name:
|
||||||
|
raise
|
||||||
|
await client.delete(_wiki_page_path(owner, repo, resolved_name))
|
||||||
|
return {"deleted": True, "page_name": page_name}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$EnvFile = $(
|
||||||
|
if ($env:GITEA_ENV_FILE) {
|
||||||
|
$env:GITEA_ENV_FILE
|
||||||
|
} else {
|
||||||
|
Join-Path $HOME ".codex/gitea.env"
|
||||||
|
}
|
||||||
|
),
|
||||||
|
[string]$Version = "0.5.1",
|
||||||
|
[switch]$CheckConfig,
|
||||||
|
[Parameter(ValueFromRemainingArguments = $true)]
|
||||||
|
[string[]]$ServerArgs
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$utf8 = [System.Text.UTF8Encoding]::new($false)
|
||||||
|
[Console]::OutputEncoding = $utf8
|
||||||
|
$OutputEncoding = $utf8
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $EnvFile -PathType Leaf)) {
|
||||||
|
throw "Gitea MCP 配置文件不存在:$EnvFile"
|
||||||
|
}
|
||||||
|
|
||||||
|
$values = @{}
|
||||||
|
foreach ($rawLine in Get-Content -LiteralPath $EnvFile) {
|
||||||
|
$line = $rawLine.Trim()
|
||||||
|
if (-not $line -or $line.StartsWith("#")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$pair = $line -split "=", 2
|
||||||
|
if ($pair.Count -ne 2) {
|
||||||
|
throw "Gitea MCP 配置行必须使用 KEY=VALUE 格式。"
|
||||||
|
}
|
||||||
|
|
||||||
|
$values[$pair[0].Trim()] = $pair[1].Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($name in @("GITEA_URL", "GITEA_TOKEN")) {
|
||||||
|
if (-not $values.ContainsKey($name) -or [string]::IsNullOrWhiteSpace($values[$name])) {
|
||||||
|
throw "$name 未配置或为空。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$giteaUri = [Uri]$values["GITEA_URL"]
|
||||||
|
} catch {
|
||||||
|
throw "GITEA_URL 不是有效 URL。"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($giteaUri.Scheme -notin @("http", "https")) {
|
||||||
|
throw "GITEA_URL 只支持 http 或 https。"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($giteaUri.AbsolutePath.Trim("/") -ne "") {
|
||||||
|
throw "GITEA_URL 必须填写实例根地址,不要包含 /api/v1;gitea-mcp 会自动追加 API 路径。"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($giteaUri.Scheme -eq "http" -and $values["GITEA_ALLOW_INSECURE_HTTP"] -ne "1") {
|
||||||
|
throw "当前使用 HTTP。确认接受 Token 明文传输风险后,在私有配置中设置 GITEA_ALLOW_INSECURE_HTTP=1。"
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($entry in $values.GetEnumerator()) {
|
||||||
|
if ($entry.Key -like "GITEA_*") {
|
||||||
|
Set-Item -Path "Env:$($entry.Key)" -Value $entry.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$noProxyEntries = @($env:NO_PROXY -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||||
|
if ($noProxyEntries -notcontains $giteaUri.Host) {
|
||||||
|
$noProxyEntries += $giteaUri.Host
|
||||||
|
}
|
||||||
|
$env:NO_PROXY = $noProxyEntries -join ","
|
||||||
|
$env:no_proxy = $env:NO_PROXY
|
||||||
|
|
||||||
|
if ($values["GITEA_DIRECT"] -eq "1") {
|
||||||
|
foreach ($proxyVariable in @("ALL_PROXY", "all_proxy", "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy")) {
|
||||||
|
Remove-Item -Path "Env:$proxyVariable" -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($CheckConfig) {
|
||||||
|
Write-Output "Gitea MCP 配置有效:URL=$($giteaUri.GetLeftPart([UriPartial]::Authority)),Token 已设置,版本=$Version。"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$uvx = Get-Command uvx -ErrorAction Stop
|
||||||
|
$stderrLog = Join-Path ([IO.Path]::GetTempPath()) "gitea-mcp-$PID.stderr.log"
|
||||||
|
& $uvx.Source --from "gitea-mcp==$Version" gitea-mcp @ServerArgs 2>> $stderrLog
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
|
||||||
Reference in New Issue
Block a user