Compare commits

...

21 Commits

Author SHA1 Message Date
chaim a519798e69 fix(security): require Gitea tokens via env vars
Removed BUILTIN_TOKEN (dev) and the hardcoded prod/nautilus tokens
from BUILTIN_SERVERS. install.sh now refuses to run if
GITEA_DEFAULT_TOKEN, GITEA_PROD_TOKEN, or GITEA_NAUTILUS_TOKEN env
vars are missing.

Why: discovered during the LegalCrm 2026-04-25 security audit. Per
the Infisical-only secrets policy, tokens MUST come from Infisical
(/gitea per-environment → API_TOKEN), never baked into source. The
script was previously a public credential dump for all three
production Gitea instances.

Run with the env vars set before re-installing:
  GITEA_DEFAULT_TOKEN=<dev> GITEA_PROD_TOKEN=<prod> GITEA_NAUTILUS_TOKEN=<nautilus> ./install.sh

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:49:23 +00:00
chaim 12f0e872c1 fix: update install.sh URLs from Chaim/gitea-mcp to mcp-servers/gitea-mcp 2026-04-04 20:03:46 +00:00
chaim 9d41be6c4f fix: update repo URLs and config path after transfer to mcp-servers org
- REPO_URL: Chaim/gitea-mcp → mcp-servers/gitea-mcp
- curl install URL in header and footer
- Config output: $CLAUDE_SETTINGS → $CLAUDE_JSON (correct file for MCP)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:16:07 +00:00
chaim 8a314a8a5b fix: write MCP config to ~/.claude.json instead of settings.json
Claude Code looks for global MCP servers in ~/.claude.json, not in
~/.claude/settings.json. Also migrates any existing entry from the
legacy location and cleans up duplicates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:45:59 +00:00
chaim f9cefeecd8 feat: default to non-interactive install (AUTO_YES=1)
All servers and tokens are built-in, so no prompts needed by default.
Set AUTO_YES=0 to restore interactive mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:38:32 +00:00
chaim f5837d0569 feat: auto-configure all 3 Gitea servers (dev, prod, nautilus)
Install script now includes built-in config for all servers with tokens
from Infisical. No prompts needed with AUTO_YES=1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:36:16 +00:00
chaim 943bc4a063 fix: skip additional servers prompt when AUTO_YES=1
The auto-yes flag was entering the add-servers loop and prompting for
input. Now breaks out of the loop immediately in non-interactive mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:35:13 +00:00
chaim a2eeab9719 feat: auto-configure default Gitea server and token
No longer prompts for host/token during installation - uses built-in
defaults for gitea.dev.marcus-law.co.il. Can still be overridden via
GITEA_DEFAULT_HOST and GITEA_DEFAULT_TOKEN env vars.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:32:51 +00:00
chaim 842502ca5a feat: auto-install missing prerequisites (go, git, jq)
Install script now detects missing dependencies and installs them
automatically via apt/dnf/yum/pacman/apk/brew. Go is installed
directly from go.dev if missing or too old.

Also added note about global config path in README.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:06:54 +00:00
chaim 3ac04e69a8 feat: add install script and rewrite README in Hebrew
One-liner install: curl | bash that builds from source, configures
global MCP in ~/.claude/settings.json, handles duplicates, and
supports interactive multi-server setup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:04:08 +00:00
chaim 58d2feb54a feat: add multi-server support via GITEA_SERVERS env var
Allows connecting to multiple Gitea instances from a single MCP server.
Set GITEA_SERVERS as a JSON object mapping names to {host, token, insecure}
configs. Every tool gets an optional "server" parameter, and a new
"list_servers" tool is added to discover available connections.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:28:40 +00:00
chaim 4b4b5ee247 feat: add missing administration tools
Add 20+ new MCP tools for repository administration:

- Repository: get_repo, repo_write (edit/delete/transfer), list_forks
- Branch Protection: read (list/get) and write (create/edit/delete)
- Webhooks: read (list/get) and write (create/edit/delete/test)
- Collaborators: read (list) and write (add/delete)
- Deploy Keys: read (list/get) and write (create/delete)
- Topics: read (list) and write (add/delete/set)
- Compare: get_compare for branch/tag/commit comparison
- Organization: org_read (get/list_teams/get_team/list_team_members/list_team_repos)
  and org_write (create_team/delete_team/add_team_member/remove_team_member/add_team_repo/remove_team_repo)
- PR enhancements: list_files and is_merged methods added to pull_request_read

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:41:36 +00:00
silverwind 08128b9471 Add notification_read and notification_write tools (#172)
Add notification management via two new MCP tools using the method-dispatch pattern:

- `notification_read`: list notifications (global or repo-scoped, with status/subject_type/since/before filters) and get single notification thread by ID
- `notification_write`: mark single notification as read, mark all notifications as read (global or repo-scoped)

---
This PR was written with the help of Claude Opus 4.6

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/172
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-04-02 16:47:24 +00:00
pengu 133fe487cd Add missing ref and deadline fields to issue responses (#169)
## Summary

### Read (issue responses)
- Add `ref` (branch) field to issue responses when non-empty
- Add `deadline` (due_date) field to issue responses when non-nil
- Applied to `slimIssue()`, `slimIssues()` in `operation/issue/slim.go` and `slimIssues()` in `operation/search/slim.go`

### Write (issue_write)
- Add `ref` parameter to `issue_write` tool for both `create` and `update` methods
- Allows setting the branch reference on issues via the MCP, consistent with the SDK's `CreateIssueOption.Ref` and `EditIssueOption.Ref` fields

Fixes #168

## Test plan
- [ ] `go test ./...` passes
- [ ] Verify `issue_read` returns `ref` when a branch is assigned to an issue
- [ ] Verify `issue_read` returns `deadline` when a due date is set
- [ ] Verify `list_issues` and `search_issues` include these fields
- [ ] Verify `issue_write` with method `update` can set `ref` on an issue
- [ ] Verify `issue_write` with method `create` can set `ref` on a new issue

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/169
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: pengu <jeremy@wenjy.fr>
Co-committed-by: pengu <jeremy@wenjy.fr>
2026-03-26 18:37:57 +00:00
silverwind 05682e2afa Document server-side pagination limit in README (#166)
The maximum effective page size for paginated tools is determined by the Gitea server's [`[api].MAX_RESPONSE_ITEMS`](https://github.com/go-gitea/gitea/blob/ffa626b585225d62718f39e1b5fcc00416b0b7e4/custom/conf/app.example.ini#L2471-L2472) setting (default: 50). Requesting a `perPage` value higher than this limit will be silently capped by the server.

Fixes https://gitea.com/gitea/gitea-mcp/issues/165

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/166
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-03-26 17:15:33 +00:00
silverwind a5dd03c7f0 Add missing tool parameters from Gitea SDK (#164)
Expose additional parameters that the Gitea SDK supports but were not yet wired through the MCP tool definitions.

**Motivation:** Systematic comparison against github-mcp-server and the Gitea SDK revealed several supported parameters that were missing from tool schemas.

- `list_issues`: `labels`, `since`, `before` filters
- `issue_write`: `labels` and `deadline` on create, `deadline`/`remove_deadline` on update
- `pull_request_write`: `labels`/`deadline` on create/update, `remove_deadline` on update, `force_merge`/`merge_when_checks_succeed`/`head_commit_id` on merge
- `list_branches`: `page`/`perPage` pagination
- `create_repo`: `trust_model`, `object_format_name`
- `label_write`: `is_archived` on create/edit

**Testing:** Added tests for issue list filters, issue create labels/deadline, PR create labels/deadline, and PR merge new params.

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/164
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-03-26 07:00:14 +00:00
silverwind 9056a5ef27 Add get_commit, get_repository_tree, and search_issues tools (#162)
Add three new read-only tools inspired by the GitHub MCP server:

- `get_commit`: Get details of a specific commit by SHA, branch, or tag
- `get_repository_tree`: Get the file tree of a repository with optional recursive traversal, pagination, and ref support
- `search_issues`: Search issues and pull requests across all accessible repositories with filters for state, type, labels, and owner

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/162
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-03-24 17:12:58 +00:00
silverwind c8004e9198 Update mcp-go to v0.45.0 (#163)
Update github.com/mark3labs/mcp-go from v0.44.0 to v0.45.0.

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/163
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-03-24 17:07:40 +00:00
tomholford 6a3ce66e09 feat(pull): add draft parameter for creating/updating draft PRs (#159)
## Summary

The Gitea API has no native `draft` field on `CreatePullRequestOption` or `EditPullRequestOption`. Instead, Gitea treats PRs whose title starts with a WIP prefix (e.g. `WIP:`, `[WIP]`) as drafts. This adds a `draft` boolean parameter to the `pull_request_write` tool so MCP clients can create/update draft PRs without knowing about the WIP prefix convention.

## Changes

- Add `draft` boolean parameter to `PullRequestWriteTool` schema, supported on `create` and `update` methods
- Add `applyDraftPrefix()` helper that handles both default Gitea WIP prefixes (`WIP:`, `[WIP]`) case-insensitively
- When `draft=true` and no prefix exists, prepend `WIP: `; when a prefix already exists, preserve the title as-is (no normalization)
- When `draft=false`, strip any recognized WIP prefix
- On `update`, if `draft` is set without `title`, auto-fetch the current PR title via GET
- Add tests: 12 unit tests for `applyDraftPrefix`, 5 integration tests for create, 4 for edit

---------

Co-authored-by: tomholford <tomholford@users.noreply.github.com>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/159
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: tomholford <128995+tomholford@noreply.gitea.com>
Co-committed-by: tomholford <128995+tomholford@noreply.gitea.com>
2026-03-23 11:53:00 +00:00
silverwind 0bdf8f5bb3 wiki: accept plain text content instead of content_base64 (#156)
The `wiki_write` tool exposed the Gitea API's `content_base64` parameter directly, requiring callers to provide base64-encoded content. This caused LLM agents to incorrectly infer that other tools like `create_or_update_file` also require base64 encoding, corrupting files with literal base64 strings.

Rename the parameter to `content` (plain text) and handle base64 encoding internally, matching the pattern already used by `create_or_update_file`. Also added test coverage for this.

Closes #151

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/156
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-03-14 01:49:40 +00:00
silverwind 7bf54b9e83 Use debug.ReadBuildInfo to determine version for go run (#155)
*Written by Claude on behalf of @silverwind*

When installed via `go run module@version` or `go install module@version`, the Go toolchain embeds the module version in the binary. Use `debug.ReadBuildInfo` to read it as a fallback when `Version` has not been set via ldflags, so that `go run gitea.com/gitea/gitea-mcp@latest` will reports the latest tag version instead of `dev`.

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/155
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-03-13 19:56:42 +00:00
50 changed files with 4323 additions and 334 deletions
+1
View File
@@ -3,3 +3,4 @@ gitea-mcp
gitea-mcp.exe
*.log
tmp
gitea-mcp-new
+10 -6
View File
@@ -8,7 +8,7 @@ GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1
GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2
.PHONY: help
help: ## Print this help message.
help: ## print this help message
@echo "Usage: make [target]"
@echo ""
@echo "Targets:"
@@ -16,7 +16,7 @@ help: ## Print this help message.
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: install
install: build ## Install the application.
install: build ## install the application
@echo "Installing $(EXECUTABLE)..."
@mkdir -p $(GOPATH)/bin
@cp $(EXECUTABLE) $(GOPATH)/bin/$(EXECUTABLE)
@@ -24,23 +24,23 @@ install: build ## Install the application.
@echo "Please add $(GOPATH)/bin to your PATH if it is not already there."
.PHONY: uninstall
uninstall: ## Uninstall the application.
uninstall: ## uninstall the application
@echo "Uninstalling $(EXECUTABLE)..."
@rm -f $(GOPATH)/bin/$(EXECUTABLE)
@echo "Uninstalled $(EXECUTABLE) from $(GOPATH)/bin/$(EXECUTABLE)"
.PHONY: clean
clean: ## Clean the build artifacts.
clean: ## delete build artifacts
@echo "Cleaning up build artifacts..."
@rm -f $(EXECUTABLE)
@echo "Cleaned up $(EXECUTABLE)"
.PHONY: build
build: ## Build the application.
build: ## build the application
$(GO) build -v -ldflags '-s -w $(LDFLAGS)' -o $(EXECUTABLE)
.PHONY: air
air: ## Install air for hot reload.
air: ## install air for hot reload
@hash air > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GO) install github.com/air-verse/air@latest; \
fi
@@ -49,6 +49,10 @@ air: ## Install air for hot reload.
dev: air ## run the application with hot reload
air --build.cmd "make build" --build.bin ./gitea-mcp
.PHONY: fmt
fmt: ## format the Go code
$(GO) run $(GOFUMPT_PACKAGE) -w .
.PHONY: lint
lint: lint-go ## lint everything
+84 -233
View File
@@ -1,284 +1,135 @@
# Gitea MCP Server
[繁體中文](README.zh-tw.md) | [简体中文](README.zh-cn.md)
שרת MCP (Model Context Protocol) לגיטאה — מאפשר ניהול מלא של ריפוזיטוריות, issues, pull requests, wikis, actions ועוד דרך Claude Code וכלי MCP אחרים.
**Gitea MCP Server** is an integration plugin designed to connect Gitea with Model Context Protocol (MCP) systems. This allows for seamless command execution and repository management through an MCP-compatible chat interface.
**תומך בחיבור לכמה שרתי Gitea במקביל.**
[![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22docker.gitea.com/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22docker.gitea.com/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}&quality=insiders)
## Table of Contents
- [Gitea MCP Server](#gitea-mcp-server)
- [Table of Contents](#table-of-contents)
- [What is Gitea?](#what-is-gitea)
- [What is MCP?](#what-is-mcp)
- [🚧 Installation](#-installation)
- [Usage with Claude Code](#usage-with-claude-code)
- [Usage with VS Code](#usage-with-vs-code)
- [📥 Download the official binary release](#-download-the-official-binary-release)
- [🔧 Build from Source](#-build-from-source)
- [📁 Add to PATH](#-add-to-path)
- [🚀 Usage](#-usage)
- [✅ Available Tools](#-available-tools)
- [🐛 Debugging](#-debugging)
- [🛠 Troubleshooting](#-troubleshooting)
## What is Gitea?
Gitea is a community-managed lightweight code hosting solution written in Go. It is published under the MIT license. Gitea provides Git hosting including a repository viewer, issue tracking, pull requests, and more.
## What is MCP?
Model Context Protocol (MCP) is a protocol that allows for the integration of various tools and systems through a chat interface. It enables seamless command execution and management of repositories, users, and other resources.
## 🚧 Installation
### Usage with Claude Code
This method uses `go run` and requires [Go](https://go.dev) to be installed.
## התקנה מהירה
```bash
claude mcp add --transport stdio --scope user gitea \
--env GITEA_ACCESS_TOKEN=token \
--env GITEA_HOST=https://gitea.com \
-- go run gitea.com/gitea/gitea-mcp@latest -t stdio
curl -fsSL https://gitea.dev.marcus-law.co.il/mcp-servers/gitea-mcp/raw/branch/main/install.sh | bash
```
### Usage with VS Code
הסקריפט:
- משכפל את הריפו ל-`~/.gitea-mcp`
- בונה בינארי ומתקין ל-`$GOPATH/bin`
- מגדיר MCP server גלובלי ב-`~/.claude/settings.json`
- מאפשר הוספת שרתים נוספים באופן אינטראקטיבי
- מוחק הגדרות כפולות מקבצי `.mcp.json` ברמת פרויקט
For quick installation, use one of the one-click install buttons at the top of this README.
### דרישות מוקדמות
For manual installation, add the following JSON block to your User Settings (JSON) file in VS Code. You can do this by pressing `Ctrl + Shift + P` and typing `Preferences: Open User Settings (JSON)`.
- [Go](https://go.dev) 1.24+
- git
- jq
Optionally, you can add it to a file called `.vscode/mcp.json` in your workspace. This will allow you to share the configuration with others.
### שדרוג
> Note that the `mcp` key is not needed in the `.vscode/mcp.json` file.
```json
{
"mcp": {
"inputs": [
{
"type": "promptString",
"id": "gitea_token",
"description": "Gitea Personal Access Token",
"password": true
}
],
"servers": {
"gitea-mcp": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"GITEA_ACCESS_TOKEN",
"docker.gitea.com/gitea-mcp-server"
],
"env": {
"GITEA_ACCESS_TOKEN": "${input:gitea_token}"
}
}
}
}
}
```
### 📥 Download the official binary release
You can download the official release from [official Gitea MCP binary releases](https://gitea.com/gitea/gitea-mcp/releases).
### 🔧 Build from Source
You can download the source code by cloning the repository using Git:
אותה פקודה בדיוק — הסקריפט מזהה התקנה קיימת ומעדכן:
```bash
git clone https://gitea.com/gitea/gitea-mcp.git
curl -fsSL https://gitea.dev.marcus-law.co.il/mcp-servers/gitea-mcp/raw/branch/main/install.sh | bash
```
Before building, make sure you have the following installed:
## הגדרה ידנית
- make
- Golang (Go 1.24 or later recommended)
> **חשוב:** כדי שההגדרה תהיה **גלובלית** (זמינה בכל פרויקט), יש לכתוב אותה ב-`~/.claude/settings.json` תחת `mcpServers`.
> אל תשתמשו ב-`.mcp.json` ברמת פרויקט — זה יגרום לכפילויות ויעבוד רק בתיקייה הספציפית.
Then run:
```bash
make install
```
### 📁 Add to PATH
After installing, copy the binary gitea-mcp to a directory included in your system's PATH. For example:
```bash
cp gitea-mcp /usr/local/bin/
```
## 🚀 Usage
This example is for Cursor, you can also use plugins in VSCode.
To configure the MCP server for Gitea, add the following to your MCP configuration file:
- **stdio mode**
### שרת יחיד
```json
{
"mcpServers": {
"gitea": {
"gitea-dev": {
"command": "gitea-mcp",
"args": [
"-t",
"stdio",
"--host",
"https://gitea.com"
// "--token", "<your personal access token>"
],
"args": ["-t", "stdio", "--host", "https://gitea.example.com"],
"env": {
// "GITEA_HOST": "https://gitea.com",
// "GITEA_INSECURE": "true",
"GITEA_ACCESS_TOKEN": "<your personal access token>"
"GITEA_ACCESS_TOKEN": "<token>"
}
}
}
}
```
- **http mode**
### כמה שרתים
הוסף את `GITEA_SERVERS` כ-JSON object עם שם → `{host, token, insecure}`:
```json
{
"mcpServers": {
"gitea": {
"url": "http://localhost:8080/mcp",
"headers": {
"Authorization": "Bearer <your personal access token>"
"gitea-dev": {
"command": "gitea-mcp",
"args": ["-t", "stdio", "--host", "https://gitea.example.com"],
"env": {
"GITEA_ACCESS_TOKEN": "<default-token>",
"GITEA_SERVERS": "{\"prod\":{\"host\":\"https://gitea.prod.example.com\",\"token\":\"<prod-token>\"},\"staging\":{\"host\":\"https://staging.example.com\",\"token\":\"<staging-token>\",\"insecure\":true}}"
}
}
}
}
```
**Default log path**: `$HOME/.gitea-mcp/gitea-mcp.log`
כל כלי מקבל פרמטר `server` אופציונלי:
> [!NOTE]
> You can provide your Gitea host and access token either as command-line arguments or environment variables.
> Command-line arguments have the highest priority
Once everything is set up, try typing the following in your MCP-compatible chatbox:
```text
list all my repositories
```
list_my_repos # ברירת מחדל
list_my_repos(server: "prod") # שרת ספציפי
list_servers # הצג שרתים מוגדרים
```
## ✅ Available Tools
## משתני סביבה
The Gitea MCP Server supports the following tools:
| משתנה | תיאור |
|---|---|
| `GITEA_ACCESS_TOKEN` | טוקן גישה לשרת ברירת המחדל |
| `GITEA_HOST` | כתובת שרת ברירת המחדל |
| `GITEA_SERVERS` | JSON object של שרתים נוספים |
| `GITEA_READONLY` | `true` להצגת כלי קריאה בלבד |
| `GITEA_DEBUG` | `true` למצב debug |
| `GITEA_INSECURE` | `true` לדלג על בדיקת TLS |
| `MCP_MODE` | `stdio` (ברירת מחדל) או `http` |
| Tool | Scope | Description |
| :-------------------------------: | :----------: | :------------------------------------------------------: |
| get_my_user_info | User | Get the information of the authenticated user |
| get_user_orgs | User | Get organizations associated with the authenticated user |
| create_repo | Repository | Create a new repository |
| fork_repo | Repository | Fork a repository |
| list_my_repos | Repository | List all repositories owned by the authenticated user |
| create_branch | Branch | Create a new branch |
| delete_branch | Branch | Delete a branch |
| list_branches | Branch | List all branches in a repository |
| create_release | Release | Create a new release in a repository |
| delete_release | Release | Delete a release from a repository |
| get_release | Release | Get a release |
| get_latest_release | Release | Get the latest release in a repository |
| list_releases | Release | List all releases in a repository |
| create_tag | Tag | Create a new tag |
| delete_tag | Tag | Delete a tag |
| get_tag | Tag | Get a tag |
| list_tags | Tag | List all tags in a repository |
| list_repo_commits | Commit | List all commits in a repository |
| get_file_content | File | Get the content and metadata of a file |
| get_dir_content | File | Get a list of entries in a directory |
| create_file | File | Create a new file |
| update_file | File | Update an existing file |
| delete_file | File | Delete a file |
| get_issue_by_index | Issue | Get an issue by its index |
| list_repo_issues | Issue | List all issues in a repository |
| create_issue | Issue | Create a new issue |
| create_issue_comment | Issue | Create a comment on an issue |
| edit_issue | Issue | Edit a issue |
| edit_issue_comment | Issue | Edit a comment on an issue |
| get_issue_comments_by_index | Issue | Get comments of an issue by its index |
| get_pull_request_by_index | Pull Request | Get a pull request by its index |
| get_pull_request_diff | Pull Request | Get a pull request diff |
| list_repo_pull_requests | Pull Request | List all pull requests in a repository |
| create_pull_request | Pull Request | Create a new pull request |
| create_pull_request_reviewer | Pull Request | Add reviewers to a pull request |
| delete_pull_request_reviewer | Pull Request | Remove reviewers from a pull request |
| list_pull_request_reviews | Pull Request | List all reviews for a pull request |
| get_pull_request_review | Pull Request | Get a specific review by ID |
| list_pull_request_review_comments | Pull Request | List inline comments for a review |
| create_pull_request_review | Pull Request | Create a review with optional inline comments |
| submit_pull_request_review | Pull Request | Submit a pending review |
| delete_pull_request_review | Pull Request | Delete a review |
| dismiss_pull_request_review | Pull Request | Dismiss a review with optional message |
| merge_pull_request | Pull Request | Merge a pull request |
| search_users | User | Search for users |
| search_org_teams | Organization | Search for teams in an organization |
| list_org_labels | Organization | List labels defined at organization level |
| create_org_label | Organization | Create a label in an organization |
| edit_org_label | Organization | Edit a label in an organization |
| delete_org_label | Organization | Delete a label in an organization |
| search_repos | Repository | Search for repositories |
| list_repo_action_secrets | Actions | List repository Actions secrets (metadata only) |
| upsert_repo_action_secret | Actions | Create/update (upsert) a repository Actions secret |
| delete_repo_action_secret | Actions | Delete a repository Actions secret |
| list_org_action_secrets | Actions | List organization Actions secrets (metadata only) |
| upsert_org_action_secret | Actions | Create/update (upsert) an organization Actions secret |
| delete_org_action_secret | Actions | Delete an organization Actions secret |
| list_repo_action_variables | Actions | List repository Actions variables |
| get_repo_action_variable | Actions | Get a repository Actions variable |
| create_repo_action_variable | Actions | Create a repository Actions variable |
| update_repo_action_variable | Actions | Update a repository Actions variable |
| delete_repo_action_variable | Actions | Delete a repository Actions variable |
| list_org_action_variables | Actions | List organization Actions variables |
| get_org_action_variable | Actions | Get an organization Actions variable |
| create_org_action_variable | Actions | Create an organization Actions variable |
| update_org_action_variable | Actions | Update an organization Actions variable |
| delete_org_action_variable | Actions | Delete an organization Actions variable |
| list_repo_action_workflows | Actions | List repository Actions workflows |
| get_repo_action_workflow | Actions | Get a repository Actions workflow |
| dispatch_repo_action_workflow | Actions | Trigger (dispatch) a repository Actions workflow |
| list_repo_action_runs | Actions | List repository Actions runs |
| get_repo_action_run | Actions | Get a repository Actions run |
| cancel_repo_action_run | Actions | Cancel a repository Actions run |
| rerun_repo_action_run | Actions | Rerun a repository Actions run |
| list_repo_action_jobs | Actions | List repository Actions jobs |
| list_repo_action_run_jobs | Actions | List Actions jobs for a run |
| get_repo_action_job_log_preview | Actions | Get a job log preview (tail/limited) |
| download_repo_action_job_log | Actions | Download a job log to a file |
| get_gitea_mcp_server_version | Server | Get the version of the Gitea MCP Server |
| list_wiki_pages | Wiki | List all wiki pages in a repository |
| get_wiki_page | Wiki | Get a wiki page content and metadata |
| get_wiki_revisions | Wiki | Get revisions history of a wiki page |
| create_wiki_page | Wiki | Create a new wiki page |
| update_wiki_page | Wiki | Update an existing wiki page |
| delete_wiki_page | Wiki | Delete a wiki page |
## כלים זמינים
## 🐛 Debugging
השרת מספק 60+ כלי MCP:
To enable debug mode, add the `-d` flag when running the Gitea MCP Server with http mode:
| תחום | כלים |
|---|---|
| משתמש | `get_me`, `get_user_orgs` |
| ריפוזיטוריות | `create_repo`, `fork_repo`, `list_my_repos`, `list_org_repos`, `get_repo`, `repo_write`, `list_forks` |
| ענפים | `list_branches`, `create_branch`, `delete_branch`, `branch_protection_read/write` |
| תגיות | `list_tags`, `get_tag`, `create_tag`, `delete_tag` |
| קבצים | `get_file_contents`, `get_dir_contents`, `create_or_update_file`, `delete_file`, `get_repository_tree` |
| קומיטים | `list_commits`, `get_commit`, `get_compare` |
| Issues | `list_issues`, `issue_read`, `issue_write`, `search_issues` |
| Pull Requests | `list_pull_requests`, `pull_request_read`, `pull_request_write`, `pull_request_review_write` |
| Labels | `label_read`, `label_write` |
| Milestones | `milestone_read`, `milestone_write` |
| Releases | `list_releases`, `get_release`, `get_latest_release`, `create_release`, `delete_release` |
| Wiki | `wiki_read`, `wiki_write` |
| מעקב זמן | `timetracking_read`, `timetracking_write` |
| Actions | `actions_run_read`, `actions_run_write`, `actions_config_read`, `actions_config_write` |
| התראות | `notification_read`, `notification_write` |
| ארגונים | `org_read`, `org_write` |
| Webhooks | `webhook_read`, `webhook_write` |
| Collaborators | `collaborator_read`, `collaborator_write` |
| Deploy Keys | `deploy_key_read`, `deploy_key_write` |
| Topics | `topic_read`, `topic_write` |
| חיפוש | `search_users`, `search_repos`, `search_org_teams` |
| שרת | `get_gitea_mcp_server_version`, `list_servers` |
```sh
./gitea-mcp -t http [--port 8080] --token <your personal access token> -d
## פיתוח
```bash
make build # בנייה
make install # בנייה + התקנה ל-GOPATH/bin
make dev # hot reload (דורש air)
make clean # ניקוי
go test ./... # טסטים
```
## 🛠 Troubleshooting
## רישיון
If you encounter any issues, here are some common troubleshooting steps:
1. **Check your PATH**: Ensure that the `gitea-mcp` binary is in a directory included in your system's PATH.
2. **Verify dependencies**: Make sure you have all the required dependencies installed, such as `make` and `Golang`.
3. **Review configuration**: Double-check your MCP configuration file for any errors or missing information.
4. **Consult logs**: Check the logs for any error messages or warnings that can provide more information about the issue.
Enjoy exploring and managing your Gitea repositories via chat!
MIT
+3
View File
@@ -166,6 +166,9 @@ cp gitea-mcp /usr/local/bin/
> 可通过命令行参数或环境变量提供 Gitea 主机和访问令牌。
> 命令行参数优先。
> [!注意]
> 许多工具支持 `page` 和 `perPage` 分页参数。最大有效页面大小由 Gitea 服务器的 `[api].MAX_RESPONSE_ITEMS` 设置决定(默认值:**50**)。请求超过此限制的 `perPage` 值将被服务器静默截断。
一切设置完成后,可在 MCP 聊天框输入:
```text
+3
View File
@@ -166,6 +166,9 @@ cp gitea-mcp /usr/local/bin/
> 可用命令列參數或環境變數提供 Gitea 主機與存取令牌。
> 命令列參數優先。
> [!注意]
> 許多工具支援 `page` 和 `perPage` 分頁參數。最大有效頁面大小由 Gitea 伺服器的 `[api].MAX_RESPONSE_ITEMS` 設定決定(預設值:**50**)。請求超過此限制的 `perPage` 值將被伺服器靜默截斷。
一切設定完成後,可在 MCP 聊天框輸入:
```text
+13
View File
@@ -2,6 +2,7 @@ package cmd
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
@@ -57,6 +58,7 @@ func init() {
fmt.Fprintf(w, " GITEA_HOST\tOverride Gitea host URL\n")
fmt.Fprintf(w, " GITEA_INSECURE\tSet to 'true' to ignore TLS errors\n")
fmt.Fprintf(w, " GITEA_READONLY\tSet to 'true' for read-only mode\n")
fmt.Fprintf(w, " GITEA_SERVERS\tJSON object mapping server names to {host, token, insecure} configs\n")
fmt.Fprintf(w, " MCP_MODE\tOverride transport mode\n")
w.Flush()
}
@@ -91,6 +93,17 @@ func init() {
if os.Getenv("GITEA_INSECURE") == "true" {
flagPkg.Insecure = true
}
// Parse multi-server configuration from GITEA_SERVERS env var (JSON object).
// Example: {"dev":{"host":"https://gitea.dev.example.com","token":"xxx"},"prod":{...}}
if serversJSON := os.Getenv("GITEA_SERVERS"); serversJSON != "" {
var servers map[string]flagPkg.ServerConfig
if err := json.Unmarshal([]byte(serversJSON), &servers); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to parse GITEA_SERVERS: %v\n", err)
} else {
flagPkg.Servers = servers
}
}
}
func Execute() {
+3 -2
View File
@@ -6,10 +6,11 @@
"-t", "stdio",
"--host", "https://gitea.com",
"--token", "<your personal access token>"
]
],
"env": {
"GITEA_HOST": "https://gitea.com",
"GITEA_ACCESS_TOKEN": "<your personal access token>"
"GITEA_ACCESS_TOKEN": "<your personal access token>",
"GITEA_SERVERS": "{\"dev\":{\"host\":\"https://gitea.dev.example.com\",\"token\":\"<dev-token>\"},\"prod\":{\"host\":\"https://gitea.prod.example.com\",\"token\":\"<prod-token>\"}}"
}
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ go 1.26.0
require (
code.gitea.io/sdk/gitea v0.23.2
github.com/mark3labs/mcp-go v0.44.0
github.com/mark3labs/mcp-go v0.45.0
go.uber.org/zap v1.27.1
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
+2 -2
View File
@@ -28,8 +28,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I=
github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/mark3labs/mcp-go v0.45.0 h1:s0S8qR/9fWaQ3pHxz7pm1uQ0DrswoSnRIxKIjbiQtkc=
github.com/mark3labs/mcp-go v0.45.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
Executable
+487
View File
@@ -0,0 +1,487 @@
#!/bin/bash
# install.sh - Install/upgrade gitea-mcp and configure global MCP server
#
# Usage:
# curl -fsSL https://gitea.dev.marcus-law.co.il/mcp-servers/gitea-mcp/raw/branch/main/install.sh | bash
# # or locally:
# bash install.sh
#
# Options (via env vars):
# GITEA_MCP_DIR=~/custom-dir # Custom install directory (default: ~/.gitea-mcp)
# GITEA_MCP_BRANCH=dev # Custom branch (default: main)
# GITEA_DEFAULT_HOST=https://... # Default Gitea host
# GITEA_DEFAULT_TOKEN=xxx # Default Gitea token
# AUTO_YES=1 # Skip all prompts
set -euo pipefail
REPO_URL="https://gitea.dev.marcus-law.co.il/mcp-servers/gitea-mcp.git"
INSTALL_DIR="${GITEA_MCP_DIR:-$HOME/.gitea-mcp}"
BRANCH="${GITEA_MCP_BRANCH:-main}"
AUTO_YES="${AUTO_YES:-1}"
MCP_NAME="gitea-dev"
BINARY_NAME="gitea-mcp"
GOBIN="${GOPATH:-$HOME/go}/bin"
BINARY_PATH="$GOBIN/$BINARY_NAME"
CLAUDE_JSON="$HOME/.claude.json"
CLAUDE_SETTINGS="$HOME/.claude/settings.json"
# ── Helpers ─────────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
info() { echo -e "${CYAN}>>>${NC} $*"; }
ok() { echo -e "${GREEN}${NC} $*"; }
warn() { echo -e "${YELLOW} !${NC} $*"; }
error() { echo -e "${RED}${NC} $*" >&2; }
ask_yes_no() {
local prompt="$1"
if [ "$AUTO_YES" = "1" ]; then return 0; fi
if [ -e /dev/tty ]; then
printf "%s [Y/n] " "$prompt"
local answer=""
read -r answer < /dev/tty 2>/dev/null || answer="y"
case "$answer" in [nN]*) return 1 ;; *) return 0 ;; esac
else
return 0
fi
}
ask_input() {
local prompt="$1" var_name="$2" default="${3:-}"
if [ -e /dev/tty ]; then
if [ -n "$default" ]; then
printf "%s [%s]: " "$prompt" "$default"
else
printf "%s: " "$prompt"
fi
local answer=""
read -r answer < /dev/tty 2>/dev/null || answer=""
if [ -z "$answer" ]; then answer="$default"; fi
eval "$var_name=\$answer"
else
eval "$var_name=\$default"
fi
}
# ── Auto-install helpers ───────────────────────────────────────────────────
detect_pkg_manager() {
if command -v apt-get &>/dev/null; then echo "apt"
elif command -v dnf &>/dev/null; then echo "dnf"
elif command -v yum &>/dev/null; then echo "yum"
elif command -v pacman &>/dev/null; then echo "pacman"
elif command -v apk &>/dev/null; then echo "apk"
elif command -v brew &>/dev/null; then echo "brew"
else echo ""; fi
}
install_pkg() {
local pkg="$1"
local pm
pm=$(detect_pkg_manager)
case "$pm" in
apt) sudo apt-get update -qq && sudo apt-get install -y -qq "$pkg" ;;
dnf) sudo dnf install -y -q "$pkg" ;;
yum) sudo yum install -y -q "$pkg" ;;
pacman) sudo pacman -S --noconfirm "$pkg" ;;
apk) sudo apk add --quiet "$pkg" ;;
brew) brew install "$pkg" ;;
*) return 1 ;;
esac
}
install_go() {
local GO_VERSION_TARGET="1.24.2"
local ARCH
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH="amd64" ;;
aarch64|arm64) ARCH="arm64" ;;
*) error "Unsupported architecture: $ARCH"; return 1 ;;
esac
local OS
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
local TARBALL="go${GO_VERSION_TARGET}.${OS}-${ARCH}.tar.gz"
local URL="https://go.dev/dl/$TARBALL"
info "Downloading Go $GO_VERSION_TARGET for $OS/$ARCH ..."
local tmpfile
tmpfile=$(mktemp)
if command -v curl &>/dev/null; then
curl -fsSL "$URL" -o "$tmpfile"
elif command -v wget &>/dev/null; then
wget -q "$URL" -O "$tmpfile"
else
error "Neither curl nor wget found — cannot download Go"
return 1
fi
info "Installing Go to /usr/local/go ..."
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf "$tmpfile"
rm -f "$tmpfile"
# Add to PATH for this session
export PATH="/usr/local/go/bin:$PATH"
# Persist in shell profile if not already there
local profile="$HOME/.profile"
[ -f "$HOME/.bashrc" ] && profile="$HOME/.bashrc"
[ -f "$HOME/.zshrc" ] && profile="$HOME/.zshrc"
if ! grep -q '/usr/local/go/bin' "$profile" 2>/dev/null; then
echo 'export PATH="/usr/local/go/bin:$PATH"' >> "$profile"
info "Added Go to PATH in $profile"
fi
ok "Go $GO_VERSION_TARGET installed"
}
# ── Step 0: Prerequisites ──────────────────────────────────────────────────
echo ""
echo -e "${CYAN}=== Gitea MCP Server Installer ===${NC}"
echo ""
info "Checking prerequisites..."
# --- git ---
if ! command -v git &>/dev/null; then
warn "git not found, installing..."
if ! install_pkg git; then
error "Failed to install git. Install manually and re-run."
exit 1
fi
fi
ok "git ($(git --version | cut -d' ' -f3))"
# --- jq ---
if ! command -v jq &>/dev/null; then
warn "jq not found, installing..."
if ! install_pkg jq; then
error "Failed to install jq. Install manually and re-run."
exit 1
fi
fi
ok "jq ($(jq --version 2>/dev/null))"
# --- Go ---
if ! command -v go &>/dev/null; then
warn "Go not found, installing..."
if ! install_go; then
error "Failed to install Go. Install manually from https://go.dev/dl/ and re-run."
exit 1
fi
fi
GO_VERSION=$(go version | grep -oP 'go\d+\.\d+')
GO_MAJOR=$(echo "$GO_VERSION" | grep -oP '\d+' | head -1)
GO_MINOR=$(echo "$GO_VERSION" | grep -oP '\d+' | tail -1)
if [ "$GO_MAJOR" -lt 1 ] || { [ "$GO_MAJOR" -eq 1 ] && [ "$GO_MINOR" -lt 24 ]; }; then
warn "Go $GO_VERSION is too old (need 1.24+), upgrading..."
if ! install_go; then
error "Failed to upgrade Go. Install manually from https://go.dev/dl/ and re-run."
exit 1
fi
GO_VERSION=$(go version | grep -oP 'go\d+\.\d+')
fi
ok "go ($GO_VERSION)"
# ── Step 1: Clone or update ────────────────────────────────────────────────
echo ""
if [ -d "$INSTALL_DIR/.git" ]; then
info "Updating existing installation in $INSTALL_DIR ..."
cd "$INSTALL_DIR"
git fetch origin 2>&1 | tail -2
git reset --hard "origin/$BRANCH" 2>&1 | tail -1
ok "Updated to latest $BRANCH"
elif [ -d "$INSTALL_DIR" ]; then
# Directory exists but is not a git repo — remove and re-clone
warn "$INSTALL_DIR exists but is not a git repo, re-cloning..."
rm -rf "$INSTALL_DIR"
git clone --branch "$BRANCH" --single-branch "$REPO_URL" "$INSTALL_DIR" 2>&1 | tail -2
cd "$INSTALL_DIR"
ok "Cloned"
else
info "Cloning to $INSTALL_DIR ..."
git clone --branch "$BRANCH" --single-branch "$REPO_URL" "$INSTALL_DIR" 2>&1 | tail -2
cd "$INSTALL_DIR"
ok "Cloned"
fi
# ── Step 2: Build ──────────────────────────────────────────────────────────
echo ""
info "Building $BINARY_NAME ..."
mkdir -p "$GOBIN"
go build -o "$BINARY_PATH" . 2>&1
VERSION=$("$BINARY_PATH" --version 2>/dev/null || echo "unknown")
ok "Built $BINARY_PATH ($VERSION)"
# ── Step 3: Find existing MCP config ───────────────────────────────────────
echo ""
info "Checking existing MCP configuration..."
# Determine where the MCP entry currently lives
MCP_CONFIG_FILE=""
EXISTING_ENTRY=""
# Check ~/.claude.json first (preferred location for global MCP servers)
if [ -f "$CLAUDE_JSON" ]; then
EXISTING_ENTRY=$(jq -r --arg name "$MCP_NAME" '.mcpServers[$name] // empty' "$CLAUDE_JSON" 2>/dev/null || true)
if [ -n "$EXISTING_ENTRY" ]; then
MCP_CONFIG_FILE="$CLAUDE_JSON"
fi
fi
# Check ~/.claude/settings.json (legacy location)
if [ -z "$EXISTING_ENTRY" ] && [ -f "$CLAUDE_SETTINGS" ]; then
EXISTING_ENTRY=$(jq -r --arg name "$MCP_NAME" '.mcpServers[$name] // empty' "$CLAUDE_SETTINGS" 2>/dev/null || true)
if [ -n "$EXISTING_ENTRY" ]; then
MCP_CONFIG_FILE="$CLAUDE_SETTINGS"
fi
fi
# Check for project-level .mcp.json files
FOUND_PROJECT_MCP=()
while IFS= read -r -d '' mcp_file; do
if jq -e --arg name "$MCP_NAME" '.mcpServers[$name] != null' "$mcp_file" &>/dev/null; then
FOUND_PROJECT_MCP+=("$mcp_file")
fi
done < <(find "$HOME" -maxdepth 3 -name ".mcp.json" \
-not -path "*/.claude/*" \
-not -path "*/.gitea-mcp/*" \
-not -path "*/node_modules/*" \
-print0 2>/dev/null || true)
# ── Step 4: Migrate / deduplicate ──────────────────────────────────────────
# If found in project-level files, migrate to global
if [ ${#FOUND_PROJECT_MCP[@]} -gt 0 ]; then
for mcp_file in "${FOUND_PROJECT_MCP[@]}"; do
warn "Found '$MCP_NAME' in project-level config: $mcp_file"
if [ -z "$EXISTING_ENTRY" ]; then
# No global config yet - migrate from project file
if ask_yes_no " Migrate to global config ($CLAUDE_JSON)?"; then
EXISTING_ENTRY=$(jq --arg name "$MCP_NAME" '.mcpServers[$name]' "$mcp_file")
MCP_CONFIG_FILE="$CLAUDE_JSON"
fi
fi
# Remove from project-level regardless
if ask_yes_no " Remove duplicate from $mcp_file?"; then
tmp=$(jq --arg name "$MCP_NAME" 'del(.mcpServers[$name])' "$mcp_file")
echo "$tmp" > "$mcp_file"
ok "Removed from $mcp_file"
fi
done
fi
# ── Step 5: Configure default server ───────────────────────────────────────
echo ""
# Extract existing values if we have a config
CURRENT_HOST=""
CURRENT_TOKEN=""
CURRENT_SERVERS=""
if [ -n "$EXISTING_ENTRY" ]; then
CURRENT_HOST=$(echo "$EXISTING_ENTRY" | jq -r '
(.args // []) | to_entries |
map(select(.value == "--host")) | first |
if . then (.key + 1) as $k | input | .[$k] else empty end
' 2>/dev/null || true)
# Simpler extraction
CURRENT_HOST=$(echo "$EXISTING_ENTRY" | jq -r '
.args as $a | ($a | index("--host")) as $i |
if $i then $a[$i + 1] else "" end
' 2>/dev/null || true)
CURRENT_TOKEN=$(echo "$EXISTING_ENTRY" | jq -r '.env.GITEA_ACCESS_TOKEN // ""' 2>/dev/null || true)
CURRENT_SERVERS=$(echo "$EXISTING_ENTRY" | jq -r '.env.GITEA_SERVERS // ""' 2>/dev/null || true)
ok "Existing config found"
echo " Default: ${CURRENT_HOST:-not set}"
if [ -n "$CURRENT_SERVERS" ] && [ "$CURRENT_SERVERS" != "" ]; then
echo "$CURRENT_SERVERS" | jq -r 'to_entries[] | " \(.key): \(.value.host)"' 2>/dev/null || true
fi
fi
# Determine default host and token (env vars > existing config; no built-in token).
# Per Infisical-only secrets policy, tokens MUST come from env vars at install time.
# Source values from Infisical /gitea (per environment) before re-running this script.
BUILTIN_HOST="https://gitea.dev.marcus-law.co.il"
DEFAULT_HOST="${GITEA_DEFAULT_HOST:-${CURRENT_HOST:-$BUILTIN_HOST}}"
DEFAULT_TOKEN="${GITEA_DEFAULT_TOKEN:-${CURRENT_TOKEN:-}}"
if [[ -z "$DEFAULT_TOKEN" ]]; then
err "No token resolved for default server. Set GITEA_DEFAULT_TOKEN env var (Infisical /gitea dev → API_TOKEN) and re-run."
exit 1
fi
ok "Default server: $DEFAULT_HOST"
# Built-in additional server hosts (no tokens — those come from env vars).
# Set GITEA_PROD_TOKEN and GITEA_NAUTILUS_TOKEN env vars (Infisical /gitea prod|nautilus → API_TOKEN).
BUILTIN_SERVERS_TEMPLATE='{"prod":{"host":"https://gitea.prod.marcus-law.co.il","token":"__PROD_TOKEN__","insecure":false},"nautilus":{"host":"https://gitea.nautilus.marcusgroup.org","token":"__NAUTILUS_TOKEN__","insecure":false}}'
if [[ -z "${GITEA_PROD_TOKEN:-}" || -z "${GITEA_NAUTILUS_TOKEN:-}" ]]; then
err "GITEA_PROD_TOKEN and GITEA_NAUTILUS_TOKEN env vars are required for the additional-servers template."
err "Source values from Infisical /gitea prod and /gitea nautilus, then re-run."
exit 1
fi
BUILTIN_SERVERS="${BUILTIN_SERVERS_TEMPLATE//__PROD_TOKEN__/$GITEA_PROD_TOKEN}"
BUILTIN_SERVERS="${BUILTIN_SERVERS//__NAUTILUS_TOKEN__/$GITEA_NAUTILUS_TOKEN}"
# ── Step 6: Add additional servers ─────────────────────────────────────────
SERVERS_JSON="${CURRENT_SERVERS:-$BUILTIN_SERVERS}"
while true; do
echo ""
if [ "$AUTO_YES" = "1" ]; then break; fi
if ! ask_yes_no "Add an additional Gitea server?"; then
break
fi
srv_name="" srv_host="" srv_token="" srv_insecure="false"
ask_input " Server name (e.g. prod, staging)" srv_name ""
if [ -z "$srv_name" ]; then break; fi
# Check if already exists
if [ -n "$SERVERS_JSON" ] && echo "$SERVERS_JSON" | jq -e --arg n "$srv_name" '.[$n]' &>/dev/null; then
existing_host=$(echo "$SERVERS_JSON" | jq -r --arg n "$srv_name" '.[$n].host')
warn "Server '$srv_name' already configured: $existing_host"
if ! ask_yes_no " Overwrite?"; then continue; fi
fi
ask_input " Host URL" srv_host ""
ask_input " Access token" srv_token ""
ask_input " Skip TLS verification? (y/n)" srv_insecure "n"
[[ "$srv_insecure" =~ ^[Yy] ]] && srv_insecure="true" || srv_insecure="false"
if [ -z "$SERVERS_JSON" ] || [ "$SERVERS_JSON" = "" ]; then
SERVERS_JSON="{}"
fi
SERVERS_JSON=$(echo "$SERVERS_JSON" | jq \
--arg n "$srv_name" --arg h "$srv_host" --arg t "$srv_token" --argjson i "$srv_insecure" \
'.[$n] = {host: $h, token: $t, insecure: $i}')
ok "Added server '$srv_name': $srv_host"
done
# ── Step 7: Write global MCP config ───────────────────────────────────────
echo ""
info "Writing global MCP configuration..."
# Build the MCP entry
if [ -n "$SERVERS_JSON" ] && [ "$SERVERS_JSON" != "{}" ] && [ "$SERVERS_JSON" != "" ]; then
MCP_ENTRY=$(jq -n \
--arg cmd "$BINARY_PATH" \
--arg host "$DEFAULT_HOST" \
--arg token "$DEFAULT_TOKEN" \
--arg servers "$SERVERS_JSON" \
'{
command: $cmd,
args: ["-t", "stdio", "--host", $host],
env: {
GITEA_ACCESS_TOKEN: $token,
GITEA_SERVERS: $servers
}
}')
else
MCP_ENTRY=$(jq -n \
--arg cmd "$BINARY_PATH" \
--arg host "$DEFAULT_HOST" \
--arg token "$DEFAULT_TOKEN" \
'{
command: $cmd,
args: ["-t", "stdio", "--host", $host],
env: {
GITEA_ACCESS_TOKEN: $token
}
}')
fi
# Write to ~/.claude.json (global MCP config location)
if [ ! -f "$CLAUDE_JSON" ]; then
echo '{}' > "$CLAUDE_JSON"
fi
tmp=$(jq --arg name "$MCP_NAME" --argjson entry "$MCP_ENTRY" \
'.mcpServers[$name] = $entry' "$CLAUDE_JSON")
echo "$tmp" > "$CLAUDE_JSON"
ok "Written to $CLAUDE_JSON"
# Clean up from settings.json if it exists there (legacy location)
if [ -f "$CLAUDE_SETTINGS" ]; then
if jq -e --arg name "$MCP_NAME" '.mcpServers[$name] != null' "$CLAUDE_SETTINGS" &>/dev/null; then
tmp=$(jq --arg name "$MCP_NAME" 'del(.mcpServers[$name])' "$CLAUDE_SETTINGS")
echo "$tmp" > "$CLAUDE_SETTINGS"
ok "Removed legacy entry from $CLAUDE_SETTINGS"
fi
fi
# ── Step 8: Clean up duplicates in other config files ──────────────────────
echo ""
info "Checking for duplicate configs..."
# Clean from project-level .mcp.json files
while IFS= read -r -d '' mcp_file; do
if jq -e --arg name "$MCP_NAME" '.mcpServers[$name] != null' "$mcp_file" &>/dev/null; then
warn "Duplicate found in $mcp_file"
if ask_yes_no " Remove '$MCP_NAME' from $mcp_file?"; then
tmp=$(jq --arg name "$MCP_NAME" 'del(.mcpServers[$name])' "$mcp_file")
echo "$tmp" > "$mcp_file"
ok "Removed"
fi
fi
done < <(find "$HOME" -maxdepth 3 -name ".mcp.json" \
-not -path "*/.claude/*" \
-not -path "*/.gitea-mcp/*" \
-not -path "*/node_modules/*" \
-print0 2>/dev/null || true)
# Clean from ~/.claude/settings.json if we wrote to ~/.claude.json
if [ -f "$CLAUDE_SETTINGS" ]; then
if jq -e --arg name "$MCP_NAME" '.mcpServers[$name] != null' "$CLAUDE_SETTINGS" &>/dev/null; then
warn "Duplicate found in $CLAUDE_SETTINGS"
if ask_yes_no " Remove from $CLAUDE_SETTINGS? (already in $CLAUDE_JSON)"; then
tmp=$(jq --arg name "$MCP_NAME" 'del(.mcpServers[$name])' "$CLAUDE_SETTINGS")
echo "$tmp" > "$CLAUDE_SETTINGS"
ok "Removed"
fi
fi
fi
# ── Done ───────────────────────────────────────────────────────────────────
echo ""
echo -e "${CYAN}=== Configured Servers ===${NC}"
echo " default: $DEFAULT_HOST"
if [ -n "$SERVERS_JSON" ] && [ "$SERVERS_JSON" != "{}" ] && [ "$SERVERS_JSON" != "" ]; then
echo "$SERVERS_JSON" | jq -r 'to_entries[] | " \(.key): \(.value.host)\(if .value.insecure then " (insecure)" else "" end)"'
fi
echo ""
echo -e "${GREEN}=== Installation complete! ===${NC}"
echo ""
echo " Binary: $BINARY_PATH ($VERSION)"
echo " Config: $CLAUDE_JSON"
echo " Source: $INSTALL_DIR"
echo ""
echo "Restart Claude Code to load the new MCP server."
echo ""
echo "To upgrade later:"
echo " curl -fsSL https://gitea.dev.marcus-law.co.il/mcp-servers/gitea-mcp/raw/branch/main/install.sh | bash"
echo ""
+7
View File
@@ -1,6 +1,8 @@
package main
import (
"runtime/debug"
"gitea.com/gitea/gitea-mcp/cmd"
"gitea.com/gitea/gitea-mcp/pkg/flag"
)
@@ -8,6 +10,11 @@ import (
var Version = "dev"
func init() {
if Version == "dev" {
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
Version = info.Main.Version
}
}
flag.Version = Version
}
+1 -1
View File
@@ -54,7 +54,7 @@ var (
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
mcp.WithString("name", mcp.Description("variable name (required for get methods)")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30), mcp.Min(1)),
)
ActionsConfigWriteTool = mcp.NewTool(
+1 -1
View File
@@ -39,7 +39,7 @@ var (
mcp.WithNumber("max_bytes", mcp.Description("max bytes to return (for 'get_job_log_preview')"), mcp.DefaultNumber(65536), mcp.Min(1024)),
mcp.WithString("output_path", mcp.Description("output file path (for 'download_job_log')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30), mcp.Min(1)),
)
ActionsRunWriteTool = mcp.NewTool(
+30 -2
View File
@@ -30,8 +30,11 @@ var (
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("state", mcp.Description("issue state"), mcp.DefaultString("all")),
mcp.WithArray("labels", mcp.Description("filter by label names"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithString("since", mcp.Description("filter issues updated after this ISO 8601 timestamp")),
mcp.WithString("before", mcp.Description("filter issues updated before this ISO 8601 timestamp")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
)
IssueReadTool = mcp.NewTool(
@@ -56,8 +59,11 @@ var (
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'create', 'update')")),
mcp.WithString("state", mcp.Description("issue state, one of open, closed, all (for 'update')")),
mcp.WithNumber("commentID", mcp.Description("id of issue comment (required for 'edit_comment')")),
mcp.WithArray("labels", mcp.Description("array of label IDs (for 'add_labels', 'replace_labels')"), mcp.Items(map[string]any{"type": "number"})),
mcp.WithArray("labels", mcp.Description("array of label IDs (for 'create', 'add_labels', 'replace_labels')"), mcp.Items(map[string]any{"type": "number"})),
mcp.WithNumber("label_id", mcp.Description("label ID to remove (required for 'remove_label')")),
mcp.WithString("ref", mcp.Description("branch name to associate with the issue (for 'create', 'update')")),
mcp.WithString("deadline", mcp.Description("due date in ISO 8601 format (for 'create', 'update')")),
mcp.WithBoolean("remove_deadline", mcp.Description("unset due date (for 'update')")),
)
)
@@ -162,14 +168,22 @@ func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if !ok {
state = "all"
}
labels := params.GetStringSlice(req.GetArguments(), "labels")
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.ListIssueOption{
State: gitea_sdk.StateType(state),
Labels: labels,
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
if t := params.GetOptionalTime(req.GetArguments(), "since"); t != nil {
opt.Since = *t
}
if t := params.GetOptionalTime(req.GetArguments(), "before"); t != nil {
opt.Before = *t
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
@@ -213,6 +227,13 @@ func createIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
opt.Milestone = milestone
}
}
if labelIDs, err := params.GetInt64Slice(req.GetArguments(), "labels"); err == nil {
opt.Labels = labelIDs
}
if ref, ok := req.GetArguments()["ref"].(string); ok {
opt.Ref = ref
}
opt.Deadline = params.GetOptionalTime(req.GetArguments(), "deadline")
issue, _, err := client.CreateIssue(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/issue err: %v", owner, repo, err))
@@ -289,6 +310,13 @@ func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
if ok {
opt.State = new(gitea_sdk.StateType(state))
}
if ref, ok := req.GetArguments()["ref"].(string); ok {
opt.Ref = &ref
}
opt.Deadline = params.GetOptionalTime(req.GetArguments(), "deadline")
if removeDeadline, ok := req.GetArguments()["remove_deadline"].(bool); ok {
opt.RemoveDeadline = &removeDeadline
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
+167
View File
@@ -0,0 +1,167 @@
package issue
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/mark3labs/mcp-go/mcp"
)
func Test_listRepoIssuesFn_filters(t *testing.T) {
const (
owner = "octo"
repo = "demo"
)
var (
mu sync.Mutex
gotQuery string
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case r.URL.Path == fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case r.URL.Path == fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo):
mu.Lock()
gotQuery = r.URL.RawQuery
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
origVersion := flag.Version
flag.Host = server.URL
flag.Token = ""
flag.Version = "test"
defer func() {
flag.Host = origHost
flag.Token = origToken
flag.Version = origVersion
}()
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"labels": []any{"bug", "enhancement"},
"since": "2026-01-01T00:00:00Z",
},
},
}
_, err := listRepoIssuesFn(context.Background(), req)
if err != nil {
t.Fatalf("listRepoIssuesFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
if !strings.Contains(gotQuery, "labels=bug%2Cenhancement") {
t.Fatalf("expected labels query param, got %s", gotQuery)
}
if !strings.Contains(gotQuery, "since=2026-01-01") {
t.Fatalf("expected since query param, got %s", gotQuery)
}
}
func Test_createIssueFn_labels(t *testing.T) {
const (
owner = "octo"
repo = "demo"
)
var (
mu sync.Mutex
gotBody map[string]any
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo):
mu.Lock()
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotBody = body
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":1,"title":"test","state":"open"}`))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
origVersion := flag.Version
flag.Host = server.URL
flag.Token = ""
flag.Version = "test"
defer func() {
flag.Host = origHost
flag.Token = origToken
flag.Version = origVersion
}()
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"title": "test issue",
"body": "body",
"labels": []any{float64(10), float64(20)},
"deadline": "2026-06-01T00:00:00Z",
},
},
}
_, err := createIssueFn(context.Background(), req)
if err != nil {
t.Fatalf("createIssueFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
labels, ok := gotBody["labels"].([]any)
if !ok || len(labels) != 2 {
t.Fatalf("expected 2 labels, got %v", gotBody["labels"])
}
if labels[0] != float64(10) || labels[1] != float64(20) {
t.Fatalf("expected labels [10,20], got %v", labels)
}
if gotBody["due_date"] == nil {
t.Fatalf("expected due_date to be set")
}
}
+12
View File
@@ -63,6 +63,12 @@ func slimIssue(i *gitea_sdk.Issue) map[string]any {
"title": i.Milestone.Title,
}
}
if i.Ref != "" {
m["ref"] = i.Ref
}
if i.Deadline != nil {
m["deadline"] = i.Deadline
}
if i.PullRequest != nil {
m["is_pull"] = true
}
@@ -88,6 +94,12 @@ func slimIssues(issues []*gitea_sdk.Issue) []map[string]any {
if len(i.Labels) > 0 {
m["labels"] = labelNames(i.Labels)
}
if i.Ref != "" {
m["ref"] = i.Ref
}
if i.Deadline != nil {
m["deadline"] = i.Deadline
}
out = append(out, m)
}
return out
+8 -1
View File
@@ -32,7 +32,7 @@ var (
mcp.WithString("org", mcp.Description("organization name (required for 'list_org')")),
mcp.WithNumber("id", mcp.Description("label ID (required for 'get_repo')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
)
LabelWriteTool = mcp.NewTool(
@@ -47,6 +47,7 @@ var (
mcp.WithString("color", mcp.Description("label color hex code e.g. #RRGGBB (required for create, optional for edit)")),
mcp.WithString("description", mcp.Description("label description")),
mcp.WithBoolean("exclusive", mcp.Description("whether the label is exclusive (org labels only)")),
mcp.WithBoolean("is_archived", mcp.Description("whether the label is archived (for create/edit repo label methods)")),
)
)
@@ -178,10 +179,13 @@ func createRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
}
description, _ := req.GetArguments()["description"].(string) // Optional
isArchived, _ := req.GetArguments()["is_archived"].(bool)
opt := gitea_sdk.CreateLabelOption{
Name: name,
Color: color,
Description: description,
IsArchived: isArchived,
}
client, err := gitea.ClientFromContext(ctx)
@@ -220,6 +224,9 @@ func editRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
if description, ok := req.GetArguments()["description"].(string); ok {
opt.Description = new(description)
}
if isArchived, ok := req.GetArguments()["is_archived"].(bool); ok {
opt.IsArchived = &isArchived
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
+1 -1
View File
@@ -33,7 +33,7 @@ var (
mcp.WithString("state", mcp.Description("milestone state (for 'list')"), mcp.DefaultString("all")),
mcp.WithString("name", mcp.Description("milestone name filter (for 'list')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
)
MilestoneWriteTool = mcp.NewTool(
+215
View File
@@ -0,0 +1,215 @@
package notification
import (
"context"
"fmt"
"time"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
NotificationReadToolName = "notification_read"
NotificationWriteToolName = "notification_write"
)
var (
NotificationReadTool = mcp.NewTool(
NotificationReadToolName,
mcp.WithDescription("Get notifications. Use method 'list' to list notifications (optionally scoped to a repo), 'get' to get a single notification thread by ID."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get")),
mcp.WithString("owner", mcp.Description("repository owner (for 'list' to scope to a repo)")),
mcp.WithString("repo", mcp.Description("repository name (for 'list' to scope to a repo)")),
mcp.WithNumber("id", mcp.Description("notification thread ID (required for 'get')")),
mcp.WithString("status", mcp.Description("filter by status (for 'list')"), mcp.Enum("unread", "read", "pinned")),
mcp.WithString("subject_type", mcp.Description("filter by subject type (for 'list')"), mcp.Enum("Issue", "Pull", "Commit", "Repository")),
mcp.WithString("since", mcp.Description("filter notifications updated after this ISO 8601 timestamp (for 'list')")),
mcp.WithString("before", mcp.Description("filter notifications updated before this ISO 8601 timestamp (for 'list')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
)
NotificationWriteTool = mcp.NewTool(
NotificationWriteToolName,
mcp.WithDescription("Manage notifications. Use method 'mark_read' to mark a single notification as read, 'mark_all_read' to mark all notifications as read (optionally scoped to a repo)."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("mark_read", "mark_all_read")),
mcp.WithNumber("id", mcp.Description("notification thread ID (required for 'mark_read')")),
mcp.WithString("owner", mcp.Description("repository owner (for 'mark_all_read' to scope to a repo)")),
mcp.WithString("repo", mcp.Description("repository name (for 'mark_all_read' to scope to a repo)")),
mcp.WithString("last_read_at", mcp.Description("ISO 8601 timestamp, marks notifications before this time as read (for 'mark_all_read', defaults to now)")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: NotificationReadTool,
Handler: notificationReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: NotificationWriteTool,
Handler: notificationWriteFn,
})
}
func notificationReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list":
return listNotificationsFn(ctx, req)
case "get":
return getNotificationFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func notificationWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "mark_read":
return markNotificationReadFn(ctx, req)
case "mark_all_read":
return markAllNotificationsReadFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listNotificationsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listNotificationsFn")
args := req.GetArguments()
page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListNotificationOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
if status, ok := args["status"].(string); ok {
opt.Status = []gitea_sdk.NotifyStatus{gitea_sdk.NotifyStatus(status)}
}
if subjectType, ok := args["subject_type"].(string); ok {
opt.SubjectTypes = []gitea_sdk.NotifySubjectType{gitea_sdk.NotifySubjectType(subjectType)}
}
if t := params.GetOptionalTime(args, "since"); t != nil {
opt.Since = *t
}
if t := params.GetOptionalTime(args, "before"); t != nil {
opt.Before = *t
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
owner := params.GetOptionalString(args, "owner", "")
repo := params.GetOptionalString(args, "repo", "")
if owner != "" && repo != "" {
threads, _, err := client.ListRepoNotifications(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list %v/%v/notifications err: %v", owner, repo, err))
}
return to.TextResult(slimThreads(threads))
}
threads, _, err := client.ListNotifications(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list notifications err: %v", err))
}
return to.TextResult(slimThreads(threads))
}
func getNotificationFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getNotificationFn")
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
thread, _, err := client.GetNotification(id)
if err != nil {
return to.ErrorResult(fmt.Errorf("get notification/%v err: %v", id, err))
}
return to.TextResult(slimThread(thread))
}
func markNotificationReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called markNotificationReadFn")
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
thread, _, err := client.ReadNotification(id)
if err != nil {
return to.ErrorResult(fmt.Errorf("mark notification/%v read err: %v", id, err))
}
if thread != nil {
return to.TextResult(slimThread(thread))
}
return to.TextResult("Notification marked as read")
}
func markAllNotificationsReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called markAllNotificationsReadFn")
args := req.GetArguments()
lastReadAt := time.Now()
if t := params.GetOptionalTime(args, "last_read_at"); t != nil {
lastReadAt = *t
}
opt := gitea_sdk.MarkNotificationOptions{
LastReadAt: lastReadAt,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
owner := params.GetOptionalString(args, "owner", "")
repo := params.GetOptionalString(args, "repo", "")
if owner != "" && repo != "" {
threads, _, err := client.ReadRepoNotifications(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("mark %v/%v/notifications read err: %v", owner, repo, err))
}
if threads != nil {
return to.TextResult(slimThreads(threads))
}
return to.TextResult("All repository notifications marked as read")
}
threads, _, err := client.ReadNotifications(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("mark all notifications read err: %v", err))
}
if threads != nil {
return to.TextResult(slimThreads(threads))
}
return to.TextResult("All notifications marked as read")
}
+66
View File
@@ -0,0 +1,66 @@
package notification
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimThread(t *gitea_sdk.NotificationThread) map[string]any {
if t == nil {
return nil
}
m := map[string]any{
"id": t.ID,
"unread": t.Unread,
"updated_at": t.UpdatedAt,
}
if t.Pinned {
m["pinned"] = true
}
if t.Repository != nil {
m["repository"] = t.Repository.FullName
}
if t.Subject != nil {
subject := map[string]any{
"title": t.Subject.Title,
"type": t.Subject.Type,
"state": t.Subject.State,
}
if t.Subject.HTMLURL != "" {
subject["html_url"] = t.Subject.HTMLURL
}
if t.Subject.LatestCommentHTMLURL != "" {
subject["latest_comment_html_url"] = t.Subject.LatestCommentHTMLURL
}
m["subject"] = subject
}
return m
}
func slimThreads(threads []*gitea_sdk.NotificationThread) []map[string]any {
out := make([]map[string]any, 0, len(threads))
for _, t := range threads {
if t == nil {
continue
}
m := map[string]any{
"id": t.ID,
"unread": t.Unread,
"updated_at": t.UpdatedAt,
}
if t.Pinned {
m["pinned"] = true
}
if t.Repository != nil {
m["repository"] = t.Repository.FullName
}
if t.Subject != nil {
m["subject"] = map[string]any{
"title": t.Subject.Title,
"type": t.Subject.Type,
"state": t.Subject.State,
}
}
out = append(out, m)
}
return out
}
+97 -30
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"os"
"os/signal"
"sort"
"strings"
"syscall"
"time"
@@ -15,6 +16,8 @@ import (
"gitea.com/gitea/gitea-mcp/operation/issue"
"gitea.com/gitea/gitea-mcp/operation/label"
"gitea.com/gitea/gitea-mcp/operation/milestone"
"gitea.com/gitea/gitea-mcp/operation/notification"
"gitea.com/gitea/gitea-mcp/operation/org"
"gitea.com/gitea/gitea-mcp/operation/pull"
"gitea.com/gitea/gitea-mcp/operation/repo"
"gitea.com/gitea/gitea-mcp/operation/search"
@@ -26,45 +29,109 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.com/gitea/gitea-mcp/pkg/log"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var mcpServer *server.MCPServer
// serverNames returns a sorted list of configured server names for use in
// enum validation and the list_servers tool.
func serverNames() []string {
names := make([]string, 0, len(flag.Servers))
for name := range flag.Servers {
names = append(names, name)
}
// Deterministic ordering for tool schemas.
sort.Strings(names)
return names
}
// injectServerParam adds an optional "server" parameter to a tool and wraps
// its handler so the chosen server's host/token/insecure are injected into
// the context before the original handler runs.
func injectServerParam(tools []server.ServerTool) []server.ServerTool {
names := serverNames()
for i, st := range tools {
// Add "server" to the JSON Schema properties.
if st.Tool.InputSchema.Properties == nil {
st.Tool.InputSchema.Properties = make(map[string]any)
}
st.Tool.InputSchema.Properties["server"] = map[string]any{
"type": "string",
"description": "Target Gitea server name. Available: " + strings.Join(names, ", ") + ". Omit to use the default server.",
"enum": names,
}
tools[i].Tool = st.Tool
// Wrap the handler to resolve server config → context values.
origHandler := st.Handler
tools[i].Handler = func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if serverName, ok := req.GetArguments()["server"].(string); ok && serverName != "" {
cfg, exists := flag.Servers[serverName]
if !exists {
return mcp.NewToolResultText("unknown server: " + serverName + ". Available: " + strings.Join(serverNames(), ", ")), nil
}
ctx = context.WithValue(ctx, mcpContext.HostContextKey, cfg.Host)
ctx = context.WithValue(ctx, mcpContext.TokenContextKey, cfg.Token)
ctx = context.WithValue(ctx, mcpContext.InsecureContextKey, cfg.Insecure)
}
return origHandler(ctx, req)
}
}
return tools
}
// listServersFn handles the "list_servers" tool — returns the configured servers.
func listServersFn(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if len(flag.Servers) == 0 {
defaultInfo := fmt.Sprintf("default: %s (no additional servers configured)", flag.Host)
return mcp.NewToolResultText(defaultInfo), nil
}
var b strings.Builder
b.WriteString(fmt.Sprintf("default: %s\n", flag.Host))
for _, name := range serverNames() {
cfg := flag.Servers[name]
b.WriteString(fmt.Sprintf("%s: %s", name, cfg.Host))
if cfg.Insecure {
b.WriteString(" (insecure)")
}
b.WriteString("\n")
}
return mcp.NewToolResultText(b.String()), nil
}
func RegisterTool(s *server.MCPServer) {
// User Tool
s.AddTools(user.Tool.Tools()...)
// Collect all tools from every domain package.
allTools := make([]server.ServerTool, 0, 200)
allTools = append(allTools, user.Tool.Tools()...)
allTools = append(allTools, actions.Tool.Tools()...)
allTools = append(allTools, repo.Tool.Tools()...)
allTools = append(allTools, notification.Tool.Tools()...)
allTools = append(allTools, issue.Tool.Tools()...)
allTools = append(allTools, label.Tool.Tools()...)
allTools = append(allTools, milestone.Tool.Tools()...)
allTools = append(allTools, pull.Tool.Tools()...)
allTools = append(allTools, search.Tool.Tools()...)
allTools = append(allTools, version.Tool.Tools()...)
allTools = append(allTools, wiki.Tool.Tools()...)
allTools = append(allTools, timetracking.Tool.Tools()...)
allTools = append(allTools, org.Tool.Tools()...)
// Actions Tool
s.AddTools(actions.Tool.Tools()...)
// If multiple servers are configured, inject the "server" parameter into every tool.
if len(flag.Servers) > 0 {
allTools = injectServerParam(allTools)
// Repo Tool
s.AddTools(repo.Tool.Tools()...)
// Issue Tool
s.AddTools(issue.Tool.Tools()...)
// Label Tool
s.AddTools(label.Tool.Tools()...)
// Milestone Tool
s.AddTools(milestone.Tool.Tools()...)
// Pull Tool
s.AddTools(pull.Tool.Tools()...)
// Search Tool
s.AddTools(search.Tool.Tools()...)
// Version Tool
s.AddTools(version.Tool.Tools()...)
// Wiki Tool
s.AddTools(wiki.Tool.Tools()...)
// Time Tracking Tool
s.AddTools(timetracking.Tool.Tools()...)
// Add a list_servers meta-tool so callers can discover available servers.
allTools = append(allTools, server.ServerTool{
Tool: mcp.NewTool("list_servers",
mcp.WithDescription("List all configured Gitea server connections"),
),
Handler: listServersFn,
})
}
s.AddTools(allTools...)
s.DeleteTools("")
}
+379
View File
@@ -0,0 +1,379 @@
package org
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
OrgReadToolName = "org_read"
OrgWriteToolName = "org_write"
)
var (
OrgReadTool = mcp.NewTool(
OrgReadToolName,
mcp.WithDescription("Read organization and team information. Methods: 'get' org details, 'list_teams', 'get_team', 'list_team_members', 'list_team_repos'."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "list_teams", "get_team", "list_team_members", "list_team_repos")),
mcp.WithString("org", mcp.Description("organization name (required for 'get', 'list_teams')")),
mcp.WithNumber("team_id", mcp.Description("team ID (required for 'get_team', 'list_team_members', 'list_team_repos')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
OrgWriteTool = mcp.NewTool(
OrgWriteToolName,
mcp.WithDescription("Manage organization teams and membership. Methods: 'create_team', 'delete_team', 'add_team_member', 'remove_team_member', 'add_team_repo', 'remove_team_repo'."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create_team", "delete_team", "add_team_member", "remove_team_member", "add_team_repo", "remove_team_repo")),
mcp.WithString("org", mcp.Description("organization name (required for 'create_team', 'add_team_repo', 'remove_team_repo')")),
mcp.WithNumber("team_id", mcp.Description("team ID (required for 'delete_team', 'add_team_member', 'remove_team_member', 'add_team_repo', 'remove_team_repo')")),
mcp.WithString("username", mcp.Description("username (required for 'add_team_member', 'remove_team_member')")),
mcp.WithString("repo", mcp.Description("repository name (required for 'add_team_repo', 'remove_team_repo')")),
mcp.WithString("name", mcp.Description("team name (required for 'create_team')")),
mcp.WithString("description", mcp.Description("team description (for 'create_team')")),
mcp.WithString("permission", mcp.Description("team permission level (for 'create_team')"), mcp.Enum("read", "write", "admin")),
mcp.WithArray("units", mcp.Description("team units (for 'create_team'), e.g. repo.code, repo.issues, repo.pulls"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithBoolean("includes_all_repositories", mcp.Description("include all org repos (for 'create_team')")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: OrgReadTool,
Handler: orgReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: OrgWriteTool,
Handler: orgWriteFn,
})
}
func orgReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "get":
return getOrgFn(ctx, req)
case "list_teams":
return listTeamsFn(ctx, req)
case "get_team":
return getTeamFn(ctx, req)
case "list_team_members":
return listTeamMembersFn(ctx, req)
case "list_team_repos":
return listTeamReposFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func getOrgFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getOrgFn")
args := req.GetArguments()
orgName, err := params.GetString(args, "org")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
o, _, err := client.GetOrg(orgName)
if err != nil {
return to.ErrorResult(fmt.Errorf("get org %v err: %v", orgName, err))
}
return to.TextResult(slimOrg(o))
}
func listTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listTeamsFn")
args := req.GetArguments()
orgName, err := params.GetString(args, "org")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(args, 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
teams, _, err := client.ListOrgTeams(orgName, gitea_sdk.ListTeamsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list teams for org %v err: %v", orgName, err))
}
return to.TextResult(slimTeams(teams))
}
func getTeamFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getTeamFn")
args := req.GetArguments()
teamID, err := params.GetIndex(args, "team_id")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
team, _, err := client.GetTeam(teamID)
if err != nil {
return to.ErrorResult(fmt.Errorf("get team %v err: %v", teamID, err))
}
return to.TextResult(slimTeam(team))
}
func listTeamMembersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listTeamMembersFn")
args := req.GetArguments()
teamID, err := params.GetIndex(args, "team_id")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(args, 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
members, _, err := client.ListTeamMembers(teamID, gitea_sdk.ListTeamMembersOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list team %v members err: %v", teamID, err))
}
return to.TextResult(slimUsers(members))
}
func listTeamReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listTeamReposFn")
args := req.GetArguments()
teamID, err := params.GetIndex(args, "team_id")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(args, 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
repos, _, err := client.ListTeamRepositories(teamID, gitea_sdk.ListTeamRepositoriesOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list team %v repos err: %v", teamID, err))
}
return to.TextResult(slimRepos(repos))
}
func orgWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create_team":
return createTeamFn(ctx, req)
case "delete_team":
return deleteTeamFn(ctx, req)
case "add_team_member":
return addTeamMemberFn(ctx, req)
case "remove_team_member":
return removeTeamMemberFn(ctx, req)
case "add_team_repo":
return addTeamRepoFn(ctx, req)
case "remove_team_repo":
return removeTeamRepoFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func createTeamFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createTeamFn")
args := req.GetArguments()
orgName, err := params.GetString(args, "org")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(args, "name")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.CreateTeamOption{
Name: name,
}
if v, ok := args["description"].(string); ok {
opt.Description = v
}
if v, ok := args["permission"].(string); ok {
switch v {
case "read":
opt.Permission = gitea_sdk.AccessModeRead
case "write":
opt.Permission = gitea_sdk.AccessModeWrite
case "admin":
opt.Permission = gitea_sdk.AccessModeAdmin
}
}
if unitStrings := params.GetStringSlice(args, "units"); unitStrings != nil {
units := make([]gitea_sdk.RepoUnitType, 0, len(unitStrings))
for _, u := range unitStrings {
units = append(units, gitea_sdk.RepoUnitType(u))
}
opt.Units = units
}
if v, ok := args["includes_all_repositories"].(bool); ok {
opt.IncludesAllRepositories = v
}
team, _, err := client.CreateTeam(orgName, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create team in org %v err: %v", orgName, err))
}
return to.TextResult(slimTeam(team))
}
func deleteTeamFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteTeamFn")
args := req.GetArguments()
teamID, err := params.GetIndex(args, "team_id")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DeleteTeam(teamID)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete team %v err: %v", teamID, err))
}
return mcp.NewToolResultText(fmt.Sprintf("Team %d deleted", teamID)), nil
}
func addTeamMemberFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called addTeamMemberFn")
args := req.GetArguments()
teamID, err := params.GetIndex(args, "team_id")
if err != nil {
return to.ErrorResult(err)
}
username, err := params.GetString(args, "username")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.AddTeamMember(teamID, username)
if err != nil {
return to.ErrorResult(fmt.Errorf("add %v to team %v err: %v", username, teamID, err))
}
return mcp.NewToolResultText(fmt.Sprintf("User %s added to team %d", username, teamID)), nil
}
func removeTeamMemberFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called removeTeamMemberFn")
args := req.GetArguments()
teamID, err := params.GetIndex(args, "team_id")
if err != nil {
return to.ErrorResult(err)
}
username, err := params.GetString(args, "username")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.RemoveTeamMember(teamID, username)
if err != nil {
return to.ErrorResult(fmt.Errorf("remove %v from team %v err: %v", username, teamID, err))
}
return mcp.NewToolResultText(fmt.Sprintf("User %s removed from team %d", username, teamID)), nil
}
func addTeamRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called addTeamRepoFn")
args := req.GetArguments()
teamID, err := params.GetIndex(args, "team_id")
if err != nil {
return to.ErrorResult(err)
}
orgName, err := params.GetString(args, "org")
if err != nil {
return to.ErrorResult(err)
}
repoName, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.AddTeamRepository(teamID, orgName, repoName)
if err != nil {
return to.ErrorResult(fmt.Errorf("add repo %v/%v to team %v err: %v", orgName, repoName, teamID, err))
}
return mcp.NewToolResultText(fmt.Sprintf("Repo %s/%s added to team %d", orgName, repoName, teamID)), nil
}
func removeTeamRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called removeTeamRepoFn")
args := req.GetArguments()
teamID, err := params.GetIndex(args, "team_id")
if err != nil {
return to.ErrorResult(err)
}
orgName, err := params.GetString(args, "org")
if err != nil {
return to.ErrorResult(err)
}
repoName, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.RemoveTeamRepository(teamID, orgName, repoName)
if err != nil {
return to.ErrorResult(fmt.Errorf("remove repo %v/%v from team %v err: %v", orgName, repoName, teamID, err))
}
return mcp.NewToolResultText(fmt.Sprintf("Repo %s/%s removed from team %d", orgName, repoName, teamID)), nil
}
+85
View File
@@ -0,0 +1,85 @@
package org
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimOrg(o *gitea_sdk.Organization) map[string]any {
if o == nil {
return nil
}
return map[string]any{
"id": o.ID,
"username": o.UserName,
"full_name": o.FullName,
"description": o.Description,
"website": o.Website,
"location": o.Location,
"visibility": o.Visibility,
"avatar_url": o.AvatarURL,
}
}
func slimTeam(t *gitea_sdk.Team) map[string]any {
if t == nil {
return nil
}
return map[string]any{
"id": t.ID,
"name": t.Name,
"description": t.Description,
"permission": t.Permission,
"units": t.Units,
"includes_all_repositories": t.IncludesAllRepositories,
"can_create_org_repo": t.CanCreateOrgRepo,
}
}
func slimTeams(teams []*gitea_sdk.Team) []map[string]any {
out := make([]map[string]any, 0, len(teams))
for _, t := range teams {
out = append(out, slimTeam(t))
}
return out
}
func slimUser(u *gitea_sdk.User) map[string]any {
if u == nil {
return nil
}
return map[string]any{
"id": u.ID,
"login": u.UserName,
"full_name": u.FullName,
"email": u.Email,
"avatar_url": u.AvatarURL,
}
}
func slimUsers(users []*gitea_sdk.User) []map[string]any {
out := make([]map[string]any, 0, len(users))
for _, u := range users {
out = append(out, slimUser(u))
}
return out
}
func slimRepos(repos []*gitea_sdk.Repository) []map[string]any {
out := make([]map[string]any, 0, len(repos))
for _, r := range repos {
if r == nil {
continue
}
m := map[string]any{
"id": r.ID,
"full_name": r.FullName,
"html_url": r.HTMLURL,
"private": r.Private,
}
if r.Owner != nil {
m["owner"] = r.Owner.UserName
}
out = append(out, m)
}
return out
}
+150 -6
View File
@@ -3,6 +3,7 @@ package pull
import (
"context"
"fmt"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
@@ -34,20 +35,20 @@ var (
mcp.WithString("sort", mcp.Description("sort"), mcp.Enum("oldest", "recentupdate", "leastupdate", "mostcomment", "leastcomment", "priority"), mcp.DefaultString("recentupdate")),
mcp.WithNumber("milestone", mcp.Description("milestone")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
)
PullRequestReadTool = mcp.NewTool(
PullRequestReadToolName,
mcp.WithDescription("Get pull request information. Use method 'get' for PR details, 'get_diff' for diff, 'get_reviews'/'get_review'/'get_review_comments' for review data."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "get_diff", "get_reviews", "get_review", "get_review_comments")),
mcp.WithDescription("Get pull request information. Use method 'get' for PR details, 'get_diff' for diff, 'get_reviews'/'get_review'/'get_review_comments' for review data, 'list_files' for changed files, 'is_merged' to check merge status."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "get_diff", "get_reviews", "get_review", "get_review_comments", "list_files", "is_merged")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("pull request index")),
mcp.WithNumber("review_id", mcp.Description("review ID (required for 'get_review', 'get_review_comments')")),
mcp.WithBoolean("binary", mcp.Description("whether to include binary file changes (for 'get_diff')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
)
PullRequestWriteTool = mcp.NewTool(
@@ -66,11 +67,18 @@ var (
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'update')")),
mcp.WithString("state", mcp.Description("PR state (for 'update')"), mcp.Enum("open", "closed")),
mcp.WithBoolean("allow_maintainer_edit", mcp.Description("allow maintainer to edit (for 'update')")),
mcp.WithArray("labels", mcp.Description("array of label IDs (for 'create', 'update')"), mcp.Items(map[string]any{"type": "number"})),
mcp.WithString("deadline", mcp.Description("due date in ISO 8601 format (for 'create', 'update')")),
mcp.WithBoolean("remove_deadline", mcp.Description("unset due date (for 'update')")),
mcp.WithString("merge_style", mcp.Description("merge style (for 'merge')"), mcp.Enum("merge", "rebase", "rebase-merge", "squash", "fast-forward-only"), mcp.DefaultString("merge")),
mcp.WithString("message", mcp.Description("merge commit message (for 'merge') or dismissal reason")),
mcp.WithBoolean("delete_branch", mcp.Description("delete branch after merge (for 'merge')")),
mcp.WithBoolean("force_merge", mcp.Description("force merge even if checks are not passing (for 'merge')")),
mcp.WithBoolean("merge_when_checks_succeed", mcp.Description("auto-merge when checks succeed (for 'merge')")),
mcp.WithString("head_commit_id", mcp.Description("expected head commit SHA for merge conflict detection (for 'merge')")),
mcp.WithArray("reviewers", mcp.Description("reviewer usernames (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithArray("team_reviewers", mcp.Description("team reviewer names (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithBoolean("draft", mcp.Description("mark PR as draft (for 'create', 'update'). Gitea uses a 'WIP: ' title prefix for drafts.")),
)
PullRequestReviewWriteTool = mcp.NewTool(
@@ -132,11 +140,85 @@ func pullRequestReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
return getPullRequestReviewFn(ctx, req)
case "get_review_comments":
return listPullRequestReviewCommentsFn(ctx, req)
case "list_files":
return listPullRequestFilesFn(ctx, req)
case "is_merged":
return isPullRequestMergedFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listPullRequestFilesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listPullRequestFilesFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repoName, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(args, 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
files, _, err := client.ListPullRequestFiles(owner, repoName, index, gitea_sdk.ListPullRequestFilesOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list PR files %v/%v#%v err: %v", owner, repoName, index, err))
}
out := make([]map[string]any, 0, len(files))
for _, f := range files {
out = append(out, map[string]any{
"filename": f.Filename,
"status": f.Status,
"additions": f.Additions,
"deletions": f.Deletions,
"changes": f.Changes,
})
}
return to.TextResult(out)
}
func isPullRequestMergedFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called isPullRequestMergedFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repoName, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
merged, _, err := client.IsPullRequestMerged(owner, repoName, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("check PR merged %v/%v#%v err: %v", owner, repoName, index, err))
}
return to.TextResult(map[string]any{"merged": merged})
}
func pullRequestWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
@@ -271,6 +353,28 @@ func listRepoPullRequestsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.
return to.TextResult(slimPullRequests(pullRequests))
}
// defaultWIPPrefixes are the default Gitea title prefixes that mark a PR as
// work-in-progress / draft. Gitea matches these case-insensitively.
var defaultWIPPrefixes = []string{"WIP:", "[WIP]"}
// applyDraftPrefix adds or removes a WIP title prefix that Gitea uses to mark
// pull requests as drafts. When the title already carries a recognized prefix
// and isDraft is true, the title is returned unchanged to avoid normalization.
func applyDraftPrefix(title string, isDraft bool) string {
for _, prefix := range defaultWIPPrefixes {
if len(title) >= len(prefix) && strings.EqualFold(title[:len(prefix)], prefix) {
if isDraft {
return title
}
return strings.TrimLeft(title[len(prefix):], " ")
}
}
if isDraft {
return "WIP: " + title
}
return title
}
func createPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createPullRequestFn")
args := req.GetArguments()
@@ -298,16 +402,26 @@ func createPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal
if err != nil {
return to.ErrorResult(err)
}
if draft, ok := args["draft"].(bool); ok {
title = applyDraftPrefix(title, draft)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
pr, _, err := client.CreatePullRequest(owner, repo, gitea_sdk.CreatePullRequestOption{
opt := gitea_sdk.CreatePullRequestOption{
Title: title,
Body: body,
Head: head,
Base: base,
})
}
if labelIDs, err := params.GetInt64Slice(args, "labels"); err == nil {
opt.Labels = labelIDs
}
opt.Deadline = params.GetOptionalTime(args, "deadline")
pr, _, err := client.CreatePullRequest(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/pull_request err: %v", owner, repo, err))
}
@@ -722,11 +836,18 @@ func mergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
forceMerge, _ := args["force_merge"].(bool)
mergeWhenChecksSucceed, _ := args["merge_when_checks_succeed"].(bool)
headCommitID, _ := args["head_commit_id"].(string)
opt := gitea_sdk.MergePullRequestOption{
Style: gitea_sdk.MergeStyle(mergeStyle),
Title: title,
Message: message,
DeleteBranchAfterMerge: deleteBranch,
ForceMerge: forceMerge,
MergeWhenChecksSucceed: mergeWhenChecksSucceed,
HeadCommitId: headCommitID,
}
merged, resp, err := client.MergePullRequest(owner, repo, index, opt)
@@ -774,6 +895,22 @@ func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if title, ok := args["title"].(string); ok {
opt.Title = title
}
if draft, ok := args["draft"].(bool); ok {
if opt.Title == "" {
// Fetch current title so the caller doesn't have to provide it
// just to toggle draft status.
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
pr, _, err := client.GetPullRequest(owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v err: %v", owner, repo, index, err))
}
opt.Title = pr.Title
}
opt.Title = applyDraftPrefix(opt.Title, draft)
}
if body, ok := args["body"].(string); ok {
opt.Body = new(body)
}
@@ -797,6 +934,13 @@ func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if allowMaintainerEdit, ok := args["allow_maintainer_edit"].(bool); ok {
opt.AllowMaintainerEdit = new(allowMaintainerEdit)
}
if labelIDs, err := params.GetInt64Slice(args, "labels"); err == nil {
opt.Labels = labelIDs
}
opt.Deadline = params.GetOptionalTime(args, "deadline")
if removeDeadline, ok := args["remove_deadline"].(bool); ok {
opt.RemoveDeadline = &removeDeadline
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
+392
View File
@@ -254,6 +254,398 @@ func Test_mergePullRequestFn(t *testing.T) {
}
}
func Test_mergePullRequestFn_newParams(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 8
)
var (
mu sync.Mutex
gotBody map[string]any
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index):
mu.Lock()
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotBody = body
mu.Unlock()
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
origVersion := flag.Version
flag.Host = server.URL
flag.Token = ""
flag.Version = "test"
defer func() {
flag.Host = origHost
flag.Token = origToken
flag.Version = origVersion
}()
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"index": float64(index),
"merge_style": "merge",
"force_merge": true,
"merge_when_checks_succeed": true,
"head_commit_id": "abc123",
},
},
}
_, err := mergePullRequestFn(context.Background(), req)
if err != nil {
t.Fatalf("mergePullRequestFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
if gotBody["force_merge"] != true {
t.Fatalf("expected force_merge true, got %v", gotBody["force_merge"])
}
if gotBody["merge_when_checks_succeed"] != true {
t.Fatalf("expected merge_when_checks_succeed true, got %v", gotBody["merge_when_checks_succeed"])
}
if gotBody["head_commit_id"] != "abc123" {
t.Fatalf("expected head_commit_id 'abc123', got %v", gotBody["head_commit_id"])
}
}
func Test_createPullRequestFn_labels(t *testing.T) {
const (
owner = "octo"
repo = "demo"
)
var (
mu sync.Mutex
gotBody map[string]any
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls", owner, repo):
mu.Lock()
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotBody = body
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":1,"title":"test","state":"open"}`))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
origVersion := flag.Version
flag.Host = server.URL
flag.Token = ""
flag.Version = "test"
defer func() {
flag.Host = origHost
flag.Token = origToken
flag.Version = origVersion
}()
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"title": "test",
"body": "body",
"head": "feature",
"base": "main",
"labels": []any{float64(1), float64(2)},
"deadline": "2026-06-01T00:00:00Z",
},
},
}
_, err := createPullRequestFn(context.Background(), req)
if err != nil {
t.Fatalf("createPullRequestFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
labels, ok := gotBody["labels"].([]any)
if !ok || len(labels) != 2 {
t.Fatalf("expected 2 labels, got %v", gotBody["labels"])
}
if labels[0] != float64(1) || labels[1] != float64(2) {
t.Fatalf("expected labels [1,2], got %v", labels)
}
if gotBody["due_date"] == nil {
t.Fatalf("expected due_date to be set")
}
}
func Test_applyDraftPrefix(t *testing.T) {
tests := []struct {
name string
title string
isDraft bool
want string
}{
{"add prefix", "my feature", true, "WIP: my feature"},
{"already prefixed WIP:", "WIP: my feature", true, "WIP: my feature"},
{"already prefixed WIP: no space", "WIP:my feature", true, "WIP:my feature"},
{"already prefixed [WIP]", "[WIP] my feature", true, "[WIP] my feature"},
{"already prefixed case insensitive", "wip: my feature", true, "wip: my feature"},
{"already prefixed [wip]", "[wip] my feature", true, "[wip] my feature"},
{"remove WIP: prefix", "WIP: my feature", false, "my feature"},
{"remove WIP: no space", "WIP:my feature", false, "my feature"},
{"remove [WIP] prefix", "[WIP] my feature", false, "my feature"},
{"remove [wip] prefix", "[wip] my feature", false, "my feature"},
{"remove wip: lowercase", "wip: my feature", false, "my feature"},
{"no prefix not draft", "my feature", false, "my feature"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := applyDraftPrefix(tt.title, tt.isDraft)
if got != tt.want {
t.Fatalf("applyDraftPrefix(%q, %v) = %q, want %q", tt.title, tt.isDraft, got, tt.want)
}
})
}
}
func Test_createPullRequestFn_draft(t *testing.T) {
const (
owner = "octo"
repo = "demo"
)
tests := []struct {
name string
title string
draft any // bool or nil (omitted)
wantTitle string
}{
{"draft true", "my feature", true, "WIP: my feature"},
{"draft false strips WIP:", "WIP: my feature", false, "my feature"},
{"draft false strips [WIP]", "[WIP] my feature", false, "my feature"},
{"draft omitted preserves title", "WIP: my feature", nil, "WIP: my feature"},
{"draft true already prefixed", "WIP: my feature", true, "WIP: my feature"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var (
mu sync.Mutex
gotBody map[string]any
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls", owner, repo):
mu.Lock()
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotBody = body
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(fmt.Appendf(nil, `{"number":1,"title":%q,"state":"open"}`, body["title"]))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
origVersion := flag.Version
flag.Host = server.URL
flag.Token = ""
flag.Version = "test"
defer func() {
flag.Host = origHost
flag.Token = origToken
flag.Version = origVersion
}()
args := map[string]any{
"owner": owner,
"repo": repo,
"title": tc.title,
"body": "test body",
"head": "feature",
"base": "main",
}
if tc.draft != nil {
args["draft"] = tc.draft
}
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: args,
},
}
_, err := createPullRequestFn(context.Background(), req)
if err != nil {
t.Fatalf("createPullRequestFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
if gotBody["title"] != tc.wantTitle {
t.Fatalf("expected title %q, got %v", tc.wantTitle, gotBody["title"])
}
})
}
}
func Test_editPullRequestFn_draft(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 7
)
tests := []struct {
name string
title string // title arg passed to the tool; empty means omitted
draft any
wantTitle string
}{
{"set draft with title", "my feature", true, "WIP: my feature"},
{"unset draft with title", "WIP: my feature", false, "my feature"},
{"set draft without title fetches current", "", true, "WIP: existing title"},
{"unset draft without title fetches current", "", false, "existing title"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var (
mu sync.Mutex
gotBody map[string]any
)
prPath := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case prPath:
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodGet {
// Auto-fetch: return the existing PR with its current title
_, _ = w.Write(fmt.Appendf(nil, `{"number":%d,"title":"existing title","state":"open"}`, index))
return
}
mu.Lock()
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotBody = body
mu.Unlock()
title := "existing title"
if s, ok := body["title"].(string); ok {
title = s
}
_, _ = w.Write(fmt.Appendf(nil, `{"number":%d,"title":%q,"state":"open"}`, index, title))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
origVersion := flag.Version
flag.Host = server.URL
flag.Token = ""
flag.Version = "test"
defer func() {
flag.Host = origHost
flag.Token = origToken
flag.Version = origVersion
}()
args := map[string]any{
"owner": owner,
"repo": repo,
"index": float64(index),
}
if tc.title != "" {
args["title"] = tc.title
}
if tc.draft != nil {
args["draft"] = tc.draft
}
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: args,
},
}
_, err := editPullRequestFn(context.Background(), req)
if err != nil {
t.Fatalf("editPullRequestFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
if gotBody["title"] != tc.wantTitle {
t.Fatalf("expected title %q, got %v", tc.wantTitle, gotBody["title"])
}
})
}
}
func Test_getPullRequestDiffFn(t *testing.T) {
const (
owner = "octo"
+5 -2
View File
@@ -43,6 +43,8 @@ var (
mcp.WithDescription("List branches"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
)
)
@@ -131,10 +133,11 @@ func ListBranchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListRepoBranchesOptions{
ListOptions: gitea_sdk.ListOptions{
Page: 1,
PageSize: 30,
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
+309
View File
@@ -0,0 +1,309 @@
package repo
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
BranchProtectionReadToolName = "branch_protection_read"
BranchProtectionWriteToolName = "branch_protection_write"
)
var (
BranchProtectionReadTool = mcp.NewTool(
BranchProtectionReadToolName,
mcp.WithDescription("Read branch protection rules. Use method 'list' to list all rules, 'get' to get a specific rule."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("name", mcp.Description("branch protection rule name (required for 'get')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
BranchProtectionWriteTool = mcp.NewTool(
BranchProtectionWriteToolName,
mcp.WithDescription("Create, edit, or delete branch protection rules."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "edit", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("branch_name", mcp.Description("branch name or pattern (required for 'create')")),
mcp.WithString("rule_name", mcp.Description("rule name (required for 'edit', 'delete')")),
mcp.WithBoolean("enable_push", mcp.Description("enable push to protected branch")),
mcp.WithArray("push_whitelist_usernames", mcp.Description("usernames allowed to push"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithArray("push_whitelist_teams", mcp.Description("team names allowed to push"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithBoolean("enable_merge_whitelist", mcp.Description("enable merge whitelist")),
mcp.WithArray("merge_whitelist_usernames", mcp.Description("usernames allowed to merge"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithArray("merge_whitelist_teams", mcp.Description("team names allowed to merge"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithNumber("required_approvals", mcp.Description("number of required approvals")),
mcp.WithBoolean("block_on_rejected_reviews", mcp.Description("block merge on rejected reviews")),
mcp.WithBoolean("block_on_outdated_branch", mcp.Description("block merge on outdated branch")),
mcp.WithBoolean("dismiss_stale_approvals", mcp.Description("dismiss stale approvals on new commits")),
mcp.WithBoolean("require_signed_commits", mcp.Description("require signed commits")),
mcp.WithString("protected_file_patterns", mcp.Description("protected file patterns (semicolon separated)")),
mcp.WithString("unprotected_file_patterns", mcp.Description("unprotected file patterns (semicolon separated)")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: BranchProtectionReadTool,
Handler: branchProtectionReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: BranchProtectionWriteTool,
Handler: branchProtectionWriteFn,
})
}
func branchProtectionReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list":
return listBranchProtectionsFn(ctx, req)
case "get":
return getBranchProtectionFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listBranchProtectionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listBranchProtectionsFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
bps, _, err := client.ListBranchProtections(owner, repo, gitea_sdk.ListBranchProtectionsOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("list branch protections %v/%v err: %v", owner, repo, err))
}
return to.TextResult(slimBranchProtections(bps))
}
func getBranchProtectionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getBranchProtectionFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(args, "name")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
bp, _, err := client.GetBranchProtection(owner, repo, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("get branch protection %v in %v/%v err: %v", name, owner, repo, err))
}
return to.TextResult(slimBranchProtection(bp))
}
func branchProtectionWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createBranchProtectionFn(ctx, req)
case "edit":
return editBranchProtectionFn(ctx, req)
case "delete":
return deleteBranchProtectionFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func createBranchProtectionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createBranchProtectionFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
branchName, err := params.GetString(args, "branch_name")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.CreateBranchProtectionOption{
BranchName: branchName,
}
if v, ok := args["enable_push"].(bool); ok {
opt.EnablePush = v
}
opt.PushWhitelistUsernames = params.GetStringSlice(args, "push_whitelist_usernames")
opt.PushWhitelistTeams = params.GetStringSlice(args, "push_whitelist_teams")
if v, ok := args["enable_merge_whitelist"].(bool); ok {
opt.EnableMergeWhitelist = v
}
opt.MergeWhitelistUsernames = params.GetStringSlice(args, "merge_whitelist_usernames")
opt.MergeWhitelistTeams = params.GetStringSlice(args, "merge_whitelist_teams")
if val, exists := args["required_approvals"]; exists {
if i, ok := params.ToInt64(val); ok {
opt.RequiredApprovals = i
}
}
if v, ok := args["block_on_rejected_reviews"].(bool); ok {
opt.BlockOnRejectedReviews = v
}
if v, ok := args["block_on_outdated_branch"].(bool); ok {
opt.BlockOnOutdatedBranch = v
}
if v, ok := args["dismiss_stale_approvals"].(bool); ok {
opt.DismissStaleApprovals = v
}
if v, ok := args["require_signed_commits"].(bool); ok {
opt.RequireSignedCommits = v
}
if v, ok := args["protected_file_patterns"].(string); ok {
opt.ProtectedFilePatterns = v
}
if v, ok := args["unprotected_file_patterns"].(string); ok {
opt.UnprotectedFilePatterns = v
}
bp, _, err := client.CreateBranchProtection(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create branch protection in %v/%v err: %v", owner, repo, err))
}
return to.TextResult(slimBranchProtection(bp))
}
func editBranchProtectionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editBranchProtectionFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
ruleName, err := params.GetString(args, "rule_name")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.EditBranchProtectionOption{}
if v, ok := args["enable_push"].(bool); ok {
opt.EnablePush = &v
}
if v := params.GetStringSlice(args, "push_whitelist_usernames"); v != nil {
opt.PushWhitelistUsernames = v
}
if v := params.GetStringSlice(args, "push_whitelist_teams"); v != nil {
opt.PushWhitelistTeams = v
}
if v, ok := args["enable_merge_whitelist"].(bool); ok {
opt.EnableMergeWhitelist = &v
}
if v := params.GetStringSlice(args, "merge_whitelist_usernames"); v != nil {
opt.MergeWhitelistUsernames = v
}
if v := params.GetStringSlice(args, "merge_whitelist_teams"); v != nil {
opt.MergeWhitelistTeams = v
}
if val, exists := args["required_approvals"]; exists {
if i, ok := params.ToInt64(val); ok {
opt.RequiredApprovals = &i
}
}
if v, ok := args["block_on_rejected_reviews"].(bool); ok {
opt.BlockOnRejectedReviews = &v
}
if v, ok := args["block_on_outdated_branch"].(bool); ok {
opt.BlockOnOutdatedBranch = &v
}
if v, ok := args["dismiss_stale_approvals"].(bool); ok {
opt.DismissStaleApprovals = &v
}
if v, ok := args["require_signed_commits"].(bool); ok {
opt.RequireSignedCommits = &v
}
if v, ok := args["protected_file_patterns"].(string); ok {
opt.ProtectedFilePatterns = &v
}
if v, ok := args["unprotected_file_patterns"].(string); ok {
opt.UnprotectedFilePatterns = &v
}
bp, _, err := client.EditBranchProtection(owner, repo, ruleName, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit branch protection %v in %v/%v err: %v", ruleName, owner, repo, err))
}
return to.TextResult(slimBranchProtection(bp))
}
func deleteBranchProtectionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteBranchProtectionFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
ruleName, err := params.GetString(args, "rule_name")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DeleteBranchProtection(owner, repo, ruleName)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete branch protection %v from %v/%v err: %v", ruleName, owner, repo, err))
}
return mcp.NewToolResultText(fmt.Sprintf("Branch protection rule '%s' deleted from %s/%s", ruleName, owner, repo)), nil
}
+168
View File
@@ -0,0 +1,168 @@
package repo
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
CollaboratorReadToolName = "collaborator_read"
CollaboratorWriteToolName = "collaborator_write"
)
var (
CollaboratorReadTool = mcp.NewTool(
CollaboratorReadToolName,
mcp.WithDescription("List repository collaborators."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
CollaboratorWriteTool = mcp.NewTool(
CollaboratorWriteToolName,
mcp.WithDescription("Add or remove repository collaborators. Use method 'add' to grant access, 'delete' to revoke."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("add", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("username", mcp.Required(), mcp.Description("collaborator username")),
mcp.WithString("permission", mcp.Description("permission level (for 'add')"), mcp.Enum("read", "write", "admin")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: CollaboratorReadTool,
Handler: collaboratorReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: CollaboratorWriteTool,
Handler: collaboratorWriteFn,
})
}
func collaboratorReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list":
return listCollaboratorsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listCollaboratorsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listCollaboratorsFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(args, 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
users, _, err := client.ListCollaborators(owner, repo, gitea_sdk.ListCollaboratorsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list collaborators %v/%v err: %v", owner, repo, err))
}
return to.TextResult(slimUsers(users))
}
func collaboratorWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "add":
return addCollaboratorFn(ctx, req)
case "delete":
return deleteCollaboratorFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func addCollaboratorFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called addCollaboratorFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
username, err := params.GetString(args, "username")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.AddCollaboratorOption{}
if perm, ok := args["permission"].(string); ok {
am := gitea_sdk.AccessMode(perm)
opt.Permission = &am
}
_, err = client.AddCollaborator(owner, repo, username, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("add collaborator %v to %v/%v err: %v", username, owner, repo, err))
}
return mcp.NewToolResultText(fmt.Sprintf("Collaborator %s added to %s/%s", username, owner, repo)), nil
}
func deleteCollaboratorFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteCollaboratorFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
username, err := params.GetString(args, "username")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DeleteCollaborator(owner, repo, username)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete collaborator %v from %v/%v err: %v", username, owner, repo, err))
}
return mcp.NewToolResultText(fmt.Sprintf("Collaborator %s removed from %s/%s", username, owner, repo)), nil
}
+43 -2
View File
@@ -16,9 +16,11 @@ import (
const (
ListRepoCommitsToolName = "list_commits"
GetCommitToolName = "get_commit"
)
var ListRepoCommitsTool = mcp.NewTool(
var (
ListRepoCommitsTool = mcp.NewTool(
ListRepoCommitsToolName,
mcp.WithDescription("List repository commits"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
@@ -26,7 +28,16 @@ var ListRepoCommitsTool = mcp.NewTool(
mcp.WithString("sha", mcp.Description("SHA or branch to start listing commits from")),
mcp.WithString("path", mcp.Description("path indicates that only commits that include the path's file/dir should be returned.")),
mcp.WithNumber("page", mcp.Required(), mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30), mcp.Min(1)),
)
GetCommitTool = mcp.NewTool(
GetCommitToolName,
mcp.WithDescription("Get details of a specific commit"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("sha", mcp.Required(), mcp.Description("commit SHA")),
)
)
func init() {
@@ -34,6 +45,10 @@ func init() {
Tool: ListRepoCommitsTool,
Handler: ListRepoCommitsFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetCommitTool,
Handler: GetCommitFn,
})
}
func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -75,3 +90,29 @@ func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
}
return to.TextResult(slimCommits(commits))
}
func GetCommitFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetCommitFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
sha, err := params.GetString(args, "sha")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
commit, _, err := client.GetSingleCommit(owner, repo, sha)
if err != nil {
return to.ErrorResult(fmt.Errorf("get commit %v err: %v", sha, err))
}
return to.TextResult(slimCommit(commit))
}
+59
View File
@@ -0,0 +1,59 @@
package repo
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
GetCompareToolName = "get_compare"
)
var (
GetCompareTool = mcp.NewTool(
GetCompareToolName,
mcp.WithDescription("Compare two branches, tags, or commits. Returns commits and diff stats between the base and head."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("basehead", mcp.Required(), mcp.Description("comparison in format 'base...head' (e.g. 'main...feature-branch')")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: GetCompareTool,
Handler: getCompareFn,
})
}
func getCompareFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getCompareFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
basehead, err := params.GetString(args, "basehead")
if err != nil {
return to.ErrorResult(err)
}
var result map[string]any
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/compare/%s", owner, repo, basehead), nil, nil, &result)
if err != nil {
return to.ErrorResult(fmt.Errorf("compare %v in %v/%v err: %v", basehead, owner, repo, err))
}
return to.TextResult(result)
}
+206
View File
@@ -0,0 +1,206 @@
package repo
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
DeployKeyReadToolName = "deploy_key_read"
DeployKeyWriteToolName = "deploy_key_write"
)
var (
DeployKeyReadTool = mcp.NewTool(
DeployKeyReadToolName,
mcp.WithDescription("Read deploy keys. Use method 'list' to list all, 'get' to get a specific key."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Description("deploy key ID (required for 'get')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
DeployKeyWriteTool = mcp.NewTool(
DeployKeyWriteToolName,
mcp.WithDescription("Create or delete deploy keys. Use method 'create' to add a new key, 'delete' to remove."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Description("deploy key ID (required for 'delete')")),
mcp.WithString("title", mcp.Description("key title (required for 'create')")),
mcp.WithString("key", mcp.Description("SSH public key content (required for 'create')")),
mcp.WithBoolean("read_only", mcp.Description("whether the key has read-only access (default true)")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: DeployKeyReadTool,
Handler: deployKeyReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeployKeyWriteTool,
Handler: deployKeyWriteFn,
})
}
func deployKeyReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list":
return listDeployKeysFn(ctx, req)
case "get":
return getDeployKeyFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listDeployKeysFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listDeployKeysFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(args, 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
keys, _, err := client.ListDeployKeys(owner, repo, gitea_sdk.ListDeployKeysOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list deploy keys %v/%v err: %v", owner, repo, err))
}
return to.TextResult(slimDeployKeys(keys))
}
func getDeployKeyFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getDeployKeyFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
id, err := params.GetIndex(args, "id")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
key, _, err := client.GetDeployKey(owner, repo, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("get deploy key %v in %v/%v err: %v", id, owner, repo, err))
}
return to.TextResult(slimDeployKey(key))
}
func deployKeyWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createDeployKeyFn(ctx, req)
case "delete":
return deleteDeployKeyFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func createDeployKeyFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createDeployKeyFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
title, err := params.GetString(args, "title")
if err != nil {
return to.ErrorResult(err)
}
key, err := params.GetString(args, "key")
if err != nil {
return to.ErrorResult(err)
}
readOnly := true
if v, ok := args["read_only"].(bool); ok {
readOnly = v
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
dk, _, err := client.CreateDeployKey(owner, repo, gitea_sdk.CreateKeyOption{
Title: title,
Key: key,
ReadOnly: readOnly,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("create deploy key in %v/%v err: %v", owner, repo, err))
}
return to.TextResult(slimDeployKey(dk))
}
func deleteDeployKeyFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteDeployKeyFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
id, err := params.GetIndex(args, "id")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DeleteDeployKey(owner, repo, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete deploy key %v from %v/%v err: %v", id, owner, repo, err))
}
return mcp.NewToolResultText(fmt.Sprintf("Deploy key %d deleted from %s/%s", id, owner, repo)), nil
}
+1 -1
View File
@@ -67,7 +67,7 @@ var (
mcp.WithBoolean("is_draft", mcp.Description("Whether the release is draft"), mcp.DefaultBool(false)),
mcp.WithBoolean("is_pre_release", mcp.Description("Whether the release is pre-release"), mcp.DefaultBool(false)),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(20), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(20), mcp.Min(1)),
)
)
+227 -1
View File
@@ -23,6 +23,9 @@ const (
ForkRepoToolName = "fork_repo"
ListMyReposToolName = "list_my_repos"
ListOrgReposToolName = "list_org_repos"
GetRepoToolName = "get_repo"
RepoWriteToolName = "repo_write"
ListForksToolName = "list_forks"
)
var (
@@ -39,6 +42,8 @@ var (
mcp.WithString("license", mcp.Description("License to use")),
mcp.WithString("readme", mcp.Description("Readme of the repository to create")),
mcp.WithString("default_branch", mcp.Description("DefaultBranch of the repository (used when initializes and in template)")),
mcp.WithString("trust_model", mcp.Description("Trust model for verifying GPG signatures"), mcp.Enum("default", "collaborator", "committer", "collaboratorcommitter")),
mcp.WithString("object_format_name", mcp.Description("Object format: sha1 or sha256"), mcp.Enum("sha1", "sha256")),
mcp.WithString("organization", mcp.Description("Organization name to create repository in (optional - defaults to personal account)")),
)
@@ -55,7 +60,7 @@ var (
ListMyReposToolName,
mcp.WithDescription("List my repositories"),
mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30), mcp.Min(1)),
)
ListOrgReposTool = mcp.NewTool(
@@ -65,6 +70,40 @@ var (
mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("pageSize", mcp.Required(), mcp.Description("Page size number"), mcp.DefaultNumber(100), mcp.Min(1)),
)
GetRepoTool = mcp.NewTool(
GetRepoToolName,
mcp.WithDescription("Get repository details including settings, permissions, and metadata"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
)
RepoWriteTool = mcp.NewTool(
RepoWriteToolName,
mcp.WithDescription("Edit, delete, or transfer a repository. Use method 'edit' to change settings, 'delete' to remove, 'transfer' to change ownership."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("edit", "delete", "transfer")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("description", mcp.Description("repository description (for 'edit')")),
mcp.WithString("website", mcp.Description("repository website URL (for 'edit')")),
mcp.WithBoolean("private", mcp.Description("whether the repository is private (for 'edit')")),
mcp.WithBoolean("has_issues", mcp.Description("enable issues (for 'edit')")),
mcp.WithBoolean("has_wiki", mcp.Description("enable wiki (for 'edit')")),
mcp.WithBoolean("has_pull_requests", mcp.Description("enable pull requests (for 'edit')")),
mcp.WithString("default_branch", mcp.Description("default branch name (for 'edit')")),
mcp.WithBoolean("archived", mcp.Description("archive/unarchive the repository (for 'edit')")),
mcp.WithString("new_owner", mcp.Description("new owner username or org (required for 'transfer')")),
mcp.WithArray("team_ids", mcp.Description("team IDs to grant access to (for 'transfer')"), mcp.Items(map[string]any{"type": "number"})),
)
ListForksTool = mcp.NewTool(
ListForksToolName,
mcp.WithDescription("List forks of a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
)
)
func init() {
@@ -84,6 +123,18 @@ func init() {
Tool: ListOrgReposTool,
Handler: ListOrgReposFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetRepoTool,
Handler: GetRepoFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: RepoWriteTool,
Handler: repoWriteFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListForksTool,
Handler: ListForksFn,
})
}
func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -102,6 +153,8 @@ func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
license, _ := args["license"].(string)
readme, _ := args["readme"].(string)
defaultBranch, _ := args["default_branch"].(string)
trustModel, _ := args["trust_model"].(string)
objectFormatName, _ := args["object_format_name"].(string)
organization, _ := args["organization"].(string)
opt := gitea_sdk.CreateRepoOption{
@@ -115,6 +168,8 @@ func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
License: license,
Readme: readme,
DefaultBranch: defaultBranch,
TrustModel: gitea_sdk.TrustModel(trustModel),
ObjectFormatName: objectFormatName,
}
var repo *gitea_sdk.Repository
@@ -223,3 +278,174 @@ func ListOrgReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
}
return to.TextResult(repos)
}
func GetRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetRepoFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
r, _, err := client.GetRepo(owner, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("get repo %v/%v err: %v", owner, repo, err))
}
return to.TextResult(slimRepoDetail(r))
}
func repoWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "edit":
return editRepoFn(ctx, req)
case "delete":
return deleteRepoFn(ctx, req)
case "transfer":
return transferRepoFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func editRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editRepoFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.EditRepoOption{}
if v, ok := args["description"].(string); ok {
opt.Description = &v
}
if v, ok := args["website"].(string); ok {
opt.Website = &v
}
if v, ok := args["private"].(bool); ok {
opt.Private = &v
}
if v, ok := args["has_issues"].(bool); ok {
opt.HasIssues = &v
}
if v, ok := args["has_wiki"].(bool); ok {
opt.HasWiki = &v
}
if v, ok := args["has_pull_requests"].(bool); ok {
opt.HasPullRequests = &v
}
if v, ok := args["default_branch"].(string); ok {
opt.DefaultBranch = &v
}
if v, ok := args["archived"].(bool); ok {
opt.Archived = &v
}
r, _, err := client.EditRepo(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit repo %v/%v err: %v", owner, repo, err))
}
return to.TextResult(slimRepo(r))
}
func deleteRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteRepoFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DeleteRepo(owner, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete repo %v/%v err: %v", owner, repo, err))
}
return mcp.NewToolResultText(fmt.Sprintf("Repository %s/%s deleted successfully", owner, repo)), nil
}
func transferRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called transferRepoFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
newOwner, err := params.GetString(args, "new_owner")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.TransferRepoOption{
NewOwner: newOwner,
}
if teamIDs, err := params.GetInt64Slice(args, "team_ids"); err == nil {
opt.TeamIDs = &teamIDs
}
r, _, err := client.TransferRepo(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("transfer repo %v/%v err: %v", owner, repo, err))
}
return to.TextResult(slimRepo(r))
}
func ListForksFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListForksFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(args, 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
forks, _, err := client.ListForks(owner, repo, gitea_sdk.ListForksOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list forks %v/%v err: %v", owner, repo, err))
}
return to.TextResult(slimRepos(forks))
}
+162
View File
@@ -184,6 +184,168 @@ func slimContents(c *gitea_sdk.ContentsResponse) map[string]any {
return m
}
func slimTree(t *gitea_sdk.GitTreeResponse) map[string]any {
if t == nil {
return nil
}
entries := make([]map[string]any, 0, len(t.Entries))
for _, e := range t.Entries {
entries = append(entries, map[string]any{
"path": e.Path,
"mode": e.Mode,
"type": e.Type,
"size": e.Size,
"sha": e.SHA,
})
}
return map[string]any{
"sha": t.SHA,
"truncated": t.Truncated,
"total_count": t.TotalCount,
"tree": entries,
}
}
func slimRepoDetail(r *gitea_sdk.Repository) map[string]any {
if r == nil {
return nil
}
m := slimRepo(r)
m["website"] = r.Website
m["has_issues"] = r.HasIssues
m["has_wiki"] = r.HasWiki
m["has_pull_requests"] = r.HasPullRequests
m["has_projects"] = r.HasProjects
m["mirror"] = r.Mirror
m["internal"] = r.Internal
m["empty"] = r.Empty
m["size"] = r.Size
if r.Permissions != nil {
m["permissions"] = map[string]any{
"admin": r.Permissions.Admin,
"push": r.Permissions.Push,
"pull": r.Permissions.Pull,
}
}
if r.Parent != nil {
m["parent"] = map[string]any{
"full_name": r.Parent.FullName,
"html_url": r.Parent.HTMLURL,
}
}
return m
}
func slimUser(u *gitea_sdk.User) map[string]any {
if u == nil {
return nil
}
return map[string]any{
"id": u.ID,
"login": u.UserName,
"full_name": u.FullName,
"email": u.Email,
"avatar_url": u.AvatarURL,
"is_admin": u.IsAdmin,
}
}
func slimUsers(users []*gitea_sdk.User) []map[string]any {
out := make([]map[string]any, 0, len(users))
for _, u := range users {
out = append(out, slimUser(u))
}
return out
}
func slimDeployKey(k *gitea_sdk.DeployKey) map[string]any {
if k == nil {
return nil
}
return map[string]any{
"id": k.ID,
"title": k.Title,
"fingerprint": k.Fingerprint,
"key": k.Key,
"url": k.URL,
"read_only": k.ReadOnly,
"created_at": k.Created,
}
}
func slimDeployKeys(keys []*gitea_sdk.DeployKey) []map[string]any {
out := make([]map[string]any, 0, len(keys))
for _, k := range keys {
out = append(out, slimDeployKey(k))
}
return out
}
func slimBranchProtection(bp *gitea_sdk.BranchProtection) map[string]any {
if bp == nil {
return nil
}
return map[string]any{
"branch_name": bp.BranchName,
"rule_name": bp.RuleName,
"enable_push": bp.EnablePush,
"push_whitelist_usernames": bp.PushWhitelistUsernames,
"push_whitelist_teams": bp.PushWhitelistTeams,
"enable_merge_whitelist": bp.EnableMergeWhitelist,
"merge_whitelist_usernames": bp.MergeWhitelistUsernames,
"merge_whitelist_teams": bp.MergeWhitelistTeams,
"required_approvals": bp.RequiredApprovals,
"enable_approvals_whitelist": bp.EnableApprovalsWhitelist,
"block_on_rejected_reviews": bp.BlockOnRejectedReviews,
"block_on_outdated_branch": bp.BlockOnOutdatedBranch,
"dismiss_stale_approvals": bp.DismissStaleApprovals,
"require_signed_commits": bp.RequireSignedCommits,
"protected_file_patterns": bp.ProtectedFilePatterns,
"unprotected_file_patterns": bp.UnprotectedFilePatterns,
"created_at": bp.Created,
"updated_at": bp.Updated,
}
}
func slimBranchProtections(bps []*gitea_sdk.BranchProtection) []map[string]any {
out := make([]map[string]any, 0, len(bps))
for _, bp := range bps {
out = append(out, slimBranchProtection(bp))
}
return out
}
func slimHook(h *gitea_sdk.Hook) map[string]any {
if h == nil {
return nil
}
m := map[string]any{
"id": h.ID,
"type": h.Type,
"active": h.Active,
"events": h.Events,
"created_at": h.Created,
"updated_at": h.Updated,
}
if h.Config != nil {
if url, ok := h.Config["url"]; ok {
m["url"] = url
}
if ct, ok := h.Config["content_type"]; ok {
m["content_type"] = ct
}
}
return m
}
func slimHooks(hooks []*gitea_sdk.Hook) []map[string]any {
out := make([]map[string]any, 0, len(hooks))
for _, h := range hooks {
out = append(out, slimHook(h))
}
return out
}
func slimDirEntries(entries []*gitea_sdk.ContentsResponse) []map[string]any {
out := make([]map[string]any, 0, len(entries))
for _, c := range entries {
+1 -1
View File
@@ -54,7 +54,7 @@ var (
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(20), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(20), mcp.Min(1)),
)
)
+169
View File
@@ -0,0 +1,169 @@
package repo
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
TopicReadToolName = "topic_read"
TopicWriteToolName = "topic_write"
)
var (
TopicReadTool = mcp.NewTool(
TopicReadToolName,
mcp.WithDescription("List repository topics."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
)
TopicWriteTool = mcp.NewTool(
TopicWriteToolName,
mcp.WithDescription("Manage repository topics. Use 'add' to add one, 'delete' to remove one, 'set' to replace all topics."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("add", "delete", "set")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("topic", mcp.Description("single topic name (required for 'add', 'delete')")),
mcp.WithArray("topics", mcp.Description("list of topic names (required for 'set')"), mcp.Items(map[string]any{"type": "string"})),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: TopicReadTool,
Handler: topicReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: TopicWriteTool,
Handler: topicWriteFn,
})
}
func topicReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called topicReadFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
topics, _, err := client.ListRepoTopics(owner, repo, gitea_sdk.ListRepoTopicsOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("list topics %v/%v err: %v", owner, repo, err))
}
return to.TextResult(topics)
}
func topicWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "add":
return addTopicFn(ctx, req)
case "delete":
return deleteTopicFn(ctx, req)
case "set":
return setTopicsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func addTopicFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called addTopicFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
topic, err := params.GetString(args, "topic")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.AddRepoTopic(owner, repo, topic)
if err != nil {
return to.ErrorResult(fmt.Errorf("add topic %v to %v/%v err: %v", topic, owner, repo, err))
}
return mcp.NewToolResultText(fmt.Sprintf("Topic '%s' added to %s/%s", topic, owner, repo)), nil
}
func deleteTopicFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteTopicFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
topic, err := params.GetString(args, "topic")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DeleteRepoTopic(owner, repo, topic)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete topic %v from %v/%v err: %v", topic, owner, repo, err))
}
return mcp.NewToolResultText(fmt.Sprintf("Topic '%s' removed from %s/%s", topic, owner, repo)), nil
}
func setTopicsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called setTopicsFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
topics := params.GetStringSlice(args, "topics")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.SetRepoTopics(owner, repo, topics)
if err != nil {
return to.ErrorResult(fmt.Errorf("set topics for %v/%v err: %v", owner, repo, err))
}
return to.TextResult(map[string]any{
"message": fmt.Sprintf("Topics set for %s/%s", owner, repo),
"topics": topics,
})
}
+74
View File
@@ -0,0 +1,74 @@
package repo
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
GetRepoTreeToolName = "get_repository_tree"
)
var GetRepoTreeTool = mcp.NewTool(
GetRepoTreeToolName,
mcp.WithDescription("Get the file tree of a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("tree_sha", mcp.Required(), mcp.Description("SHA, branch name, or tag name")),
mcp.WithBoolean("recursive", mcp.Description("whether to get the tree recursively")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: GetRepoTreeTool,
Handler: GetRepoTreeFn,
})
}
func GetRepoTreeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetRepoTreeFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
treeSHA, err := params.GetString(args, "tree_sha")
if err != nil {
return to.ErrorResult(err)
}
recursive, _ := args["recursive"].(bool)
page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListTreeOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
Ref: treeSHA,
Recursive: recursive,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
tree, _, err := client.GetTrees(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("get repository tree err: %v", err))
}
return to.TextResult(slimTree(tree))
}
+52
View File
@@ -0,0 +1,52 @@
package repo
import (
"slices"
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimTree(t *testing.T) {
tree := &gitea_sdk.GitTreeResponse{
SHA: "abc123",
TotalCount: 2,
Truncated: false,
Entries: []gitea_sdk.GitEntry{
{Path: "src", Mode: "040000", Type: "tree", Size: 0, SHA: "def456"},
{Path: "main.go", Mode: "100644", Type: "blob", Size: 42, SHA: "789abc"},
},
}
m := slimTree(tree)
if m["sha"] != "abc123" {
t.Errorf("expected sha abc123, got %v", m["sha"])
}
if m["total_count"] != 2 {
t.Errorf("expected total_count 2, got %v", m["total_count"])
}
entries := m["tree"].([]map[string]any)
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
if entries[0]["path"] != "src" {
t.Errorf("expected first entry path src, got %v", entries[0]["path"])
}
if entries[1]["type"] != "blob" {
t.Errorf("expected second entry type blob, got %v", entries[1]["type"])
}
}
func TestSlimTreeNil(t *testing.T) {
if m := slimTree(nil); m != nil {
t.Errorf("expected nil, got %v", m)
}
}
func TestGetRepoTreeToolRequired(t *testing.T) {
for _, field := range []string{"owner", "repo", "tree_sha"} {
if !slices.Contains(GetRepoTreeTool.InputSchema.Required, field) {
t.Errorf("expected %q to be required", field)
}
}
}
+304
View File
@@ -0,0 +1,304 @@
package repo
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
WebhookReadToolName = "webhook_read"
WebhookWriteToolName = "webhook_write"
)
var (
WebhookReadTool = mcp.NewTool(
WebhookReadToolName,
mcp.WithDescription("Read repository webhooks. Use method 'list' to list all, 'get' to get a specific webhook."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Description("webhook ID (required for 'get')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
WebhookWriteTool = mcp.NewTool(
WebhookWriteToolName,
mcp.WithDescription("Create, edit, delete, or test repository webhooks."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "edit", "delete", "test")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Description("webhook ID (required for 'edit', 'delete', 'test')")),
mcp.WithString("type", mcp.Description("webhook type (for 'create')"), mcp.Enum("gitea", "slack", "discord", "dingtalk", "telegram", "msteams", "feishu", "wechatwork", "packagist")),
mcp.WithString("url", mcp.Description("target URL (required for 'create', optional for 'edit')")),
mcp.WithString("content_type", mcp.Description("content type"), mcp.Enum("json", "form")),
mcp.WithString("secret", mcp.Description("webhook secret")),
mcp.WithArray("events", mcp.Description("event types to trigger on (e.g. push, pull_request, issues)"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithBoolean("active", mcp.Description("whether the webhook is active")),
mcp.WithString("branch_filter", mcp.Description("branch filter pattern")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: WebhookReadTool,
Handler: webhookReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: WebhookWriteTool,
Handler: webhookWriteFn,
})
}
func webhookReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list":
return listWebhooksFn(ctx, req)
case "get":
return getWebhookFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listWebhooksFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listWebhooksFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(args, 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
hooks, _, err := client.ListRepoHooks(owner, repo, gitea_sdk.ListHooksOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list webhooks %v/%v err: %v", owner, repo, err))
}
return to.TextResult(slimHooks(hooks))
}
func getWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getWebhookFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
id, err := params.GetIndex(args, "id")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
hook, _, err := client.GetRepoHook(owner, repo, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("get webhook %v in %v/%v err: %v", id, owner, repo, err))
}
return to.TextResult(slimHook(hook))
}
func webhookWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createWebhookFn(ctx, req)
case "edit":
return editWebhookFn(ctx, req)
case "delete":
return deleteWebhookFn(ctx, req)
case "test":
return testWebhookFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func createWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createWebhookFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
hookType := params.GetOptionalString(args, "type", "gitea")
url, err := params.GetString(args, "url")
if err != nil {
return to.ErrorResult(err)
}
contentType := params.GetOptionalString(args, "content_type", "json")
secret, _ := args["secret"].(string)
events := params.GetStringSlice(args, "events")
active := true
if v, ok := args["active"].(bool); ok {
active = v
}
branchFilter, _ := args["branch_filter"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.CreateHookOption{
Type: gitea_sdk.HookType(hookType),
Config: map[string]string{
"url": url,
"content_type": contentType,
"secret": secret,
},
Events: events,
Active: active,
BranchFilter: branchFilter,
}
hook, _, err := client.CreateRepoHook(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create webhook in %v/%v err: %v", owner, repo, err))
}
return to.TextResult(slimHook(hook))
}
func editWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editWebhookFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
id, err := params.GetIndex(args, "id")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.EditHookOption{}
config := map[string]string{}
if v, ok := args["url"].(string); ok {
config["url"] = v
}
if v, ok := args["content_type"].(string); ok {
config["content_type"] = v
}
if v, ok := args["secret"].(string); ok {
config["secret"] = v
}
if len(config) > 0 {
opt.Config = config
}
if events := params.GetStringSlice(args, "events"); events != nil {
opt.Events = events
}
if v, ok := args["active"].(bool); ok {
opt.Active = &v
}
if v, ok := args["branch_filter"].(string); ok {
opt.BranchFilter = v
}
_, err = client.EditRepoHook(owner, repo, id, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit webhook %v in %v/%v err: %v", id, owner, repo, err))
}
// EditRepoHook doesn't return the hook, fetch it
hook, _, err := client.GetRepoHook(owner, repo, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("get webhook %v in %v/%v after edit err: %v", id, owner, repo, err))
}
return to.TextResult(slimHook(hook))
}
func deleteWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteWebhookFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
id, err := params.GetIndex(args, "id")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DeleteRepoHook(owner, repo, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete webhook %v from %v/%v err: %v", id, owner, repo, err))
}
return mcp.NewToolResultText(fmt.Sprintf("Webhook %d deleted from %s/%s", id, owner, repo)), nil
}
func testWebhookFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called testWebhookFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
id, err := params.GetIndex(args, "id")
if err != nil {
return to.ErrorResult(err)
}
// TestRepoHook is not in the SDK - use raw REST
_, err = gitea.DoJSON(ctx, "POST", fmt.Sprintf("repos/%s/%s/hooks/%d/tests", owner, repo, id), nil, nil, nil)
if err != nil {
return to.ErrorResult(fmt.Errorf("test webhook %v in %v/%v err: %v", id, owner, repo, err))
}
return mcp.NewToolResultText(fmt.Sprintf("Webhook %d test triggered for %s/%s", id, owner, repo)), nil
}
+60 -3
View File
@@ -3,6 +3,7 @@ package search
import (
"context"
"fmt"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
@@ -21,6 +22,7 @@ const (
SearchUsersToolName = "search_users"
SearchOrgTeamsToolName = "search_org_teams"
SearchReposToolName = "search_repos"
SearchIssuesToolName = "search_issues"
)
var (
@@ -29,7 +31,7 @@ var (
mcp.WithDescription("search users"),
mcp.WithString("keyword", mcp.Required(), mcp.Description("Keyword")),
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
)
SearOrgTeamsTool = mcp.NewTool(
@@ -39,7 +41,7 @@ var (
mcp.WithString("query", mcp.Required(), mcp.Description("search organization teams")),
mcp.WithBoolean("includeDescription", mcp.Description("include description?")),
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
)
SearchReposTool = mcp.NewTool(
@@ -54,7 +56,19 @@ var (
mcp.WithString("sort", mcp.Description("Sort")),
mcp.WithString("order", mcp.Description("Order")),
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
)
SearchIssuesTool = mcp.NewTool(
SearchIssuesToolName,
mcp.WithDescription("Search for issues and pull requests across all accessible repositories"),
mcp.WithString("query", mcp.Required(), mcp.Description("search keyword")),
mcp.WithString("state", mcp.Description("filter by state: open, closed, all"), mcp.Enum("open", "closed", "all")),
mcp.WithString("type", mcp.Description("filter by type: issues, pulls"), mcp.Enum("issues", "pulls")),
mcp.WithString("labels", mcp.Description("comma-separated list of label names")),
mcp.WithString("owner", mcp.Description("filter by repository owner")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
)
)
@@ -71,6 +85,10 @@ func init() {
Tool: SearchReposTool,
Handler: ReposFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: SearchIssuesTool,
Handler: IssuesFn,
})
}
func UsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -175,3 +193,42 @@ func ReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult,
}
return to.TextResult(slimRepos(repos))
}
func IssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called IssuesFn")
args := req.GetArguments()
query, err := params.GetString(args, "query")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListIssueOption{
KeyWord: query,
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
if state, ok := args["state"].(string); ok {
opt.State = gitea_sdk.StateType(state)
}
if issueType, ok := args["type"].(string); ok {
opt.Type = gitea_sdk.IssueType(issueType)
}
if labels, ok := args["labels"].(string); ok && labels != "" {
opt.Labels = strings.Split(labels, ",")
}
if owner, ok := args["owner"].(string); ok {
opt.Owner = owner
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
issues, _, err := client.ListIssues(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("search issues err: %v", err))
}
return to.TextResult(slimIssues(issues))
}
+56
View File
@@ -86,3 +86,59 @@ func slimRepos(repos []*gitea_sdk.Repository) []map[string]any {
}
return out
}
func userLogin(u *gitea_sdk.User) string {
if u == nil {
return ""
}
return u.UserName
}
func labelNames(labels []*gitea_sdk.Label) []string {
if len(labels) == 0 {
return nil
}
out := make([]string, 0, len(labels))
for _, l := range labels {
if l != nil {
out = append(out, l.Name)
}
}
return out
}
func slimIssues(issues []*gitea_sdk.Issue) []map[string]any {
out := make([]map[string]any, 0, len(issues))
for _, i := range issues {
if i == nil {
continue
}
m := map[string]any{
"number": i.Index,
"title": i.Title,
"state": i.State,
"html_url": i.HTMLURL,
"user": userLogin(i.Poster),
"comments": i.Comments,
"created_at": i.Created,
"updated_at": i.Updated,
}
if len(i.Labels) > 0 {
m["labels"] = labelNames(i.Labels)
}
if i.Repository != nil {
m["repository"] = i.Repository.FullName
}
if i.Ref != "" {
m["ref"] = i.Ref
}
if i.Deadline != nil {
m["deadline"] = i.Deadline
}
if i.PullRequest != nil {
m["is_pull"] = true
}
out = append(out, m)
}
return out
}
+54
View File
@@ -0,0 +1,54 @@
package search
import (
"slices"
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimIssues(t *testing.T) {
issues := []*gitea_sdk.Issue{
{
Index: 1,
Title: "Bug report",
State: gitea_sdk.StateOpen,
HTMLURL: "https://gitea.com/org/repo/issues/1",
Poster: &gitea_sdk.User{UserName: "alice"},
Labels: []*gitea_sdk.Label{{Name: "bug"}},
Repository: &gitea_sdk.RepositoryMeta{FullName: "org/repo"},
PullRequest: nil,
},
{
Index: 2,
Title: "Add feature",
State: gitea_sdk.StateOpen,
Poster: &gitea_sdk.User{UserName: "bob"},
Repository: &gitea_sdk.RepositoryMeta{FullName: "org/repo"},
PullRequest: &gitea_sdk.PullRequestMeta{},
},
}
result := slimIssues(issues)
if len(result) != 2 {
t.Fatalf("expected 2 issues, got %d", len(result))
}
if result[0]["repository"] != "org/repo" {
t.Errorf("expected repository org/repo, got %v", result[0]["repository"])
}
if result[0]["labels"].([]string)[0] != "bug" {
t.Errorf("expected label bug, got %v", result[0]["labels"])
}
if _, ok := result[0]["is_pull"]; ok {
t.Error("issue should not have is_pull")
}
if result[1]["is_pull"] != true {
t.Error("PR should have is_pull=true")
}
}
func TestSearchIssuesToolRequired(t *testing.T) {
if !slices.Contains(SearchIssuesTool.InputSchema.Required, "query") {
t.Error("search_issues should require query")
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ var (
mcp.WithString("repo", mcp.Description("repository name (required for 'list_issue_times', 'list_repo_times')")),
mcp.WithNumber("index", mcp.Description("issue index (required for 'list_issue_times')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
)
TimetrackingWriteTool = mcp.NewTool(
+1 -1
View File
@@ -44,7 +44,7 @@ var (
GetUserOrgsToolName,
mcp.WithDescription("Get organizations associated with the authenticated user"),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(defaultPage)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(defaultPageSize)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(defaultPageSize)),
)
)
+6 -5
View File
@@ -2,6 +2,7 @@ package wiki
import (
"context"
"encoding/base64"
"fmt"
"net/url"
@@ -40,7 +41,7 @@ var (
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Description("wiki page name (required for 'update', 'delete')")),
mcp.WithString("title", mcp.Description("wiki page title (required for 'create', optional for 'update')")),
mcp.WithString("content_base64", mcp.Description("page content, base64 encoded (required for 'create', 'update')")),
mcp.WithString("content", mcp.Description("page content (required for 'create', 'update')")),
mcp.WithString("message", mcp.Description("commit message")),
)
)
@@ -176,7 +177,7 @@ func createWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil {
return to.ErrorResult(err)
}
contentBase64, err := params.GetString(args, "content_base64")
content, err := params.GetString(args, "content")
if err != nil {
return to.ErrorResult(err)
}
@@ -188,7 +189,7 @@ func createWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
requestBody := map[string]string{
"title": title,
"content_base64": contentBase64,
"content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
"message": message,
}
@@ -216,13 +217,13 @@ func updateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil {
return to.ErrorResult(err)
}
contentBase64, err := params.GetString(args, "content_base64")
content, err := params.GetString(args, "content")
if err != nil {
return to.ErrorResult(err)
}
requestBody := map[string]string{
"content_base64": contentBase64,
"content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
}
// If title is given, use it. Otherwise, keep current page name
+75
View File
@@ -0,0 +1,75 @@
package wiki
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/mark3labs/mcp-go/mcp"
)
func TestWikiWriteBase64Encoding(t *testing.T) {
tests := []struct {
name string
method string
content string
}{
{"create ascii", "create", "Hello, World!"},
{"create unicode", "create", "日本語テスト 🎉"},
{"create multiline", "create", "line1\nline2\nline3"},
{"update ascii", "update", "Updated content"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotBody map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &gotBody)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"title":"test"}`))
}))
defer srv.Close()
origHost := flag.Host
flag.Host = srv.URL
defer func() { flag.Host = origHost }()
ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token")
args := map[string]any{
"method": tt.method,
"owner": "org",
"repo": "repo",
"content": tt.content,
"pageName": "TestPage",
"title": "TestPage",
}
req := mcp.CallToolRequest{}
req.Params.Arguments = args
result, err := wikiWriteFn(ctx, req)
if err != nil {
t.Fatalf("wikiWriteFn() error: %v", err)
}
if result.IsError {
t.Fatalf("wikiWriteFn() returned error result")
}
got := gotBody["content_base64"]
want := base64.StdEncoding.EncodeToString([]byte(tt.content))
if got != want {
t.Errorf("content_base64 = %q, want %q", got, want)
}
})
}
}
+2
View File
@@ -4,4 +4,6 @@ type contextKey string
const (
TokenContextKey = contextKey("token")
HostContextKey = contextKey("host")
InsecureContextKey = contextKey("insecure")
)
+11
View File
@@ -1,5 +1,12 @@
package flag
// ServerConfig holds connection details for a named Gitea server.
type ServerConfig struct {
Host string `json:"host"`
Token string `json:"token"`
Insecure bool `json:"insecure,omitempty"`
}
var (
Host string
Port int
@@ -10,4 +17,8 @@ var (
Insecure bool
ReadOnly bool
Debug bool
// Servers maps a friendly name to its connection config.
// Populated from the GITEA_SERVERS env var (JSON object).
Servers map[string]ServerConfig
)
+24 -9
View File
@@ -12,16 +12,16 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/flag"
)
func NewClient(token string) (*gitea.Client, error) {
func newClientForHost(host, token string, insecure bool) (*gitea.Client, error) {
httpClient := &http.Client{
Transport: http.DefaultTransport,
Transport: http.DefaultTransport.(*http.Transport).Clone(),
CheckRedirect: checkRedirect,
}
opts := []gitea.ClientOption{
gitea.SetToken(token),
}
if flag.Insecure {
if insecure {
httpClient.Transport.(*http.Transport).TLSClientConfig = &tls.Config{
InsecureSkipVerify: true,
}
@@ -30,16 +30,20 @@ func NewClient(token string) (*gitea.Client, error) {
if flag.Debug {
opts = append(opts, gitea.SetDebugMode())
}
client, err := gitea.NewClient(flag.Host, opts...)
client, err := gitea.NewClient(host, opts...)
if err != nil {
return nil, fmt.Errorf("create gitea client err: %w", err)
}
// Set user agent for the client
client.SetUserAgent("gitea-mcp-server/" + flag.Version)
return client, nil
}
// NewClient creates a Gitea client using the default host and the given token.
func NewClient(token string) (*gitea.Client, error) {
return newClientForHost(flag.Host, token, flag.Insecure)
}
// checkRedirect prevents Go from silently changing mutating requests (POST, PATCH, etc.)
// to GET when following 301/302/303 redirects, which would drop the request body and
// make writes appear to succeed when they didn't.
@@ -53,10 +57,21 @@ func checkRedirect(_ *http.Request, via []*http.Request) error {
return nil
}
// ClientFromContext creates a Gitea client using connection details from the
// context (set by the multi-server wrapper) or falling back to global defaults.
func ClientFromContext(ctx context.Context) (*gitea.Client, error) {
token, ok := ctx.Value(mcpContext.TokenContextKey).(string)
if !ok {
token = flag.Token
host := flag.Host
token := flag.Token
insecure := flag.Insecure
if h, ok := ctx.Value(mcpContext.HostContextKey).(string); ok && h != "" {
host = h
}
return NewClient(token)
if t, ok := ctx.Value(mcpContext.TokenContextKey).(string); ok && t != "" {
token = t
}
if i, ok := ctx.Value(mcpContext.InsecureContextKey).(bool); ok {
insecure = i
}
return newClientForHost(host, token, insecure)
}
+13
View File
@@ -3,6 +3,7 @@ package params
import (
"fmt"
"strconv"
"time"
)
// GetString extracts a required string parameter from MCP tool arguments.
@@ -101,6 +102,18 @@ func GetInt64Slice(args map[string]any, key string) ([]int64, error) {
return out, nil
}
// GetOptionalTime extracts an optional RFC3339 timestamp parameter, returning nil if missing or unparseable.
func GetOptionalTime(args map[string]any, key string) *time.Time {
val, ok := args[key].(string)
if !ok {
return nil
}
if t, err := time.Parse(time.RFC3339, val); err == nil {
return &t
}
return nil
}
// GetOptionalInt extracts an optional integer parameter from MCP tool arguments.
// Returns defaultVal if the key is missing or the value cannot be parsed.
// Accepts both float64 (JSON number) and string representations.