first commit
Some checks failed
release-nightly / release-image (push) Has been cancelled

This commit is contained in:
2026-06-05 14:58:55 +03:00
commit 440547e9c1
87 changed files with 12840 additions and 0 deletions

52
.air.toml Normal file
View File

@@ -0,0 +1,52 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = ["-t", "http"]
bin = "./gitea-mcp"
cmd = "make build"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = false
[misc]
clean_on_exit = false
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = false
keep_scroll = true

View File

@@ -0,0 +1,19 @@
{
"name": "Gitea MCP DevContainer",
"image": "mcr.microsoft.com/devcontainers/go:1.26-bookworm",
"features": {},
"customizations": {
"vscode": {
"settings": {},
"extensions": [
"editorconfig.editorconfig",
"dbaeumer.vscode-eslint",
"golang.go",
"stylelint.vscode-stylelint",
"DavidAnson.vscode-markdownlint",
"github.copilot",
"eamodio.gitlens"
]
}
}
}

61
.dockerignore Normal file
View File

@@ -0,0 +1,61 @@
# Git
.git
.gitignore
.github/
.gitea/
# Docker
Dockerfile
.dockerignore
# Build artifacts
bin/
dist/
build/
*.exe
*.exe~
*.dll
*.so
*.dylib
# Go specific
vendor/
go.work
# Testing
*_test.go
**/test/
**/tests/
coverage.out
coverage.html
# IDE and editor files
.idea/
.vscode/
*.swp
*.swo
*~
# OS specific
.DS_Store
Thumbs.db
# Temporary files
tmp/
temp/
*.tmp
*.log
# Documentation
docs/
*.md
LICENSE
# Development tools
.air.toml
.golangci.yml
.goreleaser.yml
# Debug files
debug
__debug_bin

View File

@@ -0,0 +1,51 @@
name: release-nightly
on:
push:
branches: [main]
tags:
- "*"
jobs:
release-image:
runs-on: ubuntu-latest
env:
DOCKER_ORG: gitea
DOCKER_LATEST: nightly
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: |
linux/amd64
linux/arm64
push: true
tags: |
${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-server:${{ env.DOCKER_LATEST }}
build-args: |
VERSION=${{ steps.meta.outputs.REPO_VERSION }}

View File

@@ -0,0 +1,70 @@
name: release
on:
push:
tags:
- "*"
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: stable
- name: Install GoReleaser
run: go install github.com/goreleaser/goreleaser/v2@latest
- name: Run GoReleaser
run: goreleaser release --clean
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_FORCE_TOKEN: "gitea"
release-image:
runs-on: ubuntu-latest
env:
DOCKER_ORG: gitea
DOCKER_LATEST: latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=${GITHUB_REF_NAME#v} >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: |
linux/amd64
linux/arm64
push: true
build-args: |
VERSION=${{ steps.meta.outputs.REPO_VERSION }}
tags: |
${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-server:${{ steps.meta.outputs.REPO_VERSION }}
${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-server:${{ env.DOCKER_LATEST }}

View File

@@ -0,0 +1,19 @@
name: check-and-test
on:
- pull_request
jobs:
check-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: lint
run: make lint
- name: build
run: make build
- name: security-check
run: make security-check

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.idea
gitea-mcp
gitea-mcp.exe
*.log
tmp

117
.golangci.yml Normal file
View File

@@ -0,0 +1,117 @@
version: "2"
output:
sort-order:
- file
linters:
default: none
enable:
- bidichk
- bodyclose
- depguard
- errcheck
- forbidigo
- gocheckcompilerdirectives
- gocritic
- govet
- ineffassign
- mirror
- modernize
- nakedret
- nilnil
- nolintlint
- perfsprint
- revive
- staticcheck
- testifylint
- unconvert
- unparam
- unused
- usestdlibvars
- usetesting
- wastedassign
settings:
depguard:
rules:
main:
deny:
- pkg: io/ioutil
desc: use os or io instead
- pkg: golang.org/x/exp
desc: it's experimental and unreliable
- pkg: github.com/pkg/errors
desc: use builtin errors package instead
nolintlint:
allow-unused: false
require-explanation: true
require-specific: true
gocritic:
enabled-checks:
- equalFold
disabled-checks: []
revive:
severity: error
rules:
- name: blank-imports
- name: constant-logical-expr
- name: context-as-argument
- name: context-keys-type
- name: dot-imports
- name: empty-lines
- name: error-return
- name: error-strings
- name: exported
- name: identical-branches
- name: if-return
- name: increment-decrement
- name: modifies-value-receiver
- name: package-comments
- name: redefines-builtin-id
- name: superfluous-else
- name: time-naming
- name: unexported-return
- name: var-declaration
- name: var-naming
arguments:
- [] # AllowList - do not remove as args for the rule are positional and won't work without lists first
- [] # DenyList
- - skip-package-name-checks: true
staticcheck:
checks:
- all
testifylint: {}
usetesting:
os-temp-dir: true
perfsprint:
concat-loop: false
govet:
enable:
- nilness
- unusedwrite
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
issues:
max-issues-per-linter: 0
max-same-issues: 0
formatters:
enable:
- gci
- gofumpt
settings:
gci:
custom-order: true
sections:
- standard
- prefix(gitea.com/gitea/gitea-mcp)
- blank
- default
gofumpt:
extra-rules: true
exclusions:
generated: lax
run:
timeout: 10m

76
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,76 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
version: 2
before:
hooks:
- go mod tidy
builds:
- env:
- CGO_ENABLED=0
main: .
goos:
- linux
- windows
- darwin
flags:
- -trimpath
ldflags:
- -s -w
- -X main.Version={{.Version}}
archives:
- formats: tar.gz
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives
format_overrides:
- goos: windows
formats: zip
changelog:
sort: asc
groups:
- title: Features
regexp: "^.*feat[(\\w)]*:+.*$"
order: 0
- title: "Bug fixes"
regexp: "^.*fix[(\\w)]*:+.*$"
order: 1
- title: "Enhancements"
regexp: "^.*chore[(\\w)]*:+.*$"
order: 2
- title: "Refactor"
regexp: "^.*refactor[(\\w)]*:+.*$"
order: 3
- title: "Build process updates"
regexp: ^.*?(build|ci)(\(.+\))??!?:.+$
order: 4
- title: "Documentation updates"
regexp: ^.*?docs?(\(.+\))??!?:.+$
order: 4
- title: Others
order: 999
filters:
exclude:
- "^docs:"
- "^test:"
release:
footer: >-
---
Released by [GoReleaser](https://github.com/goreleaser/goreleaser).
gitea_urls:
api: https://gitea.com/api/v1
download: https://gitea.com
force_token: gitea

39
.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,39 @@
{
// 💡 Inputs are prompted on first server start, then stored securely by VS Code.
"inputs": [
{
"type": "promptString",
"id": "gitea-host",
"description": "Gitea Host",
"password": false
},
{
"type": "promptString",
"id": "gitea-token",
"description": "Gitea Access Token",
"password": true
},
{
"type": "promptString",
"id": "gitea-insecure",
"description": "Allow insecure connections (e.g., self-signed certificates)",
"default": "false"
}
],
"servers": {
"gitea-mcp-stdio": {
"type": "stdio",
"command": "gitea-mcp",
"args": ["-t", "stdio"],
"env": {
"GITEA_HOST": "${input:gitea-host}",
"GITEA_ACCESS_TOKEN": "${input:gitea-token}",
"GITEA_INSECURE": "${input:gitea-insecure}"
}
},
"gitea-mcp-http": {
"type": "http",
"url": "http://localhost:8080/mcp",
}
}
}

8
AGENTS.md Normal file
View File

@@ -0,0 +1,8 @@
- Use `make help` to find available development targets
- Run `make fmt` to format `.go` files, and run `make lint-go` to lint them
- Run `make tidy` after any `go.mod` changes
- Ensure no trailing whitespace in edited files
- Use Conventional Commits format for commit messages and PR titles (e.g. `type(scope): subject`)
- Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates
- Include authorship attribution in issue and pull request comments
- Add `Co-Authored-By` lines to all commits, indicating name and model used

63
BUILDING.md Normal file
View File

@@ -0,0 +1,63 @@
# Building gitea-mcp on Windows
This project includes PowerShell and batch scripts to build the gitea-mcp application on Windows systems.
## Prerequisites
- Go 1.24 or later
- Git (for version information)
- PowerShell 5.1 or later (included with Windows 10/11)
## Build Scripts
### PowerShell Script (`build.ps1`)
The main build script that replicates all Makefile functionality:
```powershell
# Show help
.\build.ps1 help
# Build the application
.\build.ps1 build
# Install the application
.\build.ps1 install
# Clean build artifacts
.\build.ps1 clean
# Run in development mode (hot reload)
.\build.ps1 dev
# Update vendor dependencies
.\build.ps1 vendor
```
### Batch File Wrapper (`build.bat`)
A simple wrapper to run the PowerShell script:
```cmd
# Run with default help target
build.bat
# Run specific target
build.bat build
build.bat install
```
## Available Targets
- **help** - Print help message
- **build** - Build the application executable
- **install** - Build and install to GOPATH/bin
- **uninstall** - Remove executable from GOPATH/bin
- **clean** - Remove build artifacts
- **air** - Install air for hot reload development
- **dev** - Run with hot reload development
- **vendor** - Tidy and verify Go module dependencies
## Output
The build process creates `gitea-mcp.exe` in the project directory.

78
CLAUDE.md Normal file
View File

@@ -0,0 +1,78 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
**Build**: `make build` - Build the gitea-mcp binary
**Install**: `make install` - Build and install to GOPATH/bin
**Clean**: `make clean` - Remove build artifacts
**Test**: `go test ./...` - Run all tests
**Hot reload**: `make dev` - Start development server with hot reload (requires air)
**Dependencies**: `make vendor` - Tidy and verify module dependencies
## Architecture Overview
This is a **Gitea MCP (Model Context Protocol) Server** written in Go that provides MCP tools for interacting with Gitea repositories, issues, pull requests, users, and more.
**Core Components**:
- `main.go` + `cmd/cmd.go`: CLI entry point and flag parsing
- `operation/operation.go`: Main server setup and tool registration
- `pkg/tool/tool.go`: Tool registry with read/write categorization
- `operation/*/`: Individual tool modules (user, repo, issue, pull, search, wiki, etc.)
**Transport Modes**:
- **stdio** (default): Standard input/output for MCP clients
- **http**: HTTP server mode on configurable port (default 8080)
**Authentication**:
- Global token via `--token` flag or `GITEA_ACCESS_TOKEN` env var
- HTTP mode supports per-request Bearer token override in Authorization header
- Token precedence: HTTP Authorization header > CLI flag > environment variable
**Tool Organization**:
- Tools are categorized as read-only or write operations
- `--read-only` flag exposes only read tools
- Tool modules register via `Tool.RegisterRead()` and `Tool.RegisterWrite()`
**Key Configuration**:
- Default Gitea host: `https://gitea.com` (override with `--host` or `GITEA_HOST`)
- Environment variables can override CLI flags: `MCP_MODE`, `GITEA_READONLY`, `GITEA_DEBUG`, `GITEA_INSECURE`
- Logs are written to `~/.gitea-mcp/gitea-mcp.log` with rotation
## Available Tools
The server provides 45 MCP tools covering:
- **User**: get_me, get_user_orgs
- **Search**: search_users, search_repos, search_org_teams
- **Repository**: create_repo, fork_repo, list_my_repos
- **Branches**: list_branches, create_branch, delete_branch
- **Tags**: list_tags, get_tag, create_tag, delete_tag
- **Files**: get_file_contents, get_dir_contents, create_or_update_file, delete_file
- **Commits**: list_commits
- **Issues**: list_issues, issue_read, issue_write
- **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
- **Time Tracking**: timetracking_read, timetracking_write
- **Actions Runs**: actions_run_read, actions_run_write
- **Actions Config**: actions_config_read, actions_config_write
- **Version**: get_gitea_mcp_server_version
## Common Development Patterns
**Testing**: Use `go test ./operation -run TestFunctionName` for specific tests
**Token Context**: HTTP requests use `pkg/context.TokenContextKey` for request-scoped token access
**Flag Access**: All packages access configuration via global variables in `pkg/flag/flag.go`
**Graceful Shutdown**: HTTP mode implements graceful shutdown with 10-second timeout on SIGTERM/SIGINT

35
Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1.4
# Build stage
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
ARG VERSION=dev
ARG TARGETOS
ARG TARGETARCH
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \
go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o gitea-mcp
# Final stage
FROM gcr.io/distroless/static-debian12:nonroot
ARG VERSION=dev
WORKDIR /app
COPY --from=builder --chown=nonroot:nonroot /app/gitea-mcp .
USER nonroot:nonroot
LABEL org.opencontainers.image.version="${VERSION}"
LABEL org.opencontainers.image.source="https://gitea.com/gitea/gitea-mcp"
CMD ["/app/gitea-mcp"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 The Gitea Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

89
Makefile Normal file
View File

@@ -0,0 +1,89 @@
GO ?= go
EXECUTABLE := gitea-mcp
VERSION ?= $(shell git describe --tags --always | sed 's/-/+/' | sed 's/^v//')
LDFLAGS := -X "main.Version=$(VERSION)"
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0
.PHONY: help
help: ## print this help message
@echo "Usage: make [target]"
@echo ""
@echo "Targets:"
@echo ""
@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
@echo "Installing $(EXECUTABLE)..."
@mkdir -p $(GOPATH)/bin
@cp $(EXECUTABLE) $(GOPATH)/bin/$(EXECUTABLE)
@echo "Installed $(EXECUTABLE) to $(GOPATH)/bin/$(EXECUTABLE)"
@echo "Please add $(GOPATH)/bin to your PATH if it is not already there."
.PHONY: uninstall
uninstall: ## uninstall the application
@echo "Uninstalling $(EXECUTABLE)..."
@rm -f $(GOPATH)/bin/$(EXECUTABLE)
@echo "Uninstalled $(EXECUTABLE) from $(GOPATH)/bin/$(EXECUTABLE)"
.PHONY: clean
clean: ## delete build artifacts
@echo "Cleaning up build artifacts..."
@rm -f $(EXECUTABLE)
@echo "Cleaned up $(EXECUTABLE)"
.PHONY: build
build: ## build the application
$(GO) build -v -ldflags '-s -w $(LDFLAGS)' -o $(EXECUTABLE)
.PHONY: air
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
.PHONY: dev
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 $(GOLANGCI_LINT_PACKAGE) fmt
.PHONY: fmt-check
fmt-check: fmt ## check that Go code is formatted
@diff=$$(git diff --color=always); \
if [ -n "$$diff" ]; then \
echo "Please run 'make fmt' and commit the result:"; \
printf "%s" "$${diff}"; \
exit 1; \
fi
.PHONY: lint
lint: lint-go ## lint everything
.PHONY: lint-fix
lint-fix: lint-go-fix ## lint everything and fix issues
.PHONY: lint-go
lint-go: ## lint go files
$(GO) run $(GOLANGCI_LINT_PACKAGE) run
.PHONY: lint-go-fix
lint-go-fix: ## lint go files and fix issues
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix
.PHONY: security-check
security-check: ## run security check
$(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
.PHONY: tidy
tidy: ## run go mod tidy
$(eval MIN_GO_VERSION := $(shell grep -Eo '^go\s+[0-9]+\.[0-9.]+' go.mod | cut -d' ' -f2))
$(GO) mod tidy -compat=$(MIN_GO_VERSION)
.PHONY: vendor
vendor: tidy ## tidy and verify module dependencies
$(GO) mod verify

330
README.md Normal file
View File

@@ -0,0 +1,330 @@
# Gitea MCP Server
[繁體中文](README.zh-tw.md) | [简体中文](README.zh-cn.md)
**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.
[![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)
- [Usage with Mistral Vibe](#usage-with-mistral-vibe)
- [📥 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 OpenCode (opencode.ai)
Add a snippet like the following in the "mcp" top-level object (add one if you don't have any):
```json
"gitea-mcp": {
"enabled": true,
"type": "local",
"command": [
"gitea-mcp",
"-t", "stdio",
"-H", "https://git.your-domain.org",
"-T", "<your personal access token>"
]
}
```
### 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
```
### Usage with VS Code
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)`.
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}"
}
}
}
}
}
```
### Usage with Mistral Vibe
Add the following configuration to your Mistral Vibe MCP configuration file (`~/.vibe/config.toml`):
```toml
[[mcp_servers]]
name = "gitea"
transport = "stdio"
command = "docker"
args = [
"run",
"--rm",
"-i",
"-e",
"GITEA_ACCESS_TOKEN",
"-e",
"GITEA_HOST",
"docker.gitea.com/gitea-mcp-server",
]
[mcp_servers.env]
GITEA_ACCESS_TOKEN = "TOKEN"
GITEA_HOST = "https://gitea.com"
```
### 📥 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
```
Before building, make sure you have the following installed:
- make
- Golang (Go 1.24 or later recommended)
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": {
"command": "gitea-mcp",
"args": [
"-t",
"stdio",
"--host",
"https://gitea.com"
// "--token", "<your personal access token>"
],
"env": {
// "GITEA_HOST": "https://gitea.com",
// "GITEA_INSECURE": "true",
"GITEA_ACCESS_TOKEN": "<your personal access token>"
}
}
}
}
```
- **http mode**
```json
{
"mcpServers": {
"gitea": {
"url": "http://localhost:8080/mcp",
"headers": {
"Authorization": "Bearer <your personal access token>"
}
}
}
}
```
**Default log path**: `$HOME/.gitea-mcp/gitea-mcp.log`
> [!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
> [!NOTE]
> Many tools support `page` and `perPage` parameters for pagination. The maximum effective page size is determined by the Gitea server's `[api].MAX_RESPONSE_ITEMS` setting (default: **50**). Requesting a `perPage` value higher than this limit will be silently capped by the server.
Once everything is set up, try typing the following in your MCP-compatible chatbox:
```text
list all my repositories
```
## ✅ Available Tools
The Gitea MCP Server supports the following tools:
| 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
To enable debug mode, add the `-d` flag when running the Gitea MCP Server with http mode:
```sh
./gitea-mcp -t http [--port 8080] --token <your personal access token> -d
```
## 🛠 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!

259
README.zh-cn.md Normal file
View File

@@ -0,0 +1,259 @@
# Gitea MCP 服务器
[English](README.md) | [繁體中文](README.zh-tw.md)
**Gitea MCP 服务器** 是一个集成插件,旨在将 Gitea 与 Model Context Protocol (MCP) 系统连接起来。这允许通过 MCP 兼容的聊天界面无缝执行命令和管理仓库。
[![在 VS Code 中使用 Docker 安装](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}}) [![在 VS Code Insiders 中使用 Docker 安装](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)
## 目录
- [Gitea MCP 服务器](#gitea-mcp-服务器)
- [目录](#目录)
- [什么是 Gitea](#什么是-gitea)
- [什么是 MCP](#什么是-mcp)
- [🚧 安装](#-安装)
- [在 Claude Code 中使用](#在-claude-code-中使用)
- [在 VS Code 中使用](#在-vs-code-中使用)
- [📥 下载官方二进制版本](#-下载官方二进制版本)
- [🔧 从源码构建](#-从源码构建)
- [📁 加入 PATH](#-加入-path)
- [🚀 使用](#-使用)
- [✅ 可用工具](#-可用工具)
- [🐛 调试](#-调试)
- [🛠 疑难排解](#-疑难排解)
## 什么是 Gitea
Gitea 是一个由社区管理的轻量级代码托管解决方案,使用 Go 语言编写,采用 MIT 许可证。Gitea 提供 Git 托管,包括仓库浏览、问题追踪、拉取请求等功能。
## 什么是 MCP
Model Context Protocol (MCP) 是一种协议,允许通过聊天界面整合各种工具和系统。它能够无缝执行命令并管理仓库、用户及其他资源。
## 🚧 安装
### 在 Claude Code 中使用
此方式使用 `go run`,需要安装 [Go](https://go.dev)。
```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
```
### 在 VS Code 中使用
要快速安装,请使用本 README 顶部的安装按钮。
如需手动安装,请将以下 JSON 块添加到 VS Code 的用户设置 (JSON) 文件。可通过按 `Ctrl + Shift + P` 并输入 `Preferences: Open User Settings (JSON)`
也可添加到工作区的 `.vscode/mcp.json` 文件,方便与他人共享配置。
> `.vscode/mcp.json` 文件不需要 `mcp` 键。
```json
{
"mcp": {
"inputs": [
{
"type": "promptString",
"id": "gitea_token",
"description": "Gitea 个人访问令牌",
"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}"
}
}
}
}
}
```
### 📥 下载官方二进制版本
可在 [官方 Gitea MCP 二进制版本](https://gitea.com/gitea/gitea-mcp/releases) 下载。
### 🔧 从源码构建
可用 Git 下载源码:
```bash
git clone https://gitea.com/gitea/gitea-mcp.git
```
构建前请先安装:
- make
- Golang建议 Go 1.24 及以上)
然后运行:
```bash
make install
```
### 📁 加入 PATH
安装后,将 gitea-mcp 可执行文件复制到系统 PATH 目录,例如:
```bash
cp gitea-mcp /usr/local/bin/
```
## 🚀 使用
此示例适用于 Cursor也可在 VSCode 使用插件。
要配置 Gitea MCP 服务器,请将以下内容添加到 MCP 配置文件:
- **stdio 模式**
```json
{
"mcpServers": {
"gitea": {
"command": "gitea-mcp",
"args": [
"-t",
"stdio",
"--host",
"https://gitea.com"
// "--token", "<your personal access token>"
],
"env": {
// "GITEA_HOST": "https://gitea.com",
// "GITEA_INSECURE": "true",
"GITEA_ACCESS_TOKEN": "<your personal access token>"
}
}
}
}
```
- **http 模式**
```json
{
"mcpServers": {
"gitea": {
"url": "http://localhost:8080/mcp",
"headers": {
"Authorization": "Bearer <your personal access token>"
}
}
}
}
```
**默认日志路径**: `$HOME/.gitea-mcp/gitea-mcp.log`
> [!注意]
> 可通过命令行参数或环境变量提供 Gitea 主机和访问令牌。
> 命令行参数优先。
> [!注意]
> 许多工具支持 `page` 和 `perPage` 分页参数。最大有效页面大小由 Gitea 服务器的 `[api].MAX_RESPONSE_ITEMS` 设置决定(默认值:**50**)。请求超过此限制的 `perPage` 值将被服务器静默截断。
一切设置完成后,可在 MCP 聊天框输入:
```text
列出我所有的仓库
```
## ✅ 可用工具
Gitea MCP 服务器支持以下工具:
| 工具 | 范围 | 描述 |
| :-------------------------------: | :------: | :------------------------: |
| get_my_user_info | 用户 | 获取已认证用户信息 |
| get_user_orgs | 用户 | 获取已认证用户关联组织 |
| create_repo | 仓库 | 创建新仓库 |
| fork_repo | 仓库 | 复刻仓库 |
| list_my_repos | 仓库 | 列出用户所有仓库 |
| create_branch | 分支 | 创建新分支 |
| delete_branch | 分支 | 删除分支 |
| list_branches | 分支 | 列出所有分支 |
| create_release | 版本发布 | 创建新版本发布 |
| delete_release | 版本发布 | 删除版本发布 |
| get_release | 版本发布 | 获取版本发布 |
| get_latest_release | 版本发布 | 获取最新版本发布 |
| list_releases | 版本发布 | 列出所有版本发布 |
| create_tag | 标签 | 创建新标签 |
| delete_tag | 标签 | 删除标签 |
| get_tag | 标签 | 获取标签 |
| list_tags | 标签 | 列出所有标签 |
| list_repo_commits | 提交 | 列出所有提交 |
| get_file_content | 文件 | 获取文件内容和元数据 |
| get_dir_content | 文件 | 获取目录内容列表 |
| create_file | 文件 | 创建新文件 |
| update_file | 文件 | 更新现有文件 |
| delete_file | 文件 | 删除文件 |
| get_issue_by_index | 问题 | 按索引获取问题 |
| list_repo_issues | 问题 | 列出所有问题 |
| create_issue | 问题 | 创建新问题 |
| create_issue_comment | 问题 | 在问题上创建评论 |
| edit_issue | 问题 | 编辑问题 |
| edit_issue_comment | 问题 | 编辑问题评论 |
| get_issue_comments_by_index | 问题 | 按索引获取问题评论 |
| get_pull_request_by_index | 拉取请求 | 按索引获取拉取请求 |
| list_repo_pull_requests | 拉取请求 | 列出所有拉取请求 |
| create_pull_request | 拉取请求 | 创建新拉取请求 |
| create_pull_request_reviewer | 拉取请求 | 为拉取请求添加审查者 |
| delete_pull_request_reviewer | 拉取请求 | 移除拉取请求的审查者 |
| list_pull_request_reviews | 拉取请求 | 列出拉取请求的所有审查 |
| get_pull_request_review | 拉取请求 | 按 ID 获取特定审查 |
| list_pull_request_review_comments | 拉取请求 | 列出审查的行内评论 |
| create_pull_request_review | 拉取请求 | 创建审查(可含行内评论) |
| submit_pull_request_review | 拉取请求 | 提交待处理的审查 |
| delete_pull_request_review | 拉取请求 | 删除审查 |
| dismiss_pull_request_review | 拉取请求 | 驳回审查(可附消息) |
| merge_pull_request | 拉取请求 | 合并拉取请求 |
| search_users | 用户 | 搜索用户 |
| search_org_teams | 组织 | 搜索组织团队 |
| list_org_labels | 组织 | 列出组织标签 |
| create_org_label | 组织 | 创建组织标签 |
| edit_org_label | 组织 | 编辑组织标签 |
| delete_org_label | 组织 | 删除组织标签 |
| search_repos | 仓库 | 搜索仓库 |
| get_gitea_mcp_server_version | 服务器 | 获取 Gitea MCP 服务器版本 |
| list_wiki_pages | Wiki | 列出所有 Wiki 页面 |
| get_wiki_page | Wiki | 获取 Wiki 页面内容和元数据 |
| get_wiki_revisions | Wiki | 获取 Wiki 修订历史 |
| create_wiki_page | Wiki | 创建新 Wiki 页面 |
| update_wiki_page | Wiki | 更新现有 Wiki 页面 |
| delete_wiki_page | Wiki | 删除 Wiki 页面 |
## 🐛 调试
启用调试模式时,请在 http 模式运行 Gitea MCP 服务器时加上 `-d` 标志:
```sh
./gitea-mcp -t http [--port 8080] --token <your personal access token> -d
```
## 🛠 疑难排解
如遇问题,可参考以下步骤:
1. **检查 PATH**:确保 `gitea-mcp` 可执行文件已在系统 PATH 目录中。
2. **验证依赖**:确认已安装 `make``Golang` 等必要依赖。
3. **检查配置**:仔细检查 MCP 配置文件是否有错误或遗漏。
4. **查看日志**:检查日志消息或警告以获取更多信息。
享受通过聊天探索和管理您的 Gitea 仓库!

259
README.zh-tw.md Normal file
View File

@@ -0,0 +1,259 @@
# Gitea MCP 伺服器
[English](README.md) | [简体中文](README.zh-cn.md)
**Gitea MCP 伺服器** 是一個整合插件,旨在將 Gitea 與 Model Context Protocol (MCP) 系統連接起來。這允許通過 MCP 兼容的聊天界面無縫執行命令和管理倉庫。
[![在 VS Code 中使用 Docker 安裝](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}}) [![在 VS Code Insiders 中使用 Docker 安裝](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)
## 目錄
- [Gitea MCP 伺服器](#gitea-mcp-伺服器)
- [目錄](#目錄)
- [什麼是 Gitea](#什麼是-gitea)
- [什麼是 MCP](#什麼是-mcp)
- [🚧 安裝](#-安裝)
- [在 Claude Code 中使用](#在-claude-code-中使用)
- [在 VS Code 中使用](#在-vs-code-中使用)
- [📥 下載官方二進位版本](#-下載官方二進位版本)
- [🔧 從原始碼建置](#-從原始碼建置)
- [📁 加入 PATH](#-加入-path)
- [🚀 使用](#-使用)
- [✅ 可用工具](#-可用工具)
- [🐛 調試](#-調試)
- [🛠 疑難排解](#-疑難排解)
## 什麼是 Gitea
Gitea 是一個由社群管理的輕量級程式碼託管解決方案,使用 Go 語言編寫,採用 MIT 授權。Gitea 提供 Git 託管,包括倉庫瀏覽、議題追蹤、拉取請求等功能。
## 什麼是 MCP
Model Context Protocol (MCP) 是一種協議,允許透過聊天介面整合各種工具與系統。它能夠無縫執行命令並管理倉庫、使用者及其他資源。
## 🚧 安裝
### 在 Claude Code 中使用
此方式使用 `go run`,需要安裝 [Go](https://go.dev)。
```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
```
### 在 VS Code 中使用
欲快速安裝,請使用本 README 頂部的安裝按鈕。
如需手動安裝,請將下列 JSON 區塊加入 VS Code 的使用者設定 (JSON) 檔案。可按 `Ctrl + Shift + P` 並輸入 `Preferences: Open User Settings (JSON)`
也可加入至工作區的 `.vscode/mcp.json` 檔案,方便與他人共享設定。
> `.vscode/mcp.json` 檔案不需 `mcp` 鍵。
```json
{
"mcp": {
"inputs": [
{
"type": "promptString",
"id": "gitea_token",
"description": "Gitea 個人存取令牌",
"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}"
}
}
}
}
}
```
### 📥 下載官方二進位版本
可至 [官方 Gitea MCP 二進位版本](https://gitea.com/gitea/gitea-mcp/releases) 下載。
### 🔧 從原始碼建置
可用 Git 下載原始碼:
```bash
git clone https://gitea.com/gitea/gitea-mcp.git
```
建置前請先安裝:
- make
- Golang建議 Go 1.24 以上)
然後執行:
```bash
make install
```
### 📁 加入 PATH
安裝後,將 gitea-mcp 執行檔複製到系統 PATH 目錄,例如:
```bash
cp gitea-mcp /usr/local/bin/
```
## 🚀 使用
此範例適用於 Cursor也可在 VSCode 使用插件。
欲設定 Gitea MCP 伺服器,請將下列內容加入 MCP 設定檔:
- **stdio 模式**
```json
{
"mcpServers": {
"gitea": {
"command": "gitea-mcp",
"args": [
"-t",
"stdio",
"--host",
"https://gitea.com"
// "--token", "<your personal access token>"
],
"env": {
// "GITEA_HOST": "https://gitea.com",
// "GITEA_INSECURE": "true",
"GITEA_ACCESS_TOKEN": "<your personal access token>"
}
}
}
}
```
- **http 模式**
```json
{
"mcpServers": {
"gitea": {
"url": "http://localhost:8080/mcp",
"headers": {
"Authorization": "Bearer <your personal access token>"
}
}
}
}
```
**預設日誌路徑**: `$HOME/.gitea-mcp/gitea-mcp.log`
> [!注意]
> 可用命令列參數或環境變數提供 Gitea 主機與存取令牌。
> 命令列參數優先。
> [!注意]
> 許多工具支援 `page` 和 `perPage` 分頁參數。最大有效頁面大小由 Gitea 伺服器的 `[api].MAX_RESPONSE_ITEMS` 設定決定(預設值:**50**)。請求超過此限制的 `perPage` 值將被伺服器靜默截斷。
一切設定完成後,可在 MCP 聊天框輸入:
```text
列出我所有的倉庫
```
## ✅ 可用工具
Gitea MCP 伺服器支援以下工具:
| 工具 | 範圍 | 描述 |
| :-------------------------------: | :------: | :--------------------------: |
| get_my_user_info | 用戶 | 取得已認證用戶資訊 |
| get_user_orgs | 用戶 | 取得已認證用戶所屬組織 |
| create_repo | 倉庫 | 創建新倉庫 |
| fork_repo | 倉庫 | 復刻倉庫 |
| list_my_repos | 倉庫 | 列出用戶所有倉庫 |
| create_branch | 分支 | 創建新分支 |
| delete_branch | 分支 | 刪除分支 |
| list_branches | 分支 | 列出所有分支 |
| create_release | 版本發布 | 創建新版本發布 |
| delete_release | 版本發布 | 刪除版本發布 |
| get_release | 版本發布 | 取得版本發布 |
| get_latest_release | 版本發布 | 取得最新版本發布 |
| list_releases | 版本發布 | 列出所有版本發布 |
| create_tag | 標籤 | 創建新標籤 |
| delete_tag | 標籤 | 刪除標籤 |
| get_tag | 標籤 | 取得標籤 |
| list_tags | 標籤 | 列出所有標籤 |
| list_repo_commits | 提交 | 列出所有提交 |
| get_file_content | 文件 | 取得文件內容與中繼資料 |
| get_dir_content | 文件 | 取得目錄內容列表 |
| create_file | 文件 | 創建新文件 |
| update_file | 文件 | 更新現有文件 |
| delete_file | 文件 | 刪除文件 |
| get_issue_by_index | 問題 | 依索引取得問題 |
| list_repo_issues | 問題 | 列出所有問題 |
| create_issue | 問題 | 創建新問題 |
| create_issue_comment | 問題 | 在問題上創建評論 |
| edit_issue | 問題 | 編輯問題 |
| edit_issue_comment | 問題 | 編輯問題評論 |
| get_issue_comments_by_index | 問題 | 依索引取得問題評論 |
| get_pull_request_by_index | 拉取請求 | 依索引取得拉取請求 |
| list_repo_pull_requests | 拉取請求 | 列出所有拉取請求 |
| create_pull_request | 拉取請求 | 創建新拉取請求 |
| create_pull_request_reviewer | 拉取請求 | 為拉取請求添加審查者 |
| delete_pull_request_reviewer | 拉取請求 | 移除拉取請求的審查者 |
| list_pull_request_reviews | 拉取請求 | 列出拉取請求的所有審查 |
| get_pull_request_review | 拉取請求 | 依 ID 取得特定審查 |
| list_pull_request_review_comments | 拉取請求 | 列出審查的行內評論 |
| create_pull_request_review | 拉取請求 | 創建審查(可含行內評論) |
| submit_pull_request_review | 拉取請求 | 提交待處理的審查 |
| delete_pull_request_review | 拉取請求 | 刪除審查 |
| dismiss_pull_request_review | 拉取請求 | 駁回審查(可附訊息) |
| merge_pull_request | 拉取請求 | 合併拉取請求 |
| search_users | 用戶 | 搜尋用戶 |
| search_org_teams | 組織 | 搜尋組織團隊 |
| list_org_labels | 組織 | 列出組織標籤 |
| create_org_label | 組織 | 創建組織標籤 |
| edit_org_label | 組織 | 編輯組織標籤 |
| delete_org_label | 組織 | 刪除組織標籤 |
| search_repos | 倉庫 | 搜尋倉庫 |
| get_gitea_mcp_server_version | 伺服器 | 取得 Gitea MCP 伺服器版本 |
| list_wiki_pages | Wiki | 列出所有 Wiki 頁面 |
| get_wiki_page | Wiki | 取得 Wiki 頁面內容與中繼資料 |
| get_wiki_revisions | Wiki | 取得 Wiki 修訂歷史 |
| create_wiki_page | Wiki | 創建新 Wiki 頁面 |
| update_wiki_page | Wiki | 更新現有 Wiki 頁面 |
| delete_wiki_page | Wiki | 刪除 Wiki 頁面 |
## 🐛 調試
啟用調試模式時,請在 http 模式執行 Gitea MCP 伺服器時加上 `-d` 旗標:
```sh
./gitea-mcp -t http [--port 8080] --token <your personal access token> -d
```
## 🛠 疑難排解
如遇問題,可參考以下步驟:
1. **檢查 PATH**:確保 `gitea-mcp` 執行檔已在系統 PATH 目錄中。
2. **驗證依賴**:確認已安裝 `make``Golang` 等必要依賴。
3. **檢查設定**:仔細檢查 MCP 設定檔是否有錯誤或遺漏。
4. **查看日誌**:檢查日誌訊息或警告以獲取更多資訊。
享受透過聊天探索與管理您的 Gitea 倉庫!

2
build.bat Normal file
View File

@@ -0,0 +1,2 @@
@echo off
powershell -ExecutionPolicy Bypass -File "%~dp0build.ps1" %*

220
build.ps1 Normal file
View File

@@ -0,0 +1,220 @@
#!/usr/bin/env pwsh
# PowerShell build script for gitea-mcp
# Replicates the functionality of the Makefile
param(
[string]$Target = "help"
)
# Configuration
$EXECUTABLE = "gitea-mcp.exe"
$VERSION = & git describe --tags --always 2>$null | ForEach-Object { $_ -replace '-', '+' -replace '^v', '' }
if (-not $VERSION) { $VERSION = "dev" }
$LDFLAGS = "-X `"main.Version=$VERSION`""
# Colors for output (Windows PowerShell compatible)
$CYAN = "Cyan"
$RESET = "White"
function Write-Header {
param([string]$Message)
Write-Host "=== $Message ===" -ForegroundColor Green
}
function Write-Info {
param([string]$Message)
Write-Host $Message -ForegroundColor Yellow
}
function Write-Success {
param([string]$Message)
Write-Host $Message -ForegroundColor Green
}
function Write-Error {
param([string]$Message)
Write-Host $Message -ForegroundColor Red
}
function Get-Help {
Write-Host "Usage: .\build.ps1 [target]" -ForegroundColor Green
Write-Host ""
Write-Host "Targets:" -ForegroundColor Green
Write-Host ""
Write-Host ("{0,-30}" -f "help") -ForegroundColor Cyan -NoNewline
Write-Host " Print this help message."
Write-Host ("{0,-30}" -f "build") -ForegroundColor Cyan -NoNewline
Write-Host " Build the application."
Write-Host ("{0,-30}" -f "install") -ForegroundColor Cyan -NoNewline
Write-Host " Install the application."
Write-Host ("{0,-30}" -f "uninstall") -ForegroundColor Cyan -NoNewline
Write-Host " Uninstall the application."
Write-Host ("{0,-30}" -f "clean") -ForegroundColor Cyan -NoNewline
Write-Host " Clean the build artifacts."
Write-Host ("{0,-30}" -f "air") -ForegroundColor Cyan -NoNewline
Write-Host " Install air for hot reload."
Write-Host ("{0,-30}" -f "dev") -ForegroundColor Cyan -NoNewline
Write-Host " Run the application with hot reload."
Write-Host ("{0,-30}" -f "vendor") -ForegroundColor Cyan -NoNewline
Write-Host " Tidy and verify module dependencies."
}
function Build-App {
Write-Header "Building application"
$ldflags = "-s -w $LDFLAGS"
Write-Info "go build -v -ldflags '$ldflags' -o $EXECUTABLE"
try {
& go build -v -ldflags $ldflags -o $EXECUTABLE
if ($LASTEXITCODE -eq 0) {
Write-Success "Build successful: $EXECUTABLE"
} else {
Write-Error "Build failed with exit code: $LASTEXITCODE"
exit $LASTEXITCODE
}
} catch {
Write-Error "Build failed: $_"
exit 1
}
}
function Install-App {
Write-Header "Installing application"
# First build the application
Build-App
$GOPATH = $env:GOPATH
if (-not $GOPATH) {
$GOPATH = Join-Path $env:USERPROFILE "go"
}
$installDir = Join-Path $GOPATH "bin"
$installPath = Join-Path $installDir $EXECUTABLE
Write-Info "Installing $EXECUTABLE to $installPath"
# Create directory if it doesn't exist
if (-not (Test-Path $installDir)) {
New-Item -ItemType Directory -Path $installDir -Force | Out-Null
}
# Copy the executable
if (Test-Path $EXECUTABLE) {
Copy-Item $EXECUTABLE $installPath -Force
Write-Success "Installed $EXECUTABLE to $installPath"
Write-Info "Please add $installDir to your PATH if it is not already there."
} else {
Write-Error "Executable not found. Please build first."
exit 1
}
}
function Uninstall-App {
Write-Header "Uninstalling application"
$GOPATH = $env:GOPATH
if (-not $GOPATH) {
$GOPATH = Join-Path $env:USERPROFILE "go"
}
$installPath = Join-Path $GOPATH "bin" $EXECUTABLE
Write-Info "Uninstalling $EXECUTABLE from $installPath"
if (Test-Path $installPath) {
Remove-Item $installPath -Force
Write-Success "Uninstalled $EXECUTABLE from $installPath"
} else {
Write-Warning "$EXECUTABLE not found at $installPath"
}
}
function Clean-Build {
Write-Header "Cleaning build artifacts"
Write-Info "Cleaning up $EXECUTABLE"
if (Test-Path $EXECUTABLE) {
Remove-Item $EXECUTABLE -Force
Write-Success "Cleaned up $EXECUTABLE"
} else {
Write-Warning "$EXECUTABLE not found"
}
}
function Install-Air {
Write-Header "Installing air for hot reload"
# Check if air is already installed
$airPath = Get-Command air -ErrorAction SilentlyContinue
if ($airPath) {
Write-Success "air is already installed"
return
}
Write-Info "Installing github.com/air-verse/air@latest"
try {
& go install github.com/air-verse/air@latest
if ($LASTEXITCODE -eq 0) {
Write-Success "air installed successfully"
} else {
Write-Error "Failed to install air"
exit $LASTEXITCODE
}
} catch {
Write-Error "Failed to install air: $_"
exit 1
}
}
function Start-Dev {
Write-Header "Starting development mode with hot reload"
# Install air first
Install-Air
Write-Info "Starting air with build configuration"
& air --build.cmd "go build -o $EXECUTABLE" --build.bin "./$EXECUTABLE"
}
function Update-Vendor {
Write-Header "Tidying and verifying module dependencies"
Write-Info "Running go mod tidy"
& go mod tidy
if ($LASTEXITCODE -ne 0) {
Write-Error "go mod tidy failed"
exit $LASTEXITCODE
}
Write-Info "Running go mod verify"
& go mod verify
if ($LASTEXITCODE -ne 0) {
Write-Error "go mod verify failed"
exit $LASTEXITCODE
}
Write-Success "Dependencies updated successfully"
}
# Main execution logic
switch ($Target.ToLower()) {
"help" { Get-Help }
"build" { Build-App }
"install" { Install-App }
"uninstall" { Uninstall-App }
"clean" { Clean-Build }
"air" { Install-Air }
"dev" { Start-Dev }
"vendor" { Update-Vendor }
default {
Write-Error "Unknown target: $Target"
Write-Host ""
Get-Help
exit 1
}
}

137
cmd/cmd.go Normal file
View File

@@ -0,0 +1,137 @@
package cmd
import (
"context"
"flag"
"fmt"
"os"
"strings"
"text/tabwriter"
"gitea.com/gitea/gitea-mcp/operation"
flagPkg "gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.com/gitea/gitea-mcp/pkg/log"
)
var (
host string
port int
token string
tools string
version bool
)
func init() {
flag.StringVar(&flagPkg.Mode, "t", "stdio", "")
flag.StringVar(&flagPkg.Mode, "transport", "stdio", "")
flag.StringVar(&host, "H", os.Getenv("GITEA_HOST"), "")
flag.StringVar(&host, "host", os.Getenv("GITEA_HOST"), "")
flag.IntVar(&port, "p", 8080, "")
flag.IntVar(&port, "port", 8080, "")
flag.StringVar(&token, "T", "", "")
flag.StringVar(&token, "token", "", "")
flag.BoolVar(&flagPkg.ReadOnly, "r", false, "")
flag.BoolVar(&flagPkg.ReadOnly, "read-only", false, "")
defaultTools := os.Getenv("GITEA_TOOLS")
flag.StringVar(&tools, "O", defaultTools, "")
flag.StringVar(&tools, "tools", defaultTools, "")
flag.BoolVar(&flagPkg.Debug, "d", false, "")
flag.BoolVar(&flagPkg.Debug, "debug", false, "")
flag.BoolVar(&flagPkg.Insecure, "k", false, "")
flag.BoolVar(&flagPkg.Insecure, "insecure", false, "")
flag.BoolVar(&version, "v", false, "")
flag.BoolVar(&version, "version", false, "")
flag.Usage = func() {
w := tabwriter.NewWriter(os.Stderr, 0, 0, 3, ' ', 0)
fmt.Fprintln(os.Stderr, "Usage: gitea-mcp [options]")
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "Options:")
fmt.Fprintf(w, " -t, -transport <type>\tTransport type: stdio or http (default: stdio)\n")
fmt.Fprintf(w, " -H, -host <url>\tGitea host URL (default: https://gitea.com)\n")
fmt.Fprintf(w, " -p, -port <number>\tHTTP server port (default: 8080)\n")
fmt.Fprintf(w, " -T, -token <token>\tPersonal access token\n")
fmt.Fprintf(w, " -r, -read-only\tExpose only read-only tools\n")
fmt.Fprintf(w, " -O, -tools <names>\tComma-separated list of tool names to expose\n")
fmt.Fprintf(w, " -d, -debug\tEnable debug mode\n")
fmt.Fprintf(w, " -k, -insecure\tIgnore TLS certificate errors\n")
fmt.Fprintf(w, " -v, -version\tPrint version and exit\n")
fmt.Fprintln(w)
fmt.Fprintln(w, "Environment variables:")
fmt.Fprintf(w, " GITEA_ACCESS_TOKEN\tProvide access token\n")
fmt.Fprintf(w, " GITEA_ACCESS_TOKEN_FILE\tPath to a file containing the access token (e.g. a Docker secret)\n")
fmt.Fprintf(w, " GITEA_DEBUG\tSet to 'true' for debug mode\n")
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_TOOLS\tComma-separated list of tool names to expose\n")
fmt.Fprintf(w, " MCP_MODE\tOverride transport mode\n")
w.Flush()
}
flag.Parse()
flagPkg.Host = host
if flagPkg.Host == "" {
flagPkg.Host = "https://gitea.com"
}
flagPkg.Port = port
flagPkg.Token = token
if flagPkg.Token == "" {
flagPkg.Token = os.Getenv("GITEA_ACCESS_TOKEN")
}
if flagPkg.Token == "" {
if tokenFile := os.Getenv("GITEA_ACCESS_TOKEN_FILE"); tokenFile != "" {
data, err := os.ReadFile(tokenFile)
if err != nil {
fmt.Fprintf(os.Stderr, "error reading GITEA_ACCESS_TOKEN_FILE: %v\n", err)
os.Exit(1)
}
flagPkg.Token = strings.TrimRight(string(data), "\r\n")
}
}
if os.Getenv("MCP_MODE") != "" {
flagPkg.Mode = os.Getenv("MCP_MODE")
}
if os.Getenv("GITEA_READONLY") == "true" {
flagPkg.ReadOnly = true
}
allowed := map[string]struct{}{}
for t := range strings.SplitSeq(tools, ",") {
if t = strings.TrimSpace(t); t != "" {
allowed[t] = struct{}{}
}
}
if len(allowed) > 0 {
flagPkg.AllowedTools = allowed
}
if os.Getenv("GITEA_DEBUG") == "true" {
flagPkg.Debug = true
}
// Set insecure mode based on environment variable
if os.Getenv("GITEA_INSECURE") == "true" {
flagPkg.Insecure = true
}
}
func Execute() {
if version {
fmt.Fprintln(os.Stdout, flagPkg.Version)
return
}
defer log.Default().Sync() //nolint:errcheck // best-effort flush
if err := operation.Run(); err != nil {
if err == context.Canceled {
log.Info("Server shutdown due to context cancellation")
return
}
log.Fatalf("Run Gitea MCP Server Error: %v", err) //nolint:gocritic // intentional exit after defer
}
}

16
config.json Normal file
View File

@@ -0,0 +1,16 @@
{
"mcpServers": {
"gitea": {
"command": "gitea-mcp",
"args": [
"-t", "stdio",
"--host", "https://gitea.com",
"--token", "<your personal access token>"
]
"env": {
"GITEA_HOST": "https://gitea.com",
"GITEA_ACCESS_TOKEN": "<your personal access token>"
}
}
}
}

29
go.mod Normal file
View File

@@ -0,0 +1,29 @@
module gitea.com/gitea/gitea-mcp
go 1.26.0
require (
gitea.dev/sdk v1.0.1
github.com/mark3labs/mcp-go v0.45.0
go.uber.org/zap v1.27.1
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
github.com/42wim/httpsig v1.2.4 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/sys v0.44.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

74
go.sum Normal file
View File

@@ -0,0 +1,74 @@
gitea.dev/sdk v1.0.1 h1:CWXQUQvp2I6YKOWkhYo1Flx2sRNfMK1X9Op4oR2awXs=
gitea.dev/sdk v1.0.1/go.mod h1:jCf5Uzz0Jkb61jxNgMxLOCWwle1J1B2nKdcRtxuK9rY=
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
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.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=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

23
main.go Normal file
View File

@@ -0,0 +1,23 @@
package main
import (
"runtime/debug"
"gitea.com/gitea/gitea-mcp/cmd"
"gitea.com/gitea/gitea-mcp/pkg/flag"
)
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
}
func main() {
cmd.Execute()
}

View File

@@ -0,0 +1,8 @@
package actions
import (
"gitea.com/gitea/gitea-mcp/pkg/tool"
)
// Tool is the registry for all Actions-related MCP tools.
var Tool = tool.New()

534
operation/actions/config.go Normal file
View File

@@ -0,0 +1,534 @@
package actions
import (
"context"
"fmt"
"net/url"
"strconv"
"time"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ActionsConfigReadToolName = "actions_config_read"
ActionsConfigWriteToolName = "actions_config_write"
)
type secretMeta struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at,omitzero"`
}
func toSecretMetas(secrets []*gitea_sdk.Secret) []secretMeta {
metas := make([]secretMeta, 0, len(secrets))
for _, s := range secrets {
if s == nil {
continue
}
metas = append(metas, secretMeta{
Name: s.Name,
Description: s.Description,
CreatedAt: s.Created,
})
}
return metas
}
var (
ActionsConfigReadTool = mcp.NewTool(
ActionsConfigReadToolName,
mcp.WithDescription("Read Actions secrets and variables."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read Actions secrets and variables")),
mcp.WithString("method", mcp.Required(), mcp.Enum("list_repo_secrets", "list_org_secrets", "list_repo_variables", "get_repo_variable", "list_org_variables", "get_org_variable")),
mcp.WithString("owner", mcp.Description("for repo methods")),
mcp.WithString("repo", mcp.Description("for repo methods")),
mcp.WithString("org", mcp.Description("for org methods")),
mcp.WithString("name", mcp.Description("for get methods")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)),
)
ActionsConfigWriteTool = mcp.NewTool(
ActionsConfigWriteToolName,
mcp.WithDescription("Write Actions secrets and variables: upsert, create, update, delete."),
mcp.WithToolAnnotation(annotation.Destructive("Manage Actions secrets and variables")),
mcp.WithString("method", mcp.Required(), mcp.Enum("upsert_repo_secret", "delete_repo_secret", "upsert_org_secret", "delete_org_secret", "create_repo_variable", "update_repo_variable", "delete_repo_variable", "create_org_variable", "update_org_variable", "delete_org_variable")),
mcp.WithString("owner", mcp.Description("for repo methods")),
mcp.WithString("repo", mcp.Description("for repo methods")),
mcp.WithString("org", mcp.Description("for org methods")),
mcp.WithString("name", mcp.Description("secret or variable name")),
mcp.WithString("data", mcp.Description("secret value (upsert)")),
mcp.WithString("value", mcp.Description("variable value")),
mcp.WithString("description"),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: ActionsConfigReadTool, Handler: configReadFn})
Tool.RegisterWrite(server.ServerTool{Tool: ActionsConfigWriteTool, Handler: configWriteFn})
}
func configReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list_repo_secrets":
return listRepoActionSecretsFn(ctx, req)
case "list_org_secrets":
return listOrgActionSecretsFn(ctx, req)
case "list_repo_variables":
return listRepoActionVariablesFn(ctx, req)
case "get_repo_variable":
return getRepoActionVariableFn(ctx, req)
case "list_org_variables":
return listOrgActionVariablesFn(ctx, req)
case "get_org_variable":
return getOrgActionVariableFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func configWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "upsert_repo_secret":
return upsertRepoActionSecretFn(ctx, req)
case "delete_repo_secret":
return deleteRepoActionSecretFn(ctx, req)
case "upsert_org_secret":
return upsertOrgActionSecretFn(ctx, req)
case "delete_org_secret":
return deleteOrgActionSecretFn(ctx, req)
case "create_repo_variable":
return createRepoActionVariableFn(ctx, req)
case "update_repo_variable":
return updateRepoActionVariableFn(ctx, req)
case "delete_repo_variable":
return deleteRepoActionVariableFn(ctx, req)
case "create_org_variable":
return createOrgActionVariableFn(ctx, req)
case "update_org_variable":
return updateOrgActionVariableFn(ctx, req)
case "delete_org_variable":
return deleteOrgActionVariableFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listRepoActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
secrets, _, err := client.Actions.ListRepoSecrets(ctx, owner, repo, gitea_sdk.ListRepoActionsSecretOption{
ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo action secrets err: %v", err))
}
return to.TextResult(toSecretMetas(secrets))
}
func upsertRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil {
return to.ErrorResult(err)
}
data, err := params.GetString(req.GetArguments(), "data")
if err != nil {
return to.ErrorResult(err)
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.Actions.CreateRepoSecret(ctx, owner, repo, name, gitea_sdk.CreateOrUpdateSecretOption{
Data: data,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("upsert repo action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode})
}
func deleteRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(req.GetArguments(), "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))
}
resp, err := client.Actions.DeleteRepoSecret(ctx, owner, repo, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete repo action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret deleted", "status": resp.StatusCode})
}
func listOrgActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
secrets, _, err := client.Actions.ListOrgSecrets(ctx, org, gitea_sdk.ListOrgActionsSecretOption{
ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list org action secrets err: %v", err))
}
return to.TextResult(toSecretMetas(secrets))
}
func upsertOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil {
return to.ErrorResult(err)
}
data, err := params.GetString(req.GetArguments(), "data")
if err != nil {
return to.ErrorResult(err)
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.Actions.CreateOrgSecret(ctx, org, name, gitea_sdk.CreateOrUpdateSecretOption{
Data: data,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("upsert org action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode})
}
func deleteOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil {
return to.ErrorResult(err)
}
escapedOrg := url.PathEscape(org)
escapedSecret := url.PathEscape(name)
_, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/secrets/%s", escapedOrg, escapedSecret), nil, nil, nil)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete org action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret deleted"})
}
func listRepoActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
var result any
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/actions/variables", url.PathEscape(owner), url.PathEscape(repo)), query, nil, &result)
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo action variables err: %v", err))
}
return to.TextResult(result)
}
func getRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(req.GetArguments(), "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))
}
variable, _, err := client.Actions.GetRepoVariable(ctx, owner, repo, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("get repo action variable err: %v", err))
}
return to.TextResult(variable)
}
func createRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil {
return to.ErrorResult(err)
}
value, err := params.GetString(req.GetArguments(), "value")
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))
}
resp, err := client.Actions.CreateRepoVariable(ctx, owner, repo, name, value)
if err != nil {
return to.ErrorResult(fmt.Errorf("create repo action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode})
}
func updateRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil {
return to.ErrorResult(err)
}
value, err := params.GetString(req.GetArguments(), "value")
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))
}
resp, err := client.Actions.UpdateRepoVariable(ctx, owner, repo, name, value)
if err != nil {
return to.ErrorResult(fmt.Errorf("update repo action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode})
}
func deleteRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(req.GetArguments(), "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))
}
resp, err := client.Actions.DeleteRepoVariable(ctx, owner, repo, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete repo action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable deleted", "status": resp.StatusCode})
}
func listOrgActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
variables, _, err := client.Actions.ListOrgVariables(ctx, org, gitea_sdk.ListOrgActionsVariableOption{
ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list org action variables err: %v", err))
}
return to.TextResult(variables)
}
func getOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(req.GetArguments(), "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))
}
variable, _, err := client.Actions.GetOrgVariable(ctx, org, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("get org action variable err: %v", err))
}
return to.TextResult(variable)
}
func createOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil {
return to.ErrorResult(err)
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil {
return to.ErrorResult(err)
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.Actions.CreateOrgVariable(ctx, org, name, gitea_sdk.CreateActionsVariableOption{
Value: value,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("create org action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode})
}
func updateOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil {
return to.ErrorResult(err)
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil {
return to.ErrorResult(err)
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.Actions.UpdateOrgVariable(ctx, org, name, gitea_sdk.UpdateActionsVariableOption{
Name: name,
Value: value,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("update org action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode})
}
func deleteOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil {
return to.ErrorResult(err)
}
_, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/variables/%s", url.PathEscape(org), url.PathEscape(name)), nil, nil, nil)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete org action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable deleted"})
}

View File

@@ -0,0 +1,22 @@
package actions
import "testing"
func TestTailByLines(t *testing.T) {
in := []byte("a\nb\nc\nd\n")
got := string(tailByLines(in, 2))
if got != "c\nd\n" {
t.Fatalf("tailByLines(...,2) = %q", got)
}
}
func TestLimitBytesKeepsTail(t *testing.T) {
in := []byte("0123456789")
out, truncated := limitBytes(in, 4)
if !truncated {
t.Fatalf("expected truncated=true")
}
if string(out) != "6789" {
t.Fatalf("limitBytes tail = %q, want %q", string(out), "6789")
}
}

538
operation/actions/runs.go Normal file
View File

@@ -0,0 +1,538 @@
package actions
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"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 (
ActionsRunReadToolName = "actions_run_read"
ActionsRunWriteToolName = "actions_run_write"
)
var (
ActionsRunReadTool = mcp.NewTool(
ActionsRunReadToolName,
mcp.WithDescription("Read Actions workflows, runs, jobs, and logs."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read Actions workflow, run, and job data")),
mcp.WithString("method", mcp.Required(), mcp.Enum("list_workflows", "get_workflow", "list_runs", "get_run", "list_jobs", "list_run_jobs", "get_job_log_preview", "download_job_log")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("workflow_id", mcp.Description("ID or filename (for 'get_workflow')")),
mcp.WithNumber("run_id", mcp.Description("for 'get_run'/'list_run_jobs'")),
mcp.WithNumber("job_id", mcp.Description("for log methods")),
mcp.WithString("status", mcp.Description("filter for 'list_runs'/'list_jobs'")),
mcp.WithNumber("tail_lines", mcp.Description("log tail lines"), mcp.DefaultNumber(200), mcp.Min(1)),
mcp.WithNumber("max_bytes", mcp.Description("max log bytes"), mcp.DefaultNumber(65536), mcp.Min(1024)),
mcp.WithString("output_path", mcp.Description("for 'download_job_log'")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)),
)
ActionsRunWriteTool = mcp.NewTool(
ActionsRunWriteToolName,
mcp.WithDescription("Write Actions runs: dispatch, cancel, rerun."),
mcp.WithToolAnnotation(annotation.Write("Trigger, cancel, or rerun Actions workflows")),
mcp.WithString("method", mcp.Required(), mcp.Enum("dispatch_workflow", "cancel_run", "rerun_run")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("workflow_id", mcp.Description("ID or filename (for 'dispatch_workflow')")),
mcp.WithString("ref", mcp.Description("branch or tag (for 'dispatch_workflow')")),
mcp.WithObject("inputs", mcp.Description("for 'dispatch_workflow'")),
mcp.WithNumber("run_id", mcp.Description("for 'cancel_run'/'rerun_run'")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: ActionsRunReadTool, Handler: runReadFn})
Tool.RegisterWrite(server.ServerTool{Tool: ActionsRunWriteTool, Handler: runWriteFn})
}
func runReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list_workflows":
return listRepoActionWorkflowsFn(ctx, req)
case "get_workflow":
return getRepoActionWorkflowFn(ctx, req)
case "list_runs":
return listRepoActionRunsFn(ctx, req)
case "get_run":
return getRepoActionRunFn(ctx, req)
case "list_jobs":
return listRepoActionJobsFn(ctx, req)
case "list_run_jobs":
return listRepoActionRunJobsFn(ctx, req)
case "get_job_log_preview":
return getRepoActionJobLogPreviewFn(ctx, req)
case "download_job_log":
return downloadRepoActionJobLogFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func runWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "dispatch_workflow":
return dispatchRepoActionWorkflowFn(ctx, req)
case "cancel_run":
return cancelRepoActionRunFn(ctx, req)
case "rerun_run":
return rerunRepoActionRunFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func doJSONWithFallback(ctx context.Context, method string, paths []string, query url.Values, body, respOut any) error {
var lastErr error
for _, p := range paths {
_, err := gitea.DoJSON(ctx, method, p, query, body, respOut)
if err == nil {
return nil
}
lastErr = err
var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
continue
}
return err
}
return lastErr
}
func listRepoActionWorkflowsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/workflows", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list action workflows err: %v", err))
}
return to.TextResult(slimActionWorkflows(result))
}
func getRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
workflowID, err := params.GetString(req.GetArguments(), "workflow_id")
if err != nil {
return to.ErrorResult(err)
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/workflows/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
},
nil, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("get action workflow err: %v", err))
}
return to.TextResult(slimActionWorkflow(result))
}
func dispatchRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
workflowID, err := params.GetString(req.GetArguments(), "workflow_id")
if err != nil {
return to.ErrorResult(err)
}
ref, err := params.GetString(req.GetArguments(), "ref")
if err != nil {
return to.ErrorResult(err)
}
var inputs map[string]any
if raw, exists := req.GetArguments()["inputs"]; exists {
if m, ok := raw.(map[string]any); ok {
inputs = m
}
}
body := map[string]any{
"ref": ref,
}
if inputs != nil {
body["inputs"] = inputs
}
err = doJSONWithFallback(ctx, "POST",
[]string{
fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatches", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatch", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
},
nil, body, nil,
)
if err != nil {
var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
return to.ErrorResult(fmt.Errorf("workflow dispatch not supported on this Gitea version (endpoint returned %d). Check https://docs.gitea.com/api/1.24/ for available Actions endpoints", httpErr.StatusCode))
}
return to.ErrorResult(fmt.Errorf("dispatch action workflow err: %v", err))
}
return to.TextResult(map[string]any{"message": "workflow dispatched"})
}
func listRepoActionRunsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
statusFilter, _ := req.GetArguments()["status"].(string)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
if statusFilter != "" {
query.Set("status", statusFilter)
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list action runs err: %v", err))
}
return to.TextResult(slimActionRuns(result))
}
func getRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
runID, err := params.GetIndex(req.GetArguments(), "run_id")
if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required"))
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d", url.PathEscape(owner), url.PathEscape(repo), runID),
},
nil, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("get action run err: %v", err))
}
return to.TextResult(slimActionRun(result))
}
func cancelRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
runID, err := params.GetIndex(req.GetArguments(), "run_id")
if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required"))
}
err = doJSONWithFallback(ctx, "POST",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d/cancel", url.PathEscape(owner), url.PathEscape(repo), runID),
},
nil, nil, nil,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("cancel action run err: %v", err))
}
return to.TextResult(map[string]any{"message": "run cancellation requested"})
}
func rerunRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
runID, err := params.GetIndex(req.GetArguments(), "run_id")
if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required"))
}
err = doJSONWithFallback(ctx, "POST",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d/rerun", url.PathEscape(owner), url.PathEscape(repo), runID),
fmt.Sprintf("repos/%s/%s/actions/runs/%d/rerun-failed-jobs", url.PathEscape(owner), url.PathEscape(repo), runID),
},
nil, nil, nil,
)
if err != nil {
var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
return to.ErrorResult(fmt.Errorf("workflow rerun not supported on this Gitea version (endpoint returned %d). Check https://docs.gitea.com/api/1.24/ for available Actions endpoints", httpErr.StatusCode))
}
return to.ErrorResult(fmt.Errorf("rerun action run err: %v", err))
}
return to.TextResult(map[string]any{"message": "run rerun requested"})
}
func listRepoActionJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
statusFilter, _ := req.GetArguments()["status"].(string)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
if statusFilter != "" {
query.Set("status", statusFilter)
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/jobs", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list action jobs err: %v", err))
}
return to.TextResult(slimActionJobs(result))
}
func listRepoActionRunJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
runID, err := params.GetIndex(req.GetArguments(), "run_id")
if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d/jobs", url.PathEscape(owner), url.PathEscape(repo), runID),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list action run jobs err: %v", err))
}
return to.TextResult(slimActionJobs(result))
}
func logPaths(owner, repo string, jobID int64) []string {
return []string{
fmt.Sprintf("repos/%s/%s/actions/jobs/%d/logs", url.PathEscape(owner), url.PathEscape(repo), jobID),
fmt.Sprintf("repos/%s/%s/actions/jobs/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID),
fmt.Sprintf("repos/%s/%s/actions/tasks/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID),
fmt.Sprintf("repos/%s/%s/actions/task/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID),
}
}
func fetchJobLogBytes(ctx context.Context, owner, repo string, jobID int64) ([]byte, string, error) {
var lastErr error
for _, p := range logPaths(owner, repo, jobID) {
b, _, err := gitea.DoBytes(ctx, "GET", p, nil, nil, "text/plain")
if err == nil {
return b, p, nil
}
lastErr = err
var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
continue
}
return nil, p, err
}
return nil, "", lastErr
}
func tailByLines(data []byte, tailLines int) []byte {
if tailLines <= 0 || len(data) == 0 {
return data
}
lines := 0
i := len(data) - 1
for i >= 0 {
if data[i] == '\n' {
lines++
if lines > tailLines {
return data[i+1:]
}
}
i--
}
return data
}
func limitBytes(data []byte, maxBytes int) ([]byte, bool) {
if maxBytes <= 0 {
return data, false
}
if len(data) <= maxBytes {
return data, false
}
return data[len(data)-maxBytes:], true
}
func getRepoActionJobLogPreviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
jobID, err := params.GetIndex(req.GetArguments(), "job_id")
if err != nil {
return to.ErrorResult(err)
}
tailLines := int(params.GetOptionalInt(req.GetArguments(), "tail_lines", 200))
maxBytes := int(params.GetOptionalInt(req.GetArguments(), "max_bytes", 65536))
raw, usedPath, err := fetchJobLogBytes(ctx, owner, repo, jobID)
if err != nil {
return to.ErrorResult(fmt.Errorf("get job log err: %v", err))
}
tailed := tailByLines(raw, tailLines)
limited, truncated := limitBytes(tailed, maxBytes)
return to.TextResult(map[string]any{
"endpoint": usedPath,
"job_id": jobID,
"bytes": len(raw),
"tail_lines": tailLines,
"max_bytes": maxBytes,
"truncated": truncated,
"log": string(limited),
})
}
func downloadRepoActionJobLogFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
jobID, err := params.GetIndex(req.GetArguments(), "job_id")
if err != nil {
return to.ErrorResult(err)
}
outputPath, _ := req.GetArguments()["output_path"].(string)
raw, usedPath, err := fetchJobLogBytes(ctx, owner, repo, jobID)
if err != nil {
return to.ErrorResult(fmt.Errorf("download job log err: %v", err))
}
if outputPath == "" {
home, _ := os.UserHomeDir()
if home == "" {
home = os.TempDir()
}
outputPath = filepath.Join(home, ".gitea-mcp", "artifacts", "actions-logs", owner, repo, fmt.Sprintf("%d.log", jobID))
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil {
return to.ErrorResult(fmt.Errorf("create output dir err: %v", err))
}
if err := os.WriteFile(outputPath, raw, 0o600); err != nil {
return to.ErrorResult(fmt.Errorf("write log file err: %v", err))
}
return to.TextResult(map[string]any{
"endpoint": usedPath,
"job_id": jobID,
"path": outputPath,
"bytes": len(raw),
})
}

92
operation/actions/slim.go Normal file
View File

@@ -0,0 +1,92 @@
package actions
func pick(m map[string]any, keys ...string) map[string]any {
out := make(map[string]any, len(keys))
for _, k := range keys {
if v, ok := m[k]; ok {
out[k] = v
}
}
return out
}
func slimPaginated(raw any, itemFn func(map[string]any) map[string]any) any {
m, ok := raw.(map[string]any)
if !ok {
return raw
}
result := make(map[string]any)
if tc, ok := m["total_count"]; ok {
result["total_count"] = tc
}
for key, val := range m {
if key == "total_count" {
continue
}
arr, ok := val.([]any)
if !ok {
continue
}
slimmed := make([]any, 0, len(arr))
for _, item := range arr {
if im, ok := item.(map[string]any); ok {
slimmed = append(slimmed, itemFn(im))
}
}
result[key] = slimmed
break
}
return result
}
func slimRun(m map[string]any) map[string]any {
return pick(m, "id", "name", "head_branch", "head_sha", "run_number",
"event", "status", "conclusion", "workflow_id",
"html_url", "created_at", "updated_at")
}
func slimJob(m map[string]any) map[string]any {
out := pick(m, "id", "run_id", "name", "workflow_name",
"status", "conclusion", "html_url",
"started_at", "completed_at")
if steps, ok := m["steps"].([]any); ok {
slim := make([]any, 0, len(steps))
for _, s := range steps {
if sm, ok := s.(map[string]any); ok {
slim = append(slim, pick(sm, "name", "number", "status", "conclusion"))
}
}
out["steps"] = slim
}
return out
}
func slimWorkflow(m map[string]any) map[string]any {
return pick(m, "id", "name", "path", "state", "html_url", "created_at", "updated_at")
}
func slimActionRun(raw any) any {
if m, ok := raw.(map[string]any); ok {
return slimRun(m)
}
return raw
}
func slimActionRuns(raw any) any {
return slimPaginated(raw, slimRun)
}
func slimActionJobs(raw any) any {
return slimPaginated(raw, slimJob)
}
func slimActionWorkflow(raw any) any {
if m, ok := raw.(map[string]any); ok {
return slimWorkflow(m)
}
return raw
}
func slimActionWorkflows(raw any) any {
return slimPaginated(raw, slimWorkflow)
}

539
operation/issue/issue.go Normal file
View File

@@ -0,0 +1,539 @@
package issue
import (
"context"
"fmt"
"net/url"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/slim"
"gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
// issueWithAssets / commentWithAssets wrap the SDK types to capture the
// `assets` field that the SDK currently drops on these endpoints.
type issueWithAssets struct {
gitea_sdk.Issue
Assets []*gitea_sdk.Attachment `json:"assets"`
}
type commentWithAssets struct {
gitea_sdk.Comment
Assets []*gitea_sdk.Attachment `json:"assets"`
}
var Tool = tool.New()
const (
ListRepoIssuesToolName = "list_issues"
IssueReadToolName = "issue_read"
IssueWriteToolName = "issue_write"
)
var (
ListRepoIssuesTool = mcp.NewTool(
ListRepoIssuesToolName,
mcp.WithToolAnnotation(annotation.ReadOnly("List repository issues")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("state", mcp.DefaultString("all")),
mcp.WithString("type", mcp.Description("issues or pulls"), mcp.Enum("issues", "pulls")),
mcp.WithArray("labels", mcp.Description("label name filter"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithArray("milestones", mcp.Description("milestone name or ID filter"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithString("since", mcp.Description("updated after ISO 8601")),
mcp.WithString("before", mcp.Description("updated before ISO 8601")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
IssueReadTool = mcp.NewTool(
IssueReadToolName,
mcp.WithDescription("Read issue: details, comments, or labels."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read issue details")),
mcp.WithString("method", mcp.Required(), mcp.Enum("get", "get_comments", "get_labels")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("issue_number", mcp.Required()),
)
IssueWriteTool = mcp.NewTool(
IssueWriteToolName,
mcp.WithDescription("Write issues: create, update, manage comments and labels."),
mcp.WithToolAnnotation(annotation.Write("Create or update issues, comments, and labels")),
mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("issue_number", mcp.Description("required except for 'create'")),
mcp.WithString("title", mcp.Description("required for 'create'")),
mcp.WithString("body", mcp.Description("required for 'create'/'add_comment'/'edit_comment'")),
mcp.WithArray("assignees", mcp.Items(map[string]any{"type": "string"})),
mcp.WithNumber("milestone"),
mcp.WithString("state", mcp.Enum("open", "closed", "all")),
mcp.WithNumber("commentID", mcp.Description("for 'edit_comment'")),
mcp.WithArray("labels", mcp.Description("label IDs"), mcp.Items(map[string]any{"type": "number"})),
mcp.WithNumber("label_id", mcp.Description("for 'remove_label'")),
mcp.WithString("ref", mcp.Description("branch to associate")),
mcp.WithString("deadline", mcp.Description("ISO 8601")),
mcp.WithBoolean("remove_deadline"),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListRepoIssuesTool,
Handler: listRepoIssuesFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: IssueReadTool,
Handler: issueReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: IssueWriteTool,
Handler: issueWriteFn,
})
}
func issueReadFn(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 getIssueByIndexFn(ctx, req)
case "get_comments":
return getIssueCommentsByIndexFn(ctx, req)
case "get_labels":
return getIssueLabelsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func issueWriteFn(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 createIssueFn(ctx, req)
case "update":
return editIssueFn(ctx, req)
case "add_comment":
return createIssueCommentFn(ctx, req)
case "edit_comment":
return editIssueCommentFn(ctx, req)
case "add_labels":
return addIssueLabelsFn(ctx, req)
case "remove_label":
return removeIssueLabelFn(ctx, req)
case "replace_labels":
return replaceIssueLabelsFn(ctx, req)
case "clear_labels":
return clearIssueLabelsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func getIssueByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
var issue issueWithAssets
path := fmt.Sprintf("repos/%s/%s/issues/%d", url.PathEscape(owner), url.PathEscape(repo), index)
if _, err := gitea.DoJSON(ctx, "GET", path, nil, nil, &issue); err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issue/%v err: %v", owner, repo, index, err))
}
m := slimIssue(&issue.Issue)
m["body"] = slim.BodyWithAttachments(issue.Body, issue.Assets)
return to.TextResult(m)
}
func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
state, ok := req.GetArguments()["state"].(string)
if !ok {
state = "all"
}
labels := params.GetStringSlice(req.GetArguments(), "labels")
milestones := params.GetStringSlice(req.GetArguments(), "milestones")
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.ListIssueOption{
State: gitea_sdk.StateType(state),
Labels: labels,
Milestones: milestones,
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
switch req.GetArguments()["type"] {
case "issues":
opt.Type = gitea_sdk.IssueTypeIssue
case "pulls":
opt.Type = gitea_sdk.IssueTypePull
}
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))
}
issues, _, err := client.Issues.ListRepoIssues(ctx, owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issues err: %v", owner, repo, err))
}
return to.TextResult(slimIssues(issues))
}
func createIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
title, err := params.GetString(req.GetArguments(), "title")
if err != nil {
return to.ErrorResult(err)
}
body, err := params.GetString(req.GetArguments(), "body")
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.CreateIssueOption{
Title: title,
Body: body,
}
opt.Assignees = params.GetStringSlice(req.GetArguments(), "assignees")
if val, exists := req.GetArguments()["milestone"]; exists {
if milestone, ok := params.ToInt64(val); ok {
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.Issues.CreateIssue(ctx, owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/issue err: %v", owner, repo, err))
}
return to.TextResult(slimIssue(issue))
}
func createIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
body, err := params.GetString(req.GetArguments(), "body")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.CreateIssueCommentOption{
Body: body,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
issueComment, _, err := client.Issues.CreateIssueComment(ctx, owner, repo, index, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/issue/%v/comment err: %v", owner, repo, index, err))
}
return to.TextResult(slimComment(issueComment))
}
func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
args := req.GetArguments()
opt := gitea_sdk.EditIssueOption{
Body: params.GetPresentStringPtr(args, "body"),
Ref: params.GetPresentStringPtr(args, "ref"),
Assignees: params.GetStringSlice(args, "assignees"),
Deadline: params.GetOptionalTime(args, "deadline"),
RemoveDeadline: params.GetOptionalBoolPtr(args, "remove_deadline"),
}
if title, ok := args["title"].(string); ok {
opt.Title = title
}
if val, exists := args["milestone"]; exists {
if milestone, ok := params.ToInt64(val); ok {
opt.Milestone = &milestone
}
}
if state, ok := args["state"].(string); ok {
s := gitea_sdk.StateType(state)
opt.State = &s
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
issue, _, err := client.Issues.EditIssue(ctx, owner, repo, index, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/issue/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimIssue(issue))
}
func editIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
commentID, err := params.GetIndex(req.GetArguments(), "commentID")
if err != nil {
return to.ErrorResult(err)
}
body, err := params.GetString(req.GetArguments(), "body")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.EditIssueCommentOption{
Body: body,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
issueComment, _, err := client.Issues.EditIssueComment(ctx, owner, repo, commentID, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/issues/comments/%v err: %v", owner, repo, commentID, err))
}
return to.TextResult(slimComment(issueComment))
}
func getIssueCommentsByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
var comments []commentWithAssets
path := fmt.Sprintf("repos/%s/%s/issues/%d/comments", url.PathEscape(owner), url.PathEscape(repo), index)
if _, err := gitea.DoJSON(ctx, "GET", path, nil, nil, &comments); err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/comments err: %v", owner, repo, index, err))
}
out := make([]map[string]any, 0, len(comments))
for i := range comments {
m := slimComment(&comments[i].Comment)
m["body"] = slim.BodyWithAttachments(comments[i].Body, comments[i].Assets)
out = append(out, m)
}
return to.TextResult(out)
}
func getIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
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))
}
labels, _, err := client.Issues.GetIssueLabels(ctx, owner, repo, index, gitea_sdk.ListLabelsOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/labels err: %v", owner, repo, index, err))
}
return to.TextResult(slim.Labels(labels))
}
func addIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
labels, err := params.GetInt64Slice(req.GetArguments(), "labels")
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))
}
issueLabels, _, err := client.Issues.AddIssueLabels(ctx, owner, repo, index, gitea_sdk.IssueLabelsOption{Labels: labels})
if err != nil {
return to.ErrorResult(fmt.Errorf("add labels to %v/%v/issue/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slim.Labels(issueLabels))
}
func replaceIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
labels, err := params.GetInt64Slice(req.GetArguments(), "labels")
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))
}
issueLabels, _, err := client.Issues.ReplaceIssueLabels(ctx, owner, repo, index, gitea_sdk.IssueLabelsOption{Labels: labels})
if err != nil {
return to.ErrorResult(fmt.Errorf("replace labels on %v/%v/issue/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slim.Labels(issueLabels))
}
func clearIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
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.Issues.ClearIssueLabels(ctx, owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("clear labels on %v/%v/issue/%v err: %v", owner, repo, index, err))
}
return to.TextResult("Labels cleared successfully")
}
func removeIssueLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
labelID, err := params.GetIndex(req.GetArguments(), "label_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.Issues.DeleteIssueLabel(ctx, owner, repo, index, labelID)
if err != nil {
return to.ErrorResult(fmt.Errorf("remove label %v from %v/%v/issue/%v err: %v", labelID, owner, repo, index, err))
}
return to.TextResult("Label removed successfully")
}

View File

@@ -0,0 +1,324 @@
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,
"type": "issues",
"labels": []any{"bug", "enhancement"},
"milestones": []any{"v1.0", "2"},
"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)
}
if !strings.Contains(gotQuery, "milestones=v1.0%2C2") {
t.Fatalf("expected milestones query param, got %s", gotQuery)
}
if !strings.Contains(gotQuery, "type=issues") {
t.Fatalf("expected type query param, got %s", gotQuery)
}
}
func Test_listRepoIssuesFn_includesMilestone(t *testing.T) {
const (
owner = "octo"
repo = "demo"
)
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):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"number": 1, "title": "with milestone", "state": "closed", "milestone": {"id": 5, "title": "v1.0"}},
{"number": 2, "title": "without milestone", "state": "open"}
]`))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
"owner": owner, "repo": repo,
}}}
res, err := listRepoIssuesFn(context.Background(), req)
if err != nil {
t.Fatalf("listRepoIssuesFn() error = %v", err)
}
if res.IsError {
t.Fatalf("unexpected error result: %v", res.Content)
}
body := res.Content[0].(mcp.TextContent).Text
if !strings.Contains(body, `"milestone"`) || !strings.Contains(body, `"v1.0"`) {
t.Fatalf("expected milestone in list output, got: %s", body)
}
}
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")
}
}
func Test_getIssueByIndexFn_includesAttachments(t *testing.T) {
const (
owner = "octo"
repo = "demo"
)
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/issues/42", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"number": 42,
"title": "bug with screenshot",
"body": "see attached",
"state": "open",
"assets": [
{"id": 1, "name": "shot.png", "size": 1024, "browser_download_url": "https://example/shot.png"}
]
}`))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
"owner": owner, "repo": repo, "issue_number": float64(42),
}}}
res, err := getIssueByIndexFn(context.Background(), req)
if err != nil {
t.Fatalf("getIssueByIndexFn() error = %v", err)
}
if res.IsError {
t.Fatalf("unexpected error result: %v", res.Content)
}
body := res.Content[0].(mcp.TextContent).Text
if !strings.Contains(body, `[shot.png](https://example/shot.png)`) {
t.Fatalf("expected attachment markdown inlined in body, got: %s", body)
}
if strings.Contains(body, `"attachments"`) {
t.Fatalf("attachments should be inlined into body, not a separate field: %s", body)
}
}
func Test_getIssueCommentsByIndexFn_includesAttachments(t *testing.T) {
const (
owner = "octo"
repo = "demo"
)
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/issues/7/comments", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"id": 1, "body": "see this", "assets": [
{"id": 9, "name": "log.txt", "size": 200, "browser_download_url": "https://example/log.txt"}
]},
{"id": 2, "body": "no attachment", "assets": []}
]`))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
"owner": owner, "repo": repo, "issue_number": float64(7),
}}}
res, err := getIssueCommentsByIndexFn(context.Background(), req)
if err != nil {
t.Fatalf("getIssueCommentsByIndexFn() error = %v", err)
}
if res.IsError {
t.Fatalf("unexpected error result: %v", res.Content)
}
body := res.Content[0].(mcp.TextContent).Text
if !strings.Contains(body, `[log.txt](https://example/log.txt)`) {
t.Fatalf("expected attachment markdown inlined in body, got: %s", body)
}
if strings.Contains(body, `"attachments"`) {
t.Fatalf("attachments should be inlined into body, not a separate field: %s", body)
}
}

95
operation/issue/slim.go Normal file
View File

@@ -0,0 +1,95 @@
package issue
import (
"gitea.com/gitea/gitea-mcp/pkg/slim"
gitea_sdk "gitea.dev/sdk"
)
func slimIssue(i *gitea_sdk.Issue) map[string]any {
if i == nil {
return nil
}
m := map[string]any{
"number": i.Index,
"title": i.Title,
"body": i.Body,
"state": i.State,
"html_url": i.HTMLURL,
"user": slim.UserLogin(i.Poster),
"labels": slim.LabelNames(i.Labels),
"comments": i.Comments,
"created_at": i.Created,
"updated_at": i.Updated,
"closed_at": i.Closed,
}
if len(i.Assignees) > 0 {
m["assignees"] = slim.UserLogins(i.Assignees)
}
if i.Milestone != nil {
m["milestone"] = map[string]any{
"id": i.Milestone.ID,
"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
}
return m
}
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": slim.UserLogin(i.Poster),
"comments": i.Comments,
"created_at": i.Created,
"updated_at": i.Updated,
}
if len(i.Labels) > 0 {
m["labels"] = slim.LabelNames(i.Labels)
}
if i.Milestone != nil {
m["milestone"] = map[string]any{
"id": i.Milestone.ID,
"title": i.Milestone.Title,
}
}
if i.Ref != "" {
m["ref"] = i.Ref
}
if i.Deadline != nil {
m["deadline"] = i.Deadline
}
out = append(out, m)
}
return out
}
func slimComment(c *gitea_sdk.Comment) map[string]any {
if c == nil {
return nil
}
return map[string]any{
"id": c.ID,
"body": c.Body,
"user": slim.UserLogin(c.Poster),
"html_url": c.HTMLURL,
"created_at": c.Created,
"updated_at": c.Updated,
}
}

View File

@@ -0,0 +1,69 @@
package issue
import (
"testing"
gitea_sdk "gitea.dev/sdk"
)
func TestSlimIssue(t *testing.T) {
i := &gitea_sdk.Issue{
Index: 42,
Title: "Bug report",
Body: "Something is broken",
State: "open",
HTMLURL: "https://gitea.com/org/repo/issues/42",
Poster: &gitea_sdk.User{UserName: "alice"},
Labels: []*gitea_sdk.Label{{Name: "bug"}},
Milestone: &gitea_sdk.Milestone{
ID: 1,
Title: "v1.0",
},
PullRequest: &gitea_sdk.PullRequestMeta{HasMerged: false},
}
m := slimIssue(i)
if m["number"] != int64(42) {
t.Errorf("expected number 42, got %v", m["number"])
}
if m["body"] != "Something is broken" {
t.Errorf("expected body, got %v", m["body"])
}
if m["is_pull"] != true {
t.Error("expected is_pull true for issue with PullRequest")
}
ms := m["milestone"].(map[string]any)
if ms["title"] != "v1.0" {
t.Errorf("expected milestone title v1.0, got %v", ms["title"])
}
}
func TestSlimIssues_ListIsSlimmer(t *testing.T) {
i := &gitea_sdk.Issue{
Index: 1,
Title: "Issue",
State: "open",
Body: "Full body",
Poster: &gitea_sdk.User{UserName: "alice"},
Labels: []*gitea_sdk.Label{{Name: "enhancement"}},
}
single := slimIssue(i)
list := slimIssues([]*gitea_sdk.Issue{i})
// Single has body, list does not
if _, ok := single["body"]; !ok {
t.Error("single issue should have body")
}
if _, ok := list[0]["body"]; ok {
t.Error("list issue should not have body")
}
}
func TestSlimIssues_Nil(t *testing.T) {
if r := slimIssues(nil); len(r) != 0 {
t.Errorf("expected empty slice, got %v", r)
}
}

366
operation/label/label.go Normal file
View File

@@ -0,0 +1,366 @@
package label
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/slim"
"gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
LabelReadToolName = "label_read"
LabelWriteToolName = "label_write"
)
var (
LabelReadTool = mcp.NewTool(
LabelReadToolName,
mcp.WithDescription("Read repo or org labels."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read labels")),
mcp.WithString("method", mcp.Required(), mcp.Enum("list_repo_labels", "get_repo_label", "list_org_labels")),
mcp.WithString("owner", mcp.Description("for repo methods")),
mcp.WithString("repo", mcp.Description("for repo methods")),
mcp.WithString("org", mcp.Description("for org methods")),
mcp.WithNumber("id", mcp.Description("label ID (for 'get_repo_label')")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
LabelWriteTool = mcp.NewTool(
LabelWriteToolName,
mcp.WithDescription("Write labels (repo or org): create, edit, delete."),
mcp.WithToolAnnotation(annotation.Destructive("Create, update, or delete labels")),
mcp.WithString("method", mcp.Required(), mcp.Enum("create_repo_label", "edit_repo_label", "delete_repo_label", "create_org_label", "edit_org_label", "delete_org_label")),
mcp.WithString("owner", mcp.Description("for repo methods")),
mcp.WithString("repo", mcp.Description("for repo methods")),
mcp.WithString("org", mcp.Description("for org methods")),
mcp.WithNumber("id", mcp.Description("for edit/delete")),
mcp.WithString("name", mcp.Description("required for create")),
mcp.WithString("color", mcp.Description("hex (#RRGGBB); required for create")),
mcp.WithString("description"),
mcp.WithBoolean("exclusive", mcp.Description("exclusive (org only)")),
mcp.WithBoolean("is_archived", mcp.Description("archived (repo only)")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: LabelReadTool,
Handler: labelReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: LabelWriteTool,
Handler: labelWriteFn,
})
}
func labelReadFn(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_repo_labels":
return listRepoLabelsFn(ctx, req)
case "get_repo_label":
return getRepoLabelFn(ctx, req)
case "list_org_labels":
return listOrgLabelsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func labelWriteFn(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_repo_label":
return createRepoLabelFn(ctx, req)
case "edit_repo_label":
return editRepoLabelFn(ctx, req)
case "delete_repo_label":
return deleteRepoLabelFn(ctx, req)
case "create_org_label":
return createOrgLabelFn(ctx, req)
case "edit_org_label":
return editOrgLabelFn(ctx, req)
case "delete_org_label":
return deleteOrgLabelFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listRepoLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.ListLabelsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
labels, _, err := client.Repositories.ListRepoLabels(ctx, owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list %v/%v/labels err: %v", owner, repo, err))
}
return to.TextResult(slim.Labels(labels))
}
func getRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
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))
}
label, _, err := client.Repositories.GetRepoLabel(ctx, owner, repo, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/label/%v err: %v", owner, repo, id, err))
}
return to.TextResult(slim.Label(label))
}
func createRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil {
return to.ErrorResult(err)
}
color, err := params.GetString(req.GetArguments(), "color")
if err != nil {
return to.ErrorResult(err)
}
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)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
label, _, err := client.Repositories.CreateLabel(ctx, owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/label err: %v", owner, repo, err))
}
return to.TextResult(slim.Label(label))
}
func editRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
args := req.GetArguments()
opt := gitea_sdk.EditLabelOption{
Name: params.GetOptionalStringPtr(args, "name"),
Color: params.GetOptionalStringPtr(args, "color"),
Description: params.GetPresentStringPtr(args, "description"),
IsArchived: params.GetOptionalBoolPtr(args, "is_archived"),
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
label, _, err := client.Repositories.EditLabel(ctx, owner, repo, id, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/label/%v err: %v", owner, repo, id, err))
}
return to.TextResult(slim.Label(label))
}
func deleteRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
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))
}
_, err = client.Repositories.DeleteLabel(ctx, owner, repo, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete %v/%v/label/%v err: %v", owner, repo, id, err))
}
return to.TextResult("Label deleted successfully")
}
func listOrgLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.ListOrgLabelsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
labels, _, err := client.Organizations.ListOrgLabels(ctx, org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list %v/labels err: %v", org, err))
}
return to.TextResult(slim.Labels(labels))
}
func createOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil {
return to.ErrorResult(err)
}
color, err := params.GetString(req.GetArguments(), "color")
if err != nil {
return to.ErrorResult(err)
}
description, _ := req.GetArguments()["description"].(string)
exclusive, _ := req.GetArguments()["exclusive"].(bool)
opt := gitea_sdk.CreateOrgLabelOption{
Name: name,
Color: color,
Description: description,
Exclusive: exclusive,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
label, _, err := client.Organizations.CreateOrgLabel(ctx, org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/labels err: %v", org, err))
}
return to.TextResult(slim.Label(label))
}
func editOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
args := req.GetArguments()
opt := gitea_sdk.EditOrgLabelOption{
Name: params.GetOptionalStringPtr(args, "name"),
Color: params.GetOptionalStringPtr(args, "color"),
Description: params.GetPresentStringPtr(args, "description"),
Exclusive: params.GetOptionalBoolPtr(args, "exclusive"),
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
label, _, err := client.Organizations.EditOrgLabel(ctx, org, id, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/labels/%v err: %v", org, id, err))
}
return to.TextResult(slim.Label(label))
}
func deleteOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
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))
}
_, err = client.Organizations.DeleteOrgLabel(ctx, org, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete %v/labels/%v err: %v", org, id, err))
}
return to.TextResult("Label deleted successfully")
}

1
operation/label/slim.go Normal file
View File

@@ -0,0 +1 @@
package label

View File

@@ -0,0 +1,254 @@
package milestone
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
MilestoneReadToolName = "milestone_read"
MilestoneWriteToolName = "milestone_write"
)
var (
MilestoneReadTool = mcp.NewTool(
MilestoneReadToolName,
mcp.WithDescription("Read milestones: get one or list."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read milestones")),
mcp.WithString("method", mcp.Required(), mcp.Enum("get", "list")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("id", mcp.Description("for 'get'")),
mcp.WithString("state", mcp.DefaultString("all")),
mcp.WithString("name", mcp.Description("name filter (for 'list')")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
MilestoneWriteTool = mcp.NewTool(
MilestoneWriteToolName,
mcp.WithDescription("Write milestones: create, update, delete."),
mcp.WithToolAnnotation(annotation.Destructive("Create, update, or delete milestones")),
mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "edit", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("id", mcp.Description("for 'update'/'delete'")),
mcp.WithString("title", mcp.Description("for 'create'")),
mcp.WithString("description"),
mcp.WithString("due_on", mcp.Description("due date")),
mcp.WithString("state", mcp.Enum("open", "closed")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: MilestoneReadTool,
Handler: milestoneReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: MilestoneWriteTool,
Handler: milestoneWriteFn,
})
}
func milestoneReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "get":
return getMilestoneFn(ctx, req)
case "list":
return listMilestonesFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func milestoneWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createMilestoneFn(ctx, req)
case "update":
return editMilestoneFn(ctx, req)
case "edit":
return editMilestoneFn(ctx, req)
case "delete":
return deleteMilestoneFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func getMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
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))
}
milestone, _, err := client.Repositories.GetMilestone(ctx, owner, repo, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/milestone/%v err: %v", owner, repo, id, err))
}
return to.TextResult(slimMilestone(milestone))
}
func listMilestonesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
state := params.GetOptionalString(req.GetArguments(), "state", "all")
name := params.GetOptionalString(req.GetArguments(), "name", "")
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.ListMilestoneOption{
State: gitea_sdk.StateType(state),
Name: name,
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
milestones, _, err := client.Repositories.ListMilestones(ctx, owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/milestones err: %v", owner, repo, err))
}
return to.TextResult(slimMilestones(milestones))
}
func createMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
title, err := params.GetString(req.GetArguments(), "title")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.CreateMilestoneOption{
Title: title,
}
description, ok := req.GetArguments()["description"].(string)
if ok {
opt.Description = description
}
opt.Deadline = params.GetOptionalTime(req.GetArguments(), "due_on")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
milestone, _, err := client.Repositories.CreateMilestone(ctx, owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/milestone err: %v", owner, repo, err))
}
return to.TextResult(slimMilestone(milestone))
}
func editMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
args := req.GetArguments()
opt := gitea_sdk.EditMilestoneOption{
Description: params.GetPresentStringPtr(args, "description"),
Deadline: params.GetOptionalTime(args, "due_on"),
}
if title, ok := args["title"].(string); ok {
opt.Title = title
}
if state, ok := args["state"].(string); ok {
s := gitea_sdk.StateType(state)
opt.State = &s
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
milestone, _, err := client.Repositories.EditMilestone(ctx, owner, repo, id, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/milestone/%v err: %v", owner, repo, id, err))
}
return to.TextResult(slimMilestone(milestone))
}
func deleteMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
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))
}
_, err = client.Repositories.DeleteMilestone(ctx, owner, repo, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete %v/%v/milestone/%v err: %v", owner, repo, id, err))
}
return to.TextResult("Milestone deleted successfully")
}

View File

@@ -0,0 +1,84 @@
package milestone
import (
"context"
"encoding/json"
"fmt"
"maps"
"net/http"
"net/http/httptest"
"sync"
"testing"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/mark3labs/mcp-go/mcp"
)
func Test_milestoneWriteFn_dueOn(t *testing.T) {
const (
owner = "octo"
repo = "demo"
id = 42
due = "2026-05-18T23:59:59Z"
)
var (
mu sync.Mutex
bodies = map[string]map[string]any{}
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/milestones", owner, repo),
fmt.Sprintf("/api/v1/repos/%s/%s/milestones/%d", owner, repo, id):
mu.Lock()
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
bodies[r.Method] = body
mu.Unlock()
_, _ = w.Write(fmt.Appendf(nil, `{"id":%d,"title":"v1","due_on":%q}`, id, due))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
args := map[string]any{"owner": owner, "repo": repo, "due_on": due}
cases := []struct {
name string
fn func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error)
method string
extra map[string]any
}{
{"create", createMilestoneFn, http.MethodPost, map[string]any{"title": "v1"}},
{"edit", editMilestoneFn, http.MethodPatch, map[string]any{"id": float64(id)}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
a := map[string]any{}
maps.Copy(a, args)
maps.Copy(a, tc.extra)
res, err := tc.fn(context.Background(), mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: a}})
if err != nil || res.IsError {
t.Fatalf("%s err=%v result=%v", tc.name, err, res)
}
mu.Lock()
body := bodies[tc.method]
mu.Unlock()
if got, _ := body["due_on"].(string); got != due {
t.Fatalf("%s: expected due_on=%q, got %v (body: %v)", tc.name, due, got, body)
}
})
}
}

View File

@@ -0,0 +1,28 @@
package milestone
import (
gitea_sdk "gitea.dev/sdk"
)
func slimMilestone(m *gitea_sdk.Milestone) map[string]any {
if m == nil {
return nil
}
return map[string]any{
"id": m.ID,
"title": m.Title,
"description": m.Description,
"state": m.State,
"open_issues": m.OpenIssues,
"closed_issues": m.ClosedIssues,
"due_on": m.Deadline,
}
}
func slimMilestones(milestones []*gitea_sdk.Milestone) []map[string]any {
out := make([]map[string]any, 0, len(milestones))
for _, m := range milestones {
out = append(out, slimMilestone(m))
}
return out
}

View File

@@ -0,0 +1,213 @@
package notification
import (
"context"
"fmt"
"time"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk"
"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("Read notifications: list (optionally scoped to a repo) or get a thread by ID."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read notifications")),
mcp.WithString("method", mcp.Required(), mcp.Enum("list", "get")),
mcp.WithString("owner", mcp.Description("scope 'list' to a repo")),
mcp.WithString("repo", mcp.Description("scope 'list' to a repo")),
mcp.WithNumber("id", mcp.Description("thread ID (for 'get')")),
mcp.WithString("status", mcp.Enum("unread", "read", "pinned")),
mcp.WithString("subject_type", mcp.Enum("Issue", "Pull", "Commit", "Repository")),
mcp.WithString("since", mcp.Description("updated after ISO 8601")),
mcp.WithString("before", mcp.Description("updated before ISO 8601")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
NotificationWriteTool = mcp.NewTool(
NotificationWriteToolName,
mcp.WithDescription("Mark a notification or all notifications as read."),
mcp.WithToolAnnotation(annotation.Write("Manage notifications")),
mcp.WithString("method", mcp.Required(), mcp.Enum("mark_read", "mark_all_read")),
mcp.WithNumber("id", mcp.Description("thread ID (for 'mark_read')")),
mcp.WithString("owner", mcp.Description("scope 'mark_all_read' to a repo")),
mcp.WithString("repo", mcp.Description("scope 'mark_all_read' to a repo")),
mcp.WithString("last_read_at", mcp.Description("ISO 8601; 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) {
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.NotificationStatus{gitea_sdk.NotificationStatus(status)}
}
if subjectType, ok := args["subject_type"].(string); ok {
opt.SubjectTypes = []gitea_sdk.NotificationSubjectType{gitea_sdk.NotificationSubjectType(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.Notifications.ListByRepo(ctx, 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.Notifications.List(ctx, 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) {
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.Notifications.GetByID(ctx, 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) {
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.Notifications.MarkReadByID(ctx, 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) {
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.Notifications.MarkReadByRepo(ctx, 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.Notifications.MarkRead(ctx, 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")
}

View File

@@ -0,0 +1,66 @@
package notification
import (
gitea_sdk "gitea.dev/sdk"
)
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
}

139
operation/operation.go Normal file
View File

@@ -0,0 +1,139 @@
package operation
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"gitea.com/gitea/gitea-mcp/operation/actions"
"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/packages"
"gitea.com/gitea/gitea-mcp/operation/pull"
"gitea.com/gitea/gitea-mcp/operation/repo"
"gitea.com/gitea/gitea-mcp/operation/search"
"gitea.com/gitea/gitea-mcp/operation/timetracking"
"gitea.com/gitea/gitea-mcp/operation/user"
"gitea.com/gitea/gitea-mcp/operation/version"
"gitea.com/gitea/gitea-mcp/operation/wiki"
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/tool"
"github.com/mark3labs/mcp-go/server"
)
var (
mcpServer *server.MCPServer
domainTools = []*tool.Tool{
user.Tool, actions.Tool, repo.Tool, notification.Tool, issue.Tool,
label.Tool, milestone.Tool, packages.Tool, pull.Tool, search.Tool,
version.Tool, wiki.Tool, timetracking.Tool,
}
)
func RegisterTool(s *server.MCPServer) {
for _, t := range domainTools {
s.AddTools(t.Tools()...)
}
tool.WarnUnmatchedAllowedTools(domainTools...)
}
// parseAuthToken extracts the token from an Authorization header.
// Supports "Bearer <token>" (case-insensitive per RFC 7235) and
// Gitea-style "token <token>" formats.
// Returns the token and true if valid, empty string and false otherwise.
func parseAuthToken(authHeader string) (string, bool) {
if len(authHeader) > 7 && strings.EqualFold(authHeader[:7], "Bearer ") {
token := strings.TrimSpace(authHeader[7:])
if token != "" {
return token, true
}
}
if len(authHeader) > 6 && strings.EqualFold(authHeader[:6], "token ") {
token := strings.TrimSpace(authHeader[6:])
if token != "" {
return token, true
}
}
return "", false
}
func getContextWithToken(ctx context.Context, r *http.Request) context.Context {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return ctx
}
token, ok := parseAuthToken(authHeader)
if !ok {
return ctx
}
return context.WithValue(ctx, mcpContext.TokenContextKey, token)
}
func Run() error {
mcpServer = newMCPServer(flag.Version)
RegisterTool(mcpServer)
switch flag.Mode {
case "stdio":
if err := server.ServeStdio(
mcpServer,
); err != nil {
return err
}
case "http":
httpServer := server.NewStreamableHTTPServer(
mcpServer,
server.WithLogger(log.Default().Sugar()),
server.WithHeartbeatInterval(30*time.Second),
server.WithHTTPContextFunc(getContextWithToken),
)
log.Infof("Gitea MCP HTTP server listening on :%d", flag.Port)
// Graceful shutdown setup
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
shutdownDone := make(chan struct{})
go func() {
<-sigCh
log.Infof("Shutdown signal received, gracefully stopping HTTP server...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
log.Errorf("HTTP server shutdown error: %v", err)
}
close(shutdownDone)
}()
if err := httpServer.Start(fmt.Sprintf(":%d", flag.Port)); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
<-shutdownDone // Wait for shutdown to finish
default:
return fmt.Errorf("invalid transport type: %s. Must be 'stdio' or 'http'", flag.Mode)
}
return nil
}
func newMCPServer(version string) *server.MCPServer {
return server.NewMCPServer(
"Gitea MCP Server",
version,
server.WithToolCapabilities(true),
server.WithLogging(),
server.WithRecovery(),
)
}

105
operation/operation_test.go Normal file
View File

@@ -0,0 +1,105 @@
package operation
import (
"testing"
)
func TestParseAuthToken(t *testing.T) {
tests := []struct {
name string
header string
wantToken string
wantOK bool
}{
{
name: "valid Bearer token",
header: "Bearer validtoken",
wantToken: "validtoken",
wantOK: true,
},
{
name: "lowercase bearer",
header: "bearer lowercase",
wantToken: "lowercase",
wantOK: true,
},
{
name: "uppercase BEARER",
header: "BEARER uppercase",
wantToken: "uppercase",
wantOK: true,
},
{
name: "token with spaces trimmed",
header: "Bearer spacedToken ",
wantToken: "spacedToken",
wantOK: true,
},
{
name: "bearer with no token",
header: "Bearer ",
wantToken: "",
wantOK: false,
},
{
name: "bearer with only spaces",
header: "Bearer ",
wantToken: "",
wantOK: false,
},
{
name: "missing space after Bearer",
header: "Bearertoken",
wantToken: "",
wantOK: false,
},
{
name: "Gitea token format",
header: "token giteaapitoken",
wantToken: "giteaapitoken",
wantOK: true,
},
{
name: "Gitea Token format capitalized",
header: "Token giteaapitoken",
wantToken: "giteaapitoken",
wantOK: true,
},
{
name: "token with no value",
header: "token ",
wantToken: "",
wantOK: false,
},
{
name: "different auth type",
header: "Basic dXNlcjpwYXNz",
wantToken: "",
wantOK: false,
},
{
name: "empty header",
header: "",
wantToken: "",
wantOK: false,
},
{
name: "bearer token with internal spaces",
header: "Bearer token with spaces",
wantToken: "token with spaces",
wantOK: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotToken, gotOK := parseAuthToken(tt.header)
if gotToken != tt.wantToken {
t.Errorf("parseAuthToken() token = %q, want %q", gotToken, tt.wantToken)
}
if gotOK != tt.wantOK {
t.Errorf("parseAuthToken() ok = %v, want %v", gotOK, tt.wantOK)
}
})
}
}

View File

@@ -0,0 +1,220 @@
package packages
import (
"context"
"fmt"
"net/url"
"strconv"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
PackageReadToolName = "package_read"
PackageWriteToolName = "package_write"
)
var (
PackageReadTool = mcp.NewTool(
PackageReadToolName,
mcp.WithToolAnnotation(annotation.ReadOnly("Read package registry")),
mcp.WithDescription("Read package registry: list packages (one entry per version, filter via 'q'/'type'), list versions, or get a version."),
mcp.WithString("method", mcp.Required(), mcp.Enum("list", "list_versions", "get")),
mcp.WithString("owner", mcp.Required(), mcp.Description("user or org")),
mcp.WithString("type", mcp.Description("container/npm/maven/pypi/cargo/generic; required except 'list'")),
mcp.WithString("name", mcp.Description("slashes auto-encoded; required except 'list'")),
mcp.WithString("version", mcp.Description("for 'get'")),
mcp.WithString("q", mcp.Description("search query")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)),
)
PackageWriteTool = mcp.NewTool(
PackageWriteToolName,
mcp.WithToolAnnotation(annotation.Destructive("Delete a package version")),
mcp.WithDescription("Delete a package version (irreversible)."),
mcp.WithString("method", mcp.Required(), mcp.Enum("delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description("user or org")),
mcp.WithString("type", mcp.Required(), mcp.Description("container/npm/maven/pypi/cargo/generic")),
mcp.WithString("name", mcp.Required(), mcp.Description("slashes auto-encoded")),
mcp.WithString("version", mcp.Required()),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: PackageReadTool,
Handler: packageReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: PackageWriteTool,
Handler: packageWriteFn,
})
}
func packageReadFn(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 listPackagesFn(ctx, req)
case "list_versions":
return listPackageVersionsFn(ctx, req)
case "get":
return getPackageFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func packageWriteFn(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 "delete":
return deletePackageVersionFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
// escapePackageName normalises a package name for use in URL paths. It
// accepts both raw names (my-repo/my-image) and pre-encoded names
// (my-repo%2Fmy-image), decoding first to avoid double-encoding. A literal
// '%' followed by two hex digits in a raw name will be folded into its
// decoded form, but package names typically do not contain '%'.
func escapePackageName(name string) string {
if strings.Contains(name, "%") {
if decoded, err := url.PathUnescape(name); err == nil {
name = decoded
}
}
return url.PathEscape(name)
}
func listPackagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
query := url.Values{}
if typ, ok := args["type"].(string); ok && typ != "" {
query.Set("type", typ)
}
if q, ok := args["q"].(string); ok && q != "" {
query.Set("q", q)
}
page, pageSize := params.GetPagination(args, 30)
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
var result any
_, err = gitea.DoJSON(ctx, "GET", "packages/"+url.PathEscape(owner), query, nil, &result)
if err != nil {
return to.ErrorResult(fmt.Errorf("list packages err: %v", err))
}
return to.TextResult(slimPackages(result))
}
func listPackageVersionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
typ, err := params.GetString(args, "type")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(args, "name")
if err != nil {
return to.ErrorResult(err)
}
query := url.Values{}
page, pageSize := params.GetPagination(args, 30)
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
var result any
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("packages/%s/%s/%s", url.PathEscape(owner), url.PathEscape(typ), escapePackageName(name)), query, nil, &result)
if err != nil {
return to.ErrorResult(fmt.Errorf("list package versions err: %v", err))
}
return to.TextResult(slimPackages(result))
}
func getPackageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
typ, err := params.GetString(args, "type")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(args, "name")
if err != nil {
return to.ErrorResult(err)
}
version, err := params.GetString(args, "version")
if err != nil {
return to.ErrorResult(err)
}
var result any
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("packages/%s/%s/%s/%s", url.PathEscape(owner), url.PathEscape(typ), escapePackageName(name), url.PathEscape(version)), nil, nil, &result)
if err != nil {
return to.ErrorResult(fmt.Errorf("get package err: %v", err))
}
return to.TextResult(slimPackage(result))
}
func deletePackageVersionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
typ, err := params.GetString(args, "type")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(args, "name")
if err != nil {
return to.ErrorResult(err)
}
version, err := params.GetString(args, "version")
if err != nil {
return to.ErrorResult(err)
}
_, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("packages/%s/%s/%s/%s", url.PathEscape(owner), url.PathEscape(typ), escapePackageName(name), url.PathEscape(version)), nil, nil, nil)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete package version err: %v", err))
}
return to.TextResult("Package version deleted successfully")
}

View File

@@ -0,0 +1,381 @@
package packages
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/mark3labs/mcp-go/mcp"
)
func TestPackageReadList(t *testing.T) {
var mu sync.Mutex
var gotQuery map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
gotQuery = map[string]string{}
for k, v := range r.URL.Query() {
gotQuery[k] = v[0]
}
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"id":1,"type":"container","name":"myrepo/myimage","version":"v1.0.0","html_url":"http://example.com","created_at":"2025-01-01T00:00:00Z","owner":{"login":"test-org"},"creator":{"login":"admin"}}]`))
}))
defer srv.Close()
origHost := flag.Host
flag.Host = srv.URL
defer func() { flag.Host = origHost }()
ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token")
t.Run("basic list", func(t *testing.T) {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]any{
"method": "list",
"owner": "test-org",
}
result, err := packageReadFn(ctx, req)
if err != nil {
t.Fatalf("packageReadFn() error: %v", err)
}
if result.IsError {
t.Fatal("packageReadFn() returned error result")
}
text := result.Content[0].(mcp.TextContent).Text
var packages []map[string]any
if err := json.Unmarshal([]byte(text), &packages); err != nil {
t.Fatalf("failed to unmarshal result: %v", err)
}
if len(packages) != 1 {
t.Fatalf("expected 1 package, got %d", len(packages))
}
if packages[0]["name"] != "myrepo/myimage" {
t.Errorf("expected name 'myrepo/myimage', got %v", packages[0]["name"])
}
if packages[0]["owner"] != "test-org" {
t.Errorf("expected owner 'test-org', got %v", packages[0]["owner"])
}
})
t.Run("with type and query filters", func(t *testing.T) {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]any{
"method": "list",
"owner": "test-org",
"type": "container",
"q": "myimage",
}
_, err := packageReadFn(ctx, req)
if err != nil {
t.Fatalf("packageReadFn() error: %v", err)
}
mu.Lock()
defer mu.Unlock()
if gotQuery["type"] != "container" {
t.Errorf("expected type=container, got %q", gotQuery["type"])
}
if gotQuery["q"] != "myimage" {
t.Errorf("expected q=myimage, got %q", gotQuery["q"])
}
})
t.Run("with pagination", func(t *testing.T) {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]any{
"method": "list",
"owner": "test-org",
"page": float64(2),
"per_page": float64(10),
}
_, err := packageReadFn(ctx, req)
if err != nil {
t.Fatalf("packageReadFn() error: %v", err)
}
mu.Lock()
defer mu.Unlock()
if gotQuery["page"] != "2" {
t.Errorf("expected page=2, got %q", gotQuery["page"])
}
if gotQuery["limit"] != "10" {
t.Errorf("expected limit=10, got %q", gotQuery["limit"])
}
})
}
func TestPackageReadListVersions(t *testing.T) {
var mu sync.Mutex
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
gotPath = r.URL.RawPath
if gotPath == "" {
gotPath = r.URL.Path
}
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"id":1,"type":"container","name":"myrepo/myimage","version":"v1.0.0"},{"id":2,"type":"container","name":"myrepo/myimage","version":"v2.0.0"}]`))
}))
defer srv.Close()
origHost := flag.Host
flag.Host = srv.URL
defer func() { flag.Host = origHost }()
ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token")
tests := []struct {
testName string
name string
}{
{"raw slash", "myrepo/myimage"},
{"pre-encoded slash", "myrepo%2Fmyimage"},
}
for _, tt := range tests {
t.Run(tt.testName, func(t *testing.T) {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]any{
"method": "list_versions",
"owner": "test-org",
"type": "container",
"name": tt.name,
}
result, err := packageReadFn(ctx, req)
if err != nil {
t.Fatalf("packageReadFn() error: %v", err)
}
if result.IsError {
t.Fatal("packageReadFn() returned error result")
}
mu.Lock()
wantPath := "/api/v1/packages/test-org/container/myrepo%2Fmyimage"
if gotPath != wantPath {
t.Errorf("request path = %q, want %q", gotPath, wantPath)
}
mu.Unlock()
text := result.Content[0].(mcp.TextContent).Text
var versions []map[string]any
if err := json.Unmarshal([]byte(text), &versions); err != nil {
t.Fatalf("failed to unmarshal result: %v", err)
}
if len(versions) != 2 {
t.Fatalf("expected 2 versions, got %d", len(versions))
}
})
}
}
func TestPackageReadGet(t *testing.T) {
var mu sync.Mutex
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
gotPath = r.URL.RawPath
if gotPath == "" {
gotPath = r.URL.Path
}
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":1,"type":"container","name":"myrepo/myimage","version":"v1.0.0","html_url":"http://example.com","created_at":"2025-01-01T00:00:00Z","owner":{"login":"test-org"}}`))
}))
defer srv.Close()
origHost := flag.Host
flag.Host = srv.URL
defer func() { flag.Host = origHost }()
ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token")
tests := []struct {
testName string
name string
}{
{"raw slash", "myrepo/myimage"},
{"pre-encoded slash", "myrepo%2Fmyimage"},
}
for _, tt := range tests {
t.Run(tt.testName, func(t *testing.T) {
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]any{
"method": "get",
"owner": "test-org",
"type": "container",
"name": tt.name,
"version": "v1.0.0",
}
result, err := packageReadFn(ctx, req)
if err != nil {
t.Fatalf("packageReadFn() error: %v", err)
}
if result.IsError {
t.Fatal("packageReadFn() returned error result")
}
mu.Lock()
wantPath := "/api/v1/packages/test-org/container/myrepo%2Fmyimage/v1.0.0"
if gotPath != wantPath {
t.Errorf("request path = %q, want %q", gotPath, wantPath)
}
mu.Unlock()
text := result.Content[0].(mcp.TextContent).Text
var pkg map[string]any
if err := json.Unmarshal([]byte(text), &pkg); err != nil {
t.Fatalf("failed to unmarshal result: %v", err)
}
if pkg["name"] != "myrepo/myimage" {
t.Errorf("expected name 'myrepo/myimage', got %v", pkg["name"])
}
if pkg["version"] != "v1.0.0" {
t.Errorf("expected version 'v1.0.0', got %v", pkg["version"])
}
})
}
}
func TestPackageWriteDelete(t *testing.T) {
var mu sync.Mutex
var gotMethod string
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
gotMethod = r.Method
gotPath = r.URL.RawPath
if gotPath == "" {
gotPath = r.URL.Path
}
mu.Unlock()
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
origHost := flag.Host
flag.Host = srv.URL
defer func() { flag.Host = origHost }()
ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token")
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]any{
"method": "delete",
"owner": "test-org",
"type": "container",
"name": "myrepo/myimage",
"version": "v1.0.0",
}
result, err := packageWriteFn(ctx, req)
if err != nil {
t.Fatalf("packageWriteFn() error: %v", err)
}
if result.IsError {
t.Fatal("packageWriteFn() returned error result")
}
mu.Lock()
defer mu.Unlock()
if gotMethod != "DELETE" {
t.Errorf("expected DELETE method, got %q", gotMethod)
}
wantPath := "/api/v1/packages/test-org/container/myrepo%2Fmyimage/v1.0.0"
if gotPath != wantPath {
t.Errorf("request path = %q, want %q", gotPath, wantPath)
}
}
func TestPackageReadUnknownMethod(t *testing.T) {
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]any{
"method": "bogus",
"owner": "test-org",
}
if _, err := packageReadFn(ctx, req); err == nil {
t.Fatal("expected error for unknown method")
}
}
func TestPackageWriteUnknownMethod(t *testing.T) {
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]any{
"method": "bogus",
"owner": "test-org",
}
if _, err := packageWriteFn(ctx, req); err == nil {
t.Fatal("expected error for unknown method")
}
}
func TestEscapePackageName(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"simple name", "mypackage", "mypackage"},
{"raw slash", "myrepo/myimage", "myrepo%2Fmyimage"},
{"pre-encoded slash", "myrepo%2Fmyimage", "myrepo%2Fmyimage"},
{"pre-encoded uppercase", "myrepo%2Fmyimage", "myrepo%2Fmyimage"},
{"multiple slashes", "a/b/c", "a%2Fb%2Fc"},
{"pre-encoded multiple slashes", "a%2Fb%2Fc", "a%2Fb%2Fc"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := escapePackageName(tt.input)
if got != tt.expected {
t.Errorf("escapePackageName(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
func TestSlimPackage(t *testing.T) {
raw := map[string]any{
"id": float64(1),
"type": "container",
"name": "test-repo/test-image",
"version": "v1.0.0",
"html_url": "http://example.com/pkg",
"created_at": "2025-01-01T00:00:00Z",
"owner": map[string]any{"login": "test-org", "id": float64(2), "email": ""},
"creator": map[string]any{"login": "admin", "id": float64(1)},
"repository": map[string]any{"full_name": "test-org/test-repo", "id": float64(3)},
}
slim := slimPackage(raw)
if slim["owner"] != "test-org" {
t.Errorf("expected owner 'test-org', got %v", slim["owner"])
}
if slim["creator"] != "admin" {
t.Errorf("expected creator 'admin', got %v", slim["creator"])
}
if slim["repository"] != "test-org/test-repo" {
t.Errorf("expected repository 'test-org/test-repo', got %v", slim["repository"])
}
if _, ok := slim["owner"].(map[string]any); ok {
t.Error("expected owner to be a string, not a map")
}
}

View File

@@ -0,0 +1,43 @@
package packages
func slimPackage(v any) map[string]any {
m, ok := v.(map[string]any)
if !ok {
return nil
}
out := map[string]any{
"id": m["id"],
"type": m["type"],
"name": m["name"],
"version": m["version"],
"html_url": m["html_url"],
"created_at": m["created_at"],
}
if owner, ok := m["owner"].(map[string]any); ok {
out["owner"] = owner["login"]
}
if creator, ok := m["creator"].(map[string]any); ok {
out["creator"] = creator["login"]
}
if repo, ok := m["repository"].(map[string]any); ok {
out["repository"] = repo["full_name"]
}
return out
}
func slimPackages(v any) any {
switch val := v.(type) {
case []any:
out := make([]map[string]any, 0, len(val))
for _, item := range val {
if slim := slimPackage(item); slim != nil {
out = append(out, slim)
}
}
return out
case map[string]any:
return slimPackage(val)
default:
return v
}
}

997
operation/pull/pull.go Normal file
View File

@@ -0,0 +1,997 @@
package pull
import (
"context"
"fmt"
"net/url"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"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/slim"
"gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
ListRepoPullRequestsToolName = "list_pull_requests"
PullRequestReadToolName = "pull_request_read"
PullRequestWriteToolName = "pull_request_write"
PullRequestReviewWriteToolName = "pull_request_review_write"
)
var (
ListRepoPullRequestsTool = mcp.NewTool(
ListRepoPullRequestsToolName,
mcp.WithToolAnnotation(annotation.ReadOnly("List pull requests")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("state", mcp.Enum("open", "closed", "all"), mcp.DefaultString("all")),
mcp.WithString("sort", mcp.Enum("oldest", "recentupdate", "leastupdate", "mostcomment", "leastcomment", "priority"), mcp.DefaultString("recentupdate")),
mcp.WithNumber("milestone"),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
PullRequestReadTool = mcp.NewTool(
PullRequestReadToolName,
mcp.WithDescription("Read pull request: details, diff, changed files, head commit status, reviews."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read pull request details")),
mcp.WithString("method", mcp.Required(), mcp.Enum("get", "get_diff", "get_files", "get_status", "get_reviews", "get_review", "get_review_comments")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("pull_number", mcp.Required()),
mcp.WithNumber("review_id", mcp.Description("for 'get_review'/'get_review_comments'")),
mcp.WithBoolean("binary", mcp.Description("include binary diff")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
PullRequestWriteTool = mcp.NewTool(
PullRequestWriteToolName,
mcp.WithDescription("Write pull requests: create, update, close, reopen, merge, update branch from base, manage reviewers."),
mcp.WithToolAnnotation(annotation.Write("Create, update, close, reopen, or merge pull requests")),
mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "close", "reopen", "merge", "update_branch", "add_reviewers", "remove_reviewers")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("pull_number", mcp.Description("required except for 'create'")),
mcp.WithString("title", mcp.Description("required for 'create'; optional for 'update'/'merge'")),
mcp.WithString("body", mcp.Description("required for 'create'; optional for 'update'")),
mcp.WithString("head", mcp.Description("head branch (required for 'create')")),
mcp.WithString("base", mcp.Description("base branch (required for 'create')")),
mcp.WithString("assignee", mcp.Description("for 'update'")),
mcp.WithArray("assignees", mcp.Description("for 'update'"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithNumber("milestone", mcp.Description("for 'update'")),
mcp.WithString("state", mcp.Description("for 'update'"), mcp.Enum("open", "closed")),
mcp.WithBoolean("allow_maintainer_edit", mcp.Description("for 'update'")),
mcp.WithArray("labels", mcp.Description("label IDs"), mcp.Items(map[string]any{"type": "number"})),
mcp.WithString("deadline", mcp.Description("ISO 8601")),
mcp.WithBoolean("remove_deadline", mcp.Description("for 'update'")),
mcp.WithString("merge_style", mcp.Description("for 'merge'"), mcp.Enum("merge", "rebase", "rebase-merge", "squash", "fast-forward-only"), mcp.DefaultString("merge")),
mcp.WithString("message", mcp.Description("merge commit message or dismissal reason")),
mcp.WithBoolean("delete_branch", mcp.Description("for 'merge'")),
mcp.WithBoolean("force_merge", mcp.Description("merge even if checks fail")),
mcp.WithBoolean("merge_when_checks_succeed", mcp.Description("for 'merge'")),
mcp.WithString("head_commit_id", mcp.Description("expected head SHA for conflict detection")),
mcp.WithArray("reviewers", mcp.Description("for 'add_reviewers'/'remove_reviewers'"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithArray("team_reviewers", mcp.Description("for 'add_reviewers'/'remove_reviewers'"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithBoolean("draft", mcp.Description("uses 'WIP: ' title prefix")),
)
PullRequestReviewWriteTool = mcp.NewTool(
PullRequestReviewWriteToolName,
mcp.WithDescription("Write PR reviews: create, submit, delete, dismiss."),
mcp.WithToolAnnotation(annotation.Write("Submit a pull request review")),
mcp.WithString("method", mcp.Required(), mcp.Enum("create", "submit", "delete", "dismiss")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("pull_number", mcp.Required()),
mcp.WithNumber("review_id", mcp.Description("required except for 'create'")),
mcp.WithString("state", mcp.Enum("APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING")),
mcp.WithString("body"),
mcp.WithString("commit_id", mcp.Description("for 'create'")),
mcp.WithString("message", mcp.Description("dismissal reason")),
mcp.WithArray("comments", mcp.Description("inline comments (for 'create')"), mcp.Items(map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string"},
"body": map[string]any{"type": "string"},
"old_line_num": map[string]any{"type": "number", "description": "old-file line (deletions)"},
"new_line_num": map[string]any{"type": "number", "description": "new-file line (additions)"},
},
})),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListRepoPullRequestsTool,
Handler: listRepoPullRequestsFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: PullRequestReadTool,
Handler: pullRequestReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: PullRequestWriteTool,
Handler: pullRequestWriteFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: PullRequestReviewWriteTool,
Handler: pullRequestReviewWriteFn,
})
}
func pullRequestReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "get":
return getPullRequestByIndexFn(ctx, req)
case "get_diff":
return getPullRequestDiffFn(ctx, req)
case "get_files":
return getPullRequestFilesFn(ctx, req)
case "get_status":
return getPullRequestStatusFn(ctx, req)
case "get_reviews":
return listPullRequestReviewsFn(ctx, req)
case "get_review":
return getPullRequestReviewFn(ctx, req)
case "get_review_comments":
return listPullRequestReviewCommentsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func pullRequestWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createPullRequestFn(ctx, req)
case "update":
return editPullRequestFn(ctx, req)
case "close":
return closePullRequestFn(ctx, req)
case "reopen":
return reopenPullRequestFn(ctx, req)
case "merge":
return mergePullRequestFn(ctx, req)
case "update_branch":
return updatePullRequestBranchFn(ctx, req)
case "add_reviewers":
return createPullRequestReviewerFn(ctx, req)
case "remove_reviewers":
return deletePullRequestReviewerFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func closePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "pull_number")
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))
}
state := gitea_sdk.StateClosed
pr, _, err := client.PullRequests.EditPullRequest(ctx, owner, repo, index, gitea_sdk.EditPullRequestOption{
State: &state,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("close %v/%v/pr/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimPullRequest(pr))
}
func reopenPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "pull_number")
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))
}
state := gitea_sdk.StateOpen
pr, _, err := client.PullRequests.EditPullRequest(ctx, owner, repo, index, gitea_sdk.EditPullRequestOption{
State: &state,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("reopen %v/%v/pr/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimPullRequest(pr))
}
func pullRequestReviewWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createPullRequestReviewFn(ctx, req)
case "submit":
return submitPullRequestReviewFn(ctx, req)
case "delete":
return deletePullRequestReviewFn(ctx, req)
case "dismiss":
return dismissPullRequestReviewFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func getPullRequestByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
index, err := params.GetIndex(args, "pull_number")
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))
}
pr, _, err := client.PullRequests.GetPullRequest(ctx, owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v err: %v", owner, repo, index, err))
}
// /pulls/{n} omits `assets`; PRs are issues internally, so the issue
// assets endpoint surfaces description attachments.
var assets []*gitea_sdk.Attachment
assetsPath := fmt.Sprintf("repos/%s/%s/issues/%d/assets", url.PathEscape(owner), url.PathEscape(repo), index)
if _, err := gitea.DoJSON(ctx, "GET", assetsPath, nil, nil, &assets); err != nil {
log.Debugf("fetch %v/%v/issues/%v/assets err: %v", owner, repo, index, err)
}
m := slimPullRequest(pr)
m["body"] = slim.BodyWithAttachments(pr.Body, assets)
return to.TextResult(m)
}
func getPullRequestDiffFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
binary, _ := args["binary"].(bool)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
diffBytes, _, err := client.PullRequests.GetPullRequestDiff(ctx, owner, repo, index, gitea_sdk.PullRequestDiffOptions{
Binary: binary,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v diff err: %v", owner, repo, index, err))
}
return to.TextResult(string(diffBytes))
}
func listRepoPullRequestsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
state, _ := args["state"].(string)
sort := params.GetOptionalString(args, "sort", "recentupdate")
milestone := params.GetOptionalInt(args, "milestone", 0)
page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListPullRequestsOptions{
State: gitea_sdk.StateType(state),
Sort: sort,
Milestone: milestone,
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
pullRequests, _, err := client.PullRequests.ListRepoPullRequests(ctx, owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list %v/%v/pull_requests err: %v", owner, repo, err))
}
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) {
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)
}
body, err := params.GetString(args, "body")
if err != nil {
return to.ErrorResult(err)
}
head, err := params.GetString(args, "head")
if err != nil {
return to.ErrorResult(err)
}
base, err := params.GetString(args, "base")
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))
}
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.PullRequests.CreatePullRequest(ctx, owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/pull_request err: %v", owner, repo, err))
}
return to.TextResult(slimPullRequest(pr))
}
type reviewerOp func(client *gitea_sdk.PullRequestsService, ctx context.Context, owner, repo string, index int64, opt gitea_sdk.PullReviewRequestOptions) (*gitea_sdk.Response, error)
func pullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest, verb string, op reviewerOp) (*mcp.CallToolResult, error) {
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)
}
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
reviewers := params.GetStringSlice(args, "reviewers")
teamReviewers := params.GetStringSlice(args, "team_reviewers")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
if _, err := op(client.PullRequests, ctx, owner, repo, index, gitea_sdk.PullReviewRequestOptions{
Reviewers: reviewers,
TeamReviewers: teamReviewers,
}); err != nil {
return to.ErrorResult(fmt.Errorf("%s review requests for %v/%v/pr/%v err: %v", verb, owner, repo, index, err))
}
return to.TextResult(map[string]any{
"message": fmt.Sprintf("Successfully %sd review requests", verb),
"reviewers": reviewers,
"team_reviewers": teamReviewers,
"pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo),
})
}
func createPullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return pullRequestReviewerFn(ctx, req, "create", (*gitea_sdk.PullRequestsService).CreateReviewRequests)
}
func deletePullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return pullRequestReviewerFn(ctx, req, "delete", (*gitea_sdk.PullRequestsService).DeleteReviewRequests)
}
func listPullRequestReviewsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
index, err := params.GetIndex(args, "pull_number")
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))
}
reviews, _, err := client.PullRequests.ListPullReviews(ctx, owner, repo, index, gitea_sdk.ListPullReviewsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list reviews for %v/%v/pr/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimReviews(reviews))
}
func getPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
reviewID, err := params.GetIndex(args, "review_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))
}
review, _, err := client.PullRequests.GetPullReview(ctx, owner, repo, index, reviewID)
if err != nil {
return to.ErrorResult(fmt.Errorf("get review %v for %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err))
}
return to.TextResult(slimReview(review))
}
func listPullRequestReviewCommentsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
reviewID, err := params.GetIndex(args, "review_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))
}
comments, _, err := client.PullRequests.ListPullReviewComments(ctx, owner, repo, index, reviewID)
if err != nil {
return to.ErrorResult(fmt.Errorf("list review comments for review %v on %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err))
}
return to.TextResult(slimReviewComments(comments))
}
func createPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.CreatePullReviewOptions{}
if state, ok := args["state"].(string); ok {
opt.State = gitea_sdk.ReviewStateType(state)
}
if body, ok := args["body"].(string); ok {
opt.Body = body
}
if commitID, ok := args["commit_id"].(string); ok {
opt.CommitID = commitID
}
// Parse inline comments
if commentsArg, exists := args["comments"]; exists {
if commentsSlice, ok := commentsArg.([]any); ok {
for _, comment := range commentsSlice {
if commentMap, ok := comment.(map[string]any); ok {
reviewComment := gitea_sdk.CreatePullReviewComment{}
if path, ok := commentMap["path"].(string); ok {
reviewComment.Path = path
}
if body, ok := commentMap["body"].(string); ok {
reviewComment.Body = body
}
if oldLineNum, ok := params.ToInt64(commentMap["old_line_num"]); ok {
reviewComment.OldLineNum = oldLineNum
}
if newLineNum, ok := params.ToInt64(commentMap["new_line_num"]); ok {
reviewComment.NewLineNum = newLineNum
}
opt.Comments = append(opt.Comments, reviewComment)
}
}
}
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
review, _, err := client.PullRequests.CreatePullReview(ctx, owner, repo, index, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create review for %v/%v/pr/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimReview(review))
}
func submitPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
reviewID, err := params.GetIndex(args, "review_id")
if err != nil {
return to.ErrorResult(err)
}
state, err := params.GetString(args, "state")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.SubmitPullReviewOptions{
State: gitea_sdk.ReviewStateType(state),
}
if body, ok := args["body"].(string); ok {
opt.Body = body
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
review, _, err := client.PullRequests.SubmitPullReview(ctx, owner, repo, index, reviewID, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("submit review %v for %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err))
}
return to.TextResult(slimReview(review))
}
func deletePullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
reviewID, err := params.GetIndex(args, "review_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.PullRequests.DeletePullReview(ctx, owner, repo, index, reviewID)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete review %v for %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err))
}
successMsg := map[string]any{
"message": "Successfully deleted review",
"review_id": reviewID,
"pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo),
}
return to.TextResult(successMsg)
}
func dismissPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
reviewID, err := params.GetIndex(args, "review_id")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.DismissPullReviewOptions{}
if message, ok := args["message"].(string); ok {
opt.Message = message
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.PullRequests.DismissPullReview(ctx, owner, repo, index, reviewID, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("dismiss review %v for %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err))
}
successMsg := map[string]any{
"message": "Successfully dismissed review",
"review_id": reviewID,
"pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo),
}
return to.TextResult(successMsg)
}
func mergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
mergeStyle := params.GetOptionalString(args, "merge_style", "merge")
title, _ := args["title"].(string)
message, _ := args["message"].(string)
deleteBranch, _ := args["delete_branch"].(bool)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
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)
deleteBranchAfterMerge := &deleteBranch
opt := gitea_sdk.MergePullRequestOption{
Style: gitea_sdk.MergeStyle(mergeStyle),
Title: title,
Message: message,
DeleteBranchAfterMerge: deleteBranchAfterMerge,
ForceMerge: forceMerge,
MergeWhenChecksSucceed: mergeWhenChecksSucceed,
HeadCommitId: headCommitID,
}
merged, resp, err := client.PullRequests.MergePullRequest(ctx, owner, repo, index, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("merge %v/%v/pr/%v err: %v", owner, repo, index, err))
}
if !merged && resp != nil && resp.StatusCode >= 400 {
return to.ErrorResult(fmt.Errorf("merge %v/%v/pr/%v failed: HTTP %d %s", owner, repo, index, resp.StatusCode, resp.Status))
}
if !merged {
return to.ErrorResult(fmt.Errorf("merge %v/%v/pr/%v returned merged=false", owner, repo, index))
}
successMsg := map[string]any{
"merged": merged,
"pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo),
"merge_style": mergeStyle,
"branch_deleted": deleteBranch,
}
return to.TextResult(successMsg)
}
func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.EditPullRequestOption{}
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.PullRequests.GetPullRequest(ctx, 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)
}
opt.Body = params.GetPresentStringPtr(args, "body")
opt.AllowMaintainerEdit = params.GetOptionalBoolPtr(args, "allow_maintainer_edit")
opt.RemoveDeadline = params.GetOptionalBoolPtr(args, "remove_deadline")
opt.Deadline = params.GetOptionalTime(args, "deadline")
if base, ok := args["base"].(string); ok {
opt.Base = base
}
if assignee, ok := args["assignee"].(string); ok {
opt.Assignee = assignee
}
if assignees := params.GetStringSlice(args, "assignees"); assignees != nil {
opt.Assignees = assignees
}
if val, exists := args["milestone"]; exists {
if milestone, ok := params.ToInt64(val); ok {
opt.Milestone = milestone
}
}
if state, ok := args["state"].(string); ok {
s := gitea_sdk.StateType(state)
opt.State = &s
}
if labelIDs, err := params.GetInt64Slice(args, "labels"); err == nil {
opt.Labels = labelIDs
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
pr, _, err := client.PullRequests.EditPullRequest(ctx, owner, repo, index, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/pr/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimPullRequest(pr))
}
func updatePullRequestBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
path := fmt.Sprintf("repos/%s/%s/pulls/%d/update", url.PathEscape(owner), url.PathEscape(repo), index)
if _, err := gitea.DoJSON(ctx, "POST", path, nil, nil, nil); err != nil {
return to.ErrorResult(fmt.Errorf("update %v/%v/pr/%v branch err: %v", owner, repo, index, err))
}
return to.TextResult(map[string]any{"message": "branch updated from base"})
}
func getPullRequestFilesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
index, err := params.GetIndex(args, "pull_number")
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.PullRequests.ListPullRequestFiles(ctx, owner, repo, index, gitea_sdk.ListPullRequestFilesOptions{
ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v files err: %v", owner, repo, index, err))
}
return to.TextResult(files)
}
func getPullRequestStatusFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
index, err := params.GetIndex(args, "pull_number")
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))
}
pr, _, err := client.PullRequests.GetPullRequest(ctx, owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v err: %v", owner, repo, index, err))
}
if pr.Head == nil || pr.Head.Sha == "" {
return to.ErrorResult(fmt.Errorf("pr %v/%v/%v has no head SHA", owner, repo, index))
}
status, _, err := client.Repositories.GetCombinedStatus(ctx, owner, repo, pr.Head.Sha)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v status err: %v", owner, repo, index, err))
}
return to.TextResult(status)
}

1044
operation/pull/pull_test.go Normal file

File diff suppressed because it is too large Load Diff

160
operation/pull/slim.go Normal file
View File

@@ -0,0 +1,160 @@
package pull
import (
"gitea.com/gitea/gitea-mcp/pkg/slim"
gitea_sdk "gitea.dev/sdk"
)
func repoRef(r *gitea_sdk.Repository) map[string]any {
if r == nil {
return nil
}
return map[string]any{
"full_name": r.FullName,
"description": r.Description,
}
}
func slimPullRequest(pr *gitea_sdk.PullRequest) map[string]any {
if pr == nil {
return nil
}
m := map[string]any{
"number": pr.Index,
"title": pr.Title,
"body": pr.Body,
"state": pr.State,
"draft": pr.Draft,
"merged": pr.HasMerged,
"mergeable": pr.Mergeable,
"html_url": pr.HTMLURL,
"user": slim.UserLogin(pr.Poster),
"labels": slim.LabelNames(pr.Labels),
"comments": pr.Comments,
"created_at": pr.Created,
"updated_at": pr.Updated,
"closed_at": pr.Closed,
}
if pr.HasMerged {
m["merged_at"] = pr.Merged
m["merge_commit_sha"] = pr.MergedCommitID
m["merged_by"] = slim.UserLogin(pr.MergedBy)
}
if pr.Head != nil {
head := map[string]any{"ref": pr.Head.Ref, "sha": pr.Head.Sha}
if pr.Head.Repository != nil {
head["repo"] = repoRef(pr.Head.Repository)
}
m["head"] = head
}
if pr.Base != nil {
base := map[string]any{"ref": pr.Base.Ref, "sha": pr.Base.Sha}
if pr.Base.Repository != nil {
base["repo"] = repoRef(pr.Base.Repository)
}
m["base"] = base
}
if pr.Additions != nil {
m["additions"] = *pr.Additions
}
if pr.Deletions != nil {
m["deletions"] = *pr.Deletions
}
if pr.ChangedFiles != nil {
m["changed_files"] = *pr.ChangedFiles
}
if len(pr.Assignees) > 0 {
m["assignees"] = slim.UserLogins(pr.Assignees)
}
if pr.Milestone != nil {
m["milestone"] = pr.Milestone.Title
}
if pr.ReviewComments > 0 {
m["review_comments"] = pr.ReviewComments
}
return m
}
func slimPullRequests(prs []*gitea_sdk.PullRequest) []map[string]any {
out := make([]map[string]any, 0, len(prs))
for _, pr := range prs {
if pr == nil {
continue
}
m := map[string]any{
"number": pr.Index,
"title": pr.Title,
"state": pr.State,
"draft": pr.Draft,
"merged": pr.HasMerged,
"html_url": pr.HTMLURL,
"user": slim.UserLogin(pr.Poster),
"created_at": pr.Created,
"updated_at": pr.Updated,
}
if pr.Head != nil {
m["head"] = pr.Head.Ref
}
if pr.Base != nil {
m["base"] = pr.Base.Ref
}
if len(pr.Labels) > 0 {
m["labels"] = slim.LabelNames(pr.Labels)
}
out = append(out, m)
}
return out
}
func slimReview(r *gitea_sdk.PullReview) map[string]any {
if r == nil {
return nil
}
return map[string]any{
"id": r.ID,
"state": r.State,
"body": r.Body,
"user": slim.UserLogin(r.Reviewer),
"comments_count": r.CodeCommentsCount,
"submitted_at": r.Submitted,
"html_url": r.HTMLURL,
"stale": r.Stale,
"official": r.Official,
"dismissed": r.Dismissed,
}
}
func slimReviews(reviews []*gitea_sdk.PullReview) []map[string]any {
out := make([]map[string]any, 0, len(reviews))
for _, r := range reviews {
out = append(out, slimReview(r))
}
return out
}
func slimReviewComment(c *gitea_sdk.PullReviewComment) map[string]any {
if c == nil {
return nil
}
return map[string]any{
"id": c.ID,
"body": c.Body,
"path": c.Path,
"position": c.LineNum,
"old_position": c.OldLineNum,
"diff_hunk": c.DiffHunk,
"user": slim.UserLogin(c.Reviewer),
"html_url": c.HTMLURL,
"created_at": c.Created,
"updated_at": c.Updated,
}
}
func slimReviewComments(comments []*gitea_sdk.PullReviewComment) []map[string]any {
out := make([]map[string]any, 0, len(comments))
for _, c := range comments {
out = append(out, slimReviewComment(c))
}
return out
}

124
operation/pull/slim_test.go Normal file
View File

@@ -0,0 +1,124 @@
package pull
import (
"testing"
"time"
gitea_sdk "gitea.dev/sdk"
)
func TestSlimPullRequest(t *testing.T) {
now := time.Now()
additions := 10
deletions := 5
changedFiles := 3
pr := &gitea_sdk.PullRequest{
Index: 1,
Title: "Fix bug",
Body: "Fixes #123",
State: "open",
Draft: false,
HasMerged: false,
Mergeable: true,
HTMLURL: "https://gitea.com/org/repo/pulls/1",
Poster: &gitea_sdk.User{UserName: "bob"},
Labels: []*gitea_sdk.Label{
{Name: "bug"},
{Name: "priority"},
},
Comments: 2,
Created: &now,
Updated: &now,
Additions: &additions,
Deletions: &deletions,
ChangedFiles: &changedFiles,
Head: &gitea_sdk.PRBranchInfo{
Ref: "fix-branch",
Sha: "abc123",
},
Base: &gitea_sdk.PRBranchInfo{
Ref: "main",
Sha: "def456",
},
Assignees: []*gitea_sdk.User{
{UserName: "alice"},
},
Milestone: &gitea_sdk.Milestone{Title: "v1.0"},
}
m := slimPullRequest(pr)
if m["number"] != int64(1) {
t.Errorf("expected number 1, got %v", m["number"])
}
if m["title"] != "Fix bug" {
t.Errorf("expected title Fix bug, got %v", m["title"])
}
if m["user"] != "bob" {
t.Errorf("expected user bob, got %v", m["user"])
}
if m["additions"] != 10 {
t.Errorf("expected additions 10, got %v", m["additions"])
}
if m["milestone"] != "v1.0" {
t.Errorf("expected milestone v1.0, got %v", m["milestone"])
}
labels := m["labels"].([]string)
if len(labels) != 2 || labels[0] != "bug" {
t.Errorf("expected labels [bug priority], got %v", labels)
}
head := m["head"].(map[string]any)
if head["ref"] != "fix-branch" {
t.Errorf("expected head ref fix-branch, got %v", head["ref"])
}
assignees := m["assignees"].([]string)
if len(assignees) != 1 || assignees[0] != "alice" {
t.Errorf("expected assignees [alice], got %v", assignees)
}
// merged fields should not be present for unmerged PR
if _, ok := m["merged_at"]; ok {
t.Error("merged_at should not be present for unmerged PR")
}
}
func TestSlimPullRequests_ListIsSlimmer(t *testing.T) {
pr := &gitea_sdk.PullRequest{
Index: 1,
Title: "PR title",
State: "open",
HTMLURL: "https://gitea.com/org/repo/pulls/1",
Poster: &gitea_sdk.User{UserName: "bob"},
Body: "Full body text here",
Head: &gitea_sdk.PRBranchInfo{Ref: "feature"},
Base: &gitea_sdk.PRBranchInfo{Ref: "main"},
}
single := slimPullRequest(pr)
list := slimPullRequests([]*gitea_sdk.PullRequest{pr})
// Single has body, list does not
if _, ok := single["body"]; !ok {
t.Error("single PR should have body")
}
if _, ok := list[0]["body"]; ok {
t.Error("list PR should not have body")
}
// List has head as string ref, single has head as map
if _, ok := single["head"].(map[string]any); !ok {
t.Error("single PR head should be a map")
}
if list[0]["head"] != "feature" {
t.Errorf("list PR head should be string ref, got %v", list[0]["head"])
}
}
func TestSlimPullRequests_Nil(t *testing.T) {
if r := slimPullRequests(nil); len(r) != 0 {
t.Errorf("expected empty slice, got %v", r)
}
}

150
operation/repo/branch.go Normal file
View File

@@ -0,0 +1,150 @@
package repo
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
CreateBranchToolName = "create_branch"
DeleteBranchToolName = "delete_branch"
ListBranchesToolName = "list_branches"
)
var (
CreateBranchTool = mcp.NewTool(
CreateBranchToolName,
mcp.WithToolAnnotation(annotation.Write("Create a new branch")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("branch", mcp.Required()),
mcp.WithString("old_branch", mcp.Description("source branch (default: repo default)")),
)
DeleteBranchTool = mcp.NewTool(
DeleteBranchToolName,
mcp.WithToolAnnotation(annotation.Destructive("Delete a branch")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("branch", mcp.Required()),
)
ListBranchesTool = mcp.NewTool(
ListBranchesToolName,
mcp.WithToolAnnotation(annotation.ReadOnly("List repository branches")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
)
func init() {
Tool.RegisterWrite(server.ServerTool{
Tool: CreateBranchTool,
Handler: CreateBranchFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteBranchTool,
Handler: DeleteBranchFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListBranchesTool,
Handler: ListBranchesFn,
})
}
func CreateBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
branch, err := params.GetString(args, "branch")
if err != nil {
return to.ErrorResult(err)
}
oldBranch, _ := args["old_branch"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, _, err = client.Repositories.CreateBranch(ctx, owner, repo, gitea_sdk.CreateBranchOption{
BranchName: branch,
OldBranchName: oldBranch,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("create branch error: %v", err))
}
return to.TextResult("Branch Created")
}
func DeleteBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
branch, err := params.GetString(args, "branch")
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.Repositories.DeleteRepoBranch(ctx, owner, repo, branch)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete branch error: %v", err))
}
return to.TextResult("Branch Deleted")
}
func ListBranchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
opt := gitea_sdk.ListRepoBranchesOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
branches, _, err := client.Repositories.ListRepoBranches(ctx, owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list branches error: %v", err))
}
return to.TextResult(slimBranches(branches))
}

109
operation/repo/commit.go Normal file
View File

@@ -0,0 +1,109 @@
package repo
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ListRepoCommitsToolName = "list_commits"
GetCommitToolName = "get_commit"
)
var (
ListRepoCommitsTool = mcp.NewTool(
ListRepoCommitsToolName,
mcp.WithToolAnnotation(annotation.ReadOnly("List repository commits")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("sha", mcp.Description("starting SHA or branch")),
mcp.WithString("path", mcp.Description("only commits touching this path")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)),
)
GetCommitTool = mcp.NewTool(
GetCommitToolName,
mcp.WithToolAnnotation(annotation.ReadOnly("Get commit details")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("sha", mcp.Required()),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListRepoCommitsTool,
Handler: ListRepoCommitsFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetCommitTool,
Handler: GetCommitFn,
})
}
func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
sha, _ := args["sha"].(string)
path, _ := args["path"].(string)
opt := gitea_sdk.ListCommitOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
SHA: sha,
Path: path,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
commits, _, err := client.Repositories.ListRepoCommits(ctx, owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo commits err: %v", err))
}
return to.TextResult(slimCommits(commits))
}
func GetCommitFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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.Repositories.GetSingleCommit(ctx, owner, repo, sha)
if err != nil {
return to.ErrorResult(fmt.Errorf("get commit %v err: %v", sha, err))
}
return to.TextResult(slimCommit(commit))
}

283
operation/repo/file.go Normal file
View File

@@ -0,0 +1,283 @@
package repo
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
GetFileToolName = "get_file_contents"
GetDirToolName = "get_dir_contents"
CreateOrUpdateFileToolName = "create_or_update_file"
DeleteFileToolName = "delete_file"
)
var (
GetFileContentTool = mcp.NewTool(
GetFileToolName,
mcp.WithDescription("Get file content and metadata"),
mcp.WithToolAnnotation(annotation.ReadOnly("Get file content")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("ref", mcp.Required(), mcp.Description("branch, tag, or commit SHA")),
mcp.WithString("path", mcp.Required()),
mcp.WithBoolean("withLines", mcp.Description("return numbered lines")),
)
GetDirContentTool = mcp.NewTool(
GetDirToolName,
mcp.WithToolAnnotation(annotation.ReadOnly("Get directory contents")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("ref", mcp.Required(), mcp.Description("branch, tag, or commit SHA")),
mcp.WithString("path", mcp.Required()),
)
CreateOrUpdateFileTool = mcp.NewTool(
CreateOrUpdateFileToolName,
mcp.WithDescription("Create or update a file (provide sha to update an existing file)."),
mcp.WithToolAnnotation(annotation.Write("Create or update a file")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("path", mcp.Required()),
mcp.WithString("content", mcp.Required()),
mcp.WithString("message", mcp.Required(), mcp.Description("commit message")),
mcp.WithString("branch_name", mcp.Required()),
mcp.WithString("sha", mcp.Description("existing file SHA (omit to create)")),
mcp.WithString("new_branch_name", mcp.Description("new branch (create only)")),
)
DeleteFileTool = mcp.NewTool(
DeleteFileToolName,
mcp.WithToolAnnotation(annotation.Destructive("Delete a file")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("path", mcp.Required()),
mcp.WithString("message", mcp.Required(), mcp.Description("commit message")),
mcp.WithString("branch_name", mcp.Required()),
mcp.WithString("sha", mcp.Required()),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: GetFileContentTool,
Handler: GetFileContentFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetDirContentTool,
Handler: GetDirContentFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: CreateOrUpdateFileTool,
Handler: CreateOrUpdateFileFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteFileTool,
Handler: DeleteFileFn,
})
}
type ContentLine struct {
LineNumber int `json:"line"`
Content string `json:"content"`
}
func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
ref, _ := args["ref"].(string)
filePath, err := params.GetString(args, "path")
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))
}
content, _, err := client.Repositories.GetContents(ctx, owner, repo, ref, filePath)
if err != nil {
return to.ErrorResult(fmt.Errorf("get file err: %v", err))
}
withLines, _ := args["withLines"].(bool)
if withLines {
rawContent, err := base64.StdEncoding.DecodeString(*content.Content)
if err != nil {
return to.ErrorResult(fmt.Errorf("decode base64 content err: %v", err))
}
contentLines := make([]ContentLine, 0)
line := 0
scanner := bufio.NewScanner(bytes.NewReader(rawContent))
for scanner.Scan() {
line++
contentLines = append(contentLines, ContentLine{
LineNumber: line,
Content: scanner.Text(),
})
}
if err := scanner.Err(); err != nil {
return to.ErrorResult(fmt.Errorf("scan content err: %v", err))
}
// remove the last blank line if exists
// git does not consider the last line as a new line
if len(contentLines) > 0 && contentLines[len(contentLines)-1].Content == "" {
contentLines = contentLines[:len(contentLines)-1]
}
contentBytes, err := json.MarshalIndent(contentLines, "", " ")
if err != nil {
return to.ErrorResult(fmt.Errorf("marshal content lines err: %v", err))
}
contentStr := string(contentBytes)
content.Content = &contentStr
}
return to.TextResult(slimContents(content))
}
func GetDirContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
ref, _ := args["ref"].(string)
filePath, err := params.GetString(args, "path")
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))
}
content, _, err := client.Repositories.ListContents(ctx, owner, repo, ref, filePath)
if err != nil {
return to.ErrorResult(fmt.Errorf("get dir content err: %v", err))
}
return to.TextResult(slimDirEntries(content))
}
func CreateOrUpdateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
filePath, err := params.GetString(args, "path")
if err != nil {
return to.ErrorResult(err)
}
content, _ := args["content"].(string)
message, _ := args["message"].(string)
branchName, _ := args["branch_name"].(string)
sha, _ := args["sha"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
if sha != "" {
// Update existing file
opt := gitea_sdk.UpdateFileOptions{
SHA: sha,
Content: base64.StdEncoding.EncodeToString([]byte(content)),
FileOptions: gitea_sdk.FileOptions{
Message: message,
BranchName: branchName,
},
}
_, _, err = client.Repositories.UpdateFile(ctx, owner, repo, filePath, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("update file err: %v", err))
}
return to.TextResult("Update file success")
}
// Create new file
opt := gitea_sdk.CreateFileOptions{
Content: base64.StdEncoding.EncodeToString([]byte(content)),
FileOptions: gitea_sdk.FileOptions{
Message: message,
BranchName: branchName,
},
}
if newBranch, ok := args["new_branch_name"].(string); ok && newBranch != "" {
opt.NewBranchName = newBranch
}
_, _, err = client.Repositories.CreateFile(ctx, owner, repo, filePath, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create file err: %v", err))
}
return to.TextResult("Create file success")
}
func DeleteFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
filePath, err := params.GetString(args, "path")
if err != nil {
return to.ErrorResult(err)
}
message, _ := args["message"].(string)
branchName, _ := args["branch_name"].(string)
sha, err := params.GetString(args, "sha")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.DeleteFileOptions{
FileOptions: gitea_sdk.FileOptions{
Message: message,
BranchName: branchName,
},
SHA: sha,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.Repositories.DeleteFile(ctx, owner, repo, filePath, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete file err: %v", err))
}
return to.TextResult("Delete file success")
}

249
operation/repo/release.go Normal file
View File

@@ -0,0 +1,249 @@
package repo
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
CreateReleaseToolName = "create_release"
DeleteReleaseToolName = "delete_release"
GetReleaseToolName = "get_release"
GetLatestReleaseToolName = "get_latest_release"
ListReleasesToolName = "list_releases"
)
var (
CreateReleaseTool = mcp.NewTool(
CreateReleaseToolName,
mcp.WithToolAnnotation(annotation.Write("Create a release")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("tag_name", mcp.Required()),
mcp.WithString("target", mcp.Required(), mcp.Description("commitish")),
mcp.WithString("title", mcp.Required()),
mcp.WithBoolean("is_draft"),
mcp.WithBoolean("is_pre_release"),
mcp.WithString("body"),
)
DeleteReleaseTool = mcp.NewTool(
DeleteReleaseToolName,
mcp.WithToolAnnotation(annotation.Destructive("Delete a release")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("id", mcp.Required()),
)
GetReleaseTool = mcp.NewTool(
GetReleaseToolName,
mcp.WithDescription("Get a release by ID"),
mcp.WithToolAnnotation(annotation.ReadOnly("Get release details")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("id", mcp.Required()),
)
GetLatestReleaseTool = mcp.NewTool(
GetLatestReleaseToolName,
mcp.WithToolAnnotation(annotation.ReadOnly("Get latest release")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
)
ListReleasesTool = mcp.NewTool(
ListReleasesToolName,
mcp.WithToolAnnotation(annotation.ReadOnly("List releases")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithBoolean("is_draft"),
mcp.WithBoolean("is_pre_release"),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(20), mcp.Min(1)),
)
)
func init() {
Tool.RegisterWrite(server.ServerTool{
Tool: CreateReleaseTool,
Handler: CreateReleaseFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteReleaseTool,
Handler: DeleteReleaseFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetReleaseTool,
Handler: GetReleaseFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetLatestReleaseTool,
Handler: GetLatestReleaseFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListReleasesTool,
Handler: ListReleasesFn,
})
}
func CreateReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
tagName, err := params.GetString(args, "tag_name")
if err != nil {
return to.ErrorResult(err)
}
target, err := params.GetString(args, "target")
if err != nil {
return to.ErrorResult(err)
}
title, err := params.GetString(args, "title")
if err != nil {
return to.ErrorResult(err)
}
isDraft, _ := args["is_draft"].(bool)
isPreRelease, _ := args["is_pre_release"].(bool)
body, _ := args["body"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, _, err = client.Releases.CreateRelease(ctx, owner, repo, gitea_sdk.CreateReleaseOption{
TagName: tagName,
Target: target,
Title: title,
Note: body,
IsDraft: isDraft,
IsPrerelease: isPreRelease,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("create release error: %v", err))
}
return to.TextResult("Release Created")
}
func DeleteReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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.Releases.DeleteRelease(ctx, owner, repo, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete release error: %v", err))
}
return to.TextResult("Release deleted successfully")
}
func GetReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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))
}
release, _, err := client.Releases.GetRelease(ctx, owner, repo, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("get release error: %v", err))
}
return to.TextResult(slimRelease(release))
}
func GetLatestReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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))
}
release, _, err := client.Releases.GetLatestRelease(ctx, owner, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("get latest release error: %v", err))
}
return to.TextResult(slimRelease(release))
}
func ListReleasesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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, 20)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
releases, _, err := client.Releases.ListReleases(ctx, owner, repo, gitea_sdk.ListReleasesOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
IsDraft: params.GetOptionalBoolPtr(args, "is_draft"),
IsPreRelease: params.GetOptionalBoolPtr(args, "is_pre_release"),
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list releases error: %v", err))
}
return to.TextResult(slimReleases(releases))
}

210
operation/repo/repo.go Normal file
View File

@@ -0,0 +1,210 @@
package repo
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/slim"
"gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
CreateRepoToolName = "create_repo"
ForkRepoToolName = "fork_repo"
ListMyReposToolName = "list_my_repos"
ListOrgReposToolName = "list_org_repos"
)
var (
CreateRepoTool = mcp.NewTool(
CreateRepoToolName,
mcp.WithToolAnnotation(annotation.Write("Create a new repository")),
mcp.WithString("name", mcp.Required()),
mcp.WithString("description"),
mcp.WithBoolean("private"),
mcp.WithString("issue_labels"),
mcp.WithBoolean("auto_init"),
mcp.WithBoolean("template"),
mcp.WithString("gitignores"),
mcp.WithString("license"),
mcp.WithString("readme"),
mcp.WithString("default_branch"),
mcp.WithString("trust_model", mcp.Enum("default", "collaborator", "committer", "collaboratorcommitter")),
mcp.WithString("object_format_name", mcp.Enum("sha1", "sha256")),
mcp.WithString("organization", mcp.Description("defaults to personal account")),
)
ForkRepoTool = mcp.NewTool(
ForkRepoToolName,
mcp.WithToolAnnotation(annotation.Write("Fork a repository")),
mcp.WithString("user", mcp.Required(), mcp.Description("owner of source repo")),
mcp.WithString("repo", mcp.Required()),
mcp.WithString("organization", mcp.Description("target org")),
mcp.WithString("name", mcp.Description("fork name")),
)
ListMyReposTool = mcp.NewTool(
ListMyReposToolName,
mcp.WithToolAnnotation(annotation.ReadOnly("List my repositories")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)),
)
ListOrgReposTool = mcp.NewTool(
ListOrgReposToolName,
mcp.WithToolAnnotation(annotation.ReadOnly("List organization repositories")),
mcp.WithString("org", mcp.Required()),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(100), mcp.Min(1)),
)
)
func init() {
Tool.RegisterWrite(server.ServerTool{
Tool: CreateRepoTool,
Handler: CreateRepoFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: ForkRepoTool,
Handler: ForkRepoFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListMyReposTool,
Handler: ListMyReposFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListOrgReposTool,
Handler: ListOrgReposFn,
})
}
func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
name, err := params.GetString(args, "name")
if err != nil {
return to.ErrorResult(err)
}
description, _ := args["description"].(string)
private, _ := args["private"].(bool)
issueLabels, _ := args["issue_labels"].(string)
autoInit, _ := args["auto_init"].(bool)
template, _ := args["template"].(bool)
gitignores, _ := args["gitignores"].(string)
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{
Name: name,
Description: description,
Private: private,
IssueLabels: issueLabels,
AutoInit: autoInit,
Template: template,
Gitignores: gitignores,
License: license,
Readme: readme,
DefaultBranch: defaultBranch,
TrustModel: gitea_sdk.TrustModel(trustModel),
ObjectFormatName: objectFormatName,
}
var repo *gitea_sdk.Repository
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
if organization != "" {
repo, _, err = client.Repositories.CreateOrgRepo(ctx, organization, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create organization repository '%s' in '%s' err: %v", name, organization, err))
}
} else {
repo, _, err = client.Repositories.CreateRepo(ctx, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create repository '%s' err: %v", name, err))
}
}
return to.TextResult(slim.Repo(repo))
}
func ForkRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
user, err := params.GetString(args, "user")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.CreateForkOption{
Organization: params.GetOptionalStringPtr(args, "organization"),
Name: params.GetOptionalStringPtr(args, "name"),
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, _, err = client.Repositories.CreateFork(ctx, user, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("fork repository error: %v", err))
}
return to.TextResult("Fork success")
}
func ListMyReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.ListReposOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
repos, _, err := client.Repositories.ListMyRepos(ctx, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list my repositories error: %v", err))
}
return to.TextResult(slim.Repos(repos))
}
func ListOrgReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 100)
opt := gitea_sdk.ListOrgReposOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
repos, _, err := client.Repositories.ListOrgRepos(ctx, org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list organization '%s' repositories error: %v", org, err))
}
return to.TextResult(repos)
}

178
operation/repo/slim.go Normal file
View File

@@ -0,0 +1,178 @@
package repo
import (
"gitea.com/gitea/gitea-mcp/pkg/slim"
gitea_sdk "gitea.dev/sdk"
)
func slimBranch(b *gitea_sdk.Branch) map[string]any {
if b == nil {
return nil
}
m := map[string]any{
"name": b.Name,
"protected": b.Protected,
}
if b.Commit != nil {
m["commit_sha"] = b.Commit.ID
}
return m
}
func slimBranches(branches []*gitea_sdk.Branch) []map[string]any {
out := make([]map[string]any, 0, len(branches))
for _, b := range branches {
out = append(out, slimBranch(b))
}
return out
}
func slimCommit(c *gitea_sdk.Commit) map[string]any {
if c == nil {
return nil
}
m := map[string]any{
"sha": c.SHA,
"html_url": c.HTMLURL,
"created": c.Created,
}
if c.RepoCommit != nil {
m["message"] = c.RepoCommit.Message
if c.RepoCommit.Author != nil {
m["author"] = map[string]any{
"name": c.RepoCommit.Author.Name,
"email": c.RepoCommit.Author.Email,
"date": c.RepoCommit.Author.Date,
}
}
}
return m
}
func slimCommits(commits []*gitea_sdk.Commit) []map[string]any {
out := make([]map[string]any, 0, len(commits))
for _, c := range commits {
out = append(out, slimCommit(c))
}
return out
}
func slimTag(t *gitea_sdk.Tag) map[string]any {
if t == nil {
return nil
}
m := map[string]any{
"name": t.Name,
"message": t.Message,
}
if t.Commit != nil {
m["commit_sha"] = t.Commit.SHA
}
return m
}
func slimTags(tags []*gitea_sdk.Tag) []map[string]any {
out := make([]map[string]any, 0, len(tags))
for _, t := range tags {
m := map[string]any{
"name": t.Name,
}
if t.Commit != nil {
m["commit_sha"] = t.Commit.SHA
}
out = append(out, m)
}
return out
}
func slimRelease(r *gitea_sdk.Release) map[string]any {
if r == nil {
return nil
}
return map[string]any{
"id": r.ID,
"tag_name": r.TagName,
"target": r.Target,
"title": r.Title,
"body": r.Note,
"draft": r.IsDraft,
"prerelease": r.IsPrerelease,
"html_url": r.HTMLURL,
"author": slim.UserLogin(r.Publisher),
"created_at": r.CreatedAt,
"published_at": r.PublishedAt,
}
}
func slimReleases(releases []*gitea_sdk.Release) []map[string]any {
out := make([]map[string]any, 0, len(releases))
for _, r := range releases {
out = append(out, slimRelease(r))
}
return out
}
func slimContents(c *gitea_sdk.ContentsResponse) map[string]any {
if c == nil {
return nil
}
m := map[string]any{
"name": c.Name,
"path": c.Path,
"sha": c.SHA,
"type": c.Type,
"size": c.Size,
}
if c.Content != nil {
m["content"] = *c.Content
}
if c.Encoding != nil {
m["encoding"] = *c.Encoding
}
if c.HTMLURL != nil {
m["html_url"] = *c.HTMLURL
}
if c.DownloadURL != nil {
m["download_url"] = *c.DownloadURL
}
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 slimDirEntries(entries []*gitea_sdk.ContentsResponse) []map[string]any {
out := make([]map[string]any, 0, len(entries))
for _, c := range entries {
if c == nil {
continue
}
out = append(out, map[string]any{
"name": c.Name,
"path": c.Path,
"type": c.Type,
"size": c.Size,
})
}
return out
}

109
operation/repo/slim_test.go Normal file
View File

@@ -0,0 +1,109 @@
package repo
import (
"testing"
gitea_sdk "gitea.dev/sdk"
)
func TestSlimTag(t *testing.T) {
tag := &gitea_sdk.Tag{
Name: "v1.0.0",
Message: "Release v1.0.0",
Commit: &gitea_sdk.CommitMeta{SHA: "abc123"},
}
m := slimTag(tag)
if m["name"] != "v1.0.0" {
t.Errorf("expected name v1.0.0, got %v", m["name"])
}
if m["message"] != "Release v1.0.0" {
t.Errorf("expected message, got %v", m["message"])
}
// List variant omits message
list := slimTags([]*gitea_sdk.Tag{tag})
if _, ok := list[0]["message"]; ok {
t.Error("Tags list should omit message")
}
if list[0]["name"] != "v1.0.0" {
t.Errorf("expected name in list, got %v", list[0]["name"])
}
}
func TestSlimRelease(t *testing.T) {
r := &gitea_sdk.Release{
ID: 1,
TagName: "v1.0.0",
Title: "First Release",
Note: "Release notes",
IsDraft: false,
Publisher: &gitea_sdk.User{UserName: "alice"},
}
m := slimRelease(r)
if m["tag_name"] != "v1.0.0" {
t.Errorf("expected tag_name v1.0.0, got %v", m["tag_name"])
}
if m["body"] != "Release notes" {
t.Errorf("expected body from Note field, got %v", m["body"])
}
if m["author"] != "alice" {
t.Errorf("expected author alice, got %v", m["author"])
}
}
func TestSlimContents(t *testing.T) {
content := "package main"
encoding := "base64"
htmlURL := "https://gitea.com/org/repo/src/branch/main/main.go"
c := &gitea_sdk.ContentsResponse{
Name: "main.go",
Path: "main.go",
SHA: "abc123",
Type: "file",
Size: 12,
Content: &content,
Encoding: &encoding,
HTMLURL: &htmlURL,
}
m := slimContents(c)
if m["name"] != "main.go" {
t.Errorf("expected name main.go, got %v", m["name"])
}
if m["content"] != "package main" {
t.Errorf("expected content, got %v", m["content"])
}
}
func TestSlimDirEntries(t *testing.T) {
entries := []*gitea_sdk.ContentsResponse{
{Name: "src", Path: "src", Type: "dir", Size: 0},
{Name: "main.go", Path: "main.go", Type: "file", Size: 100},
}
result := slimDirEntries(entries)
if len(result) != 2 {
t.Fatalf("expected 2 entries, got %d", len(result))
}
if result[0]["name"] != "src" {
t.Errorf("expected first entry name src, got %v", result[0]["name"])
}
// Dir entries should not have content
if _, ok := result[0]["content"]; ok {
t.Error("dir entries should not have content field")
}
}
func TestSlimTags_Nil(t *testing.T) {
if r := slimTags(nil); len(r) != 0 {
t.Errorf("expected empty slice, got %v", r)
}
}
func TestSlimReleases_Nil(t *testing.T) {
if r := slimReleases(nil); len(r) != 0 {
t.Errorf("expected empty slice, got %v", r)
}
}

195
operation/repo/tag.go Normal file
View File

@@ -0,0 +1,195 @@
package repo
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
CreateTagToolName = "create_tag"
DeleteTagToolName = "delete_tag"
GetTagToolName = "get_tag"
ListTagsToolName = "list_tags"
)
var (
CreateTagTool = mcp.NewTool(
CreateTagToolName,
mcp.WithToolAnnotation(annotation.Write("Create a tag")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("tag_name", mcp.Required()),
mcp.WithString("target", mcp.Description("commitish")),
mcp.WithString("message", mcp.Description("tag message")),
)
DeleteTagTool = mcp.NewTool(
DeleteTagToolName,
mcp.WithToolAnnotation(annotation.Destructive("Delete a tag")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("tag_name", mcp.Required()),
)
GetTagTool = mcp.NewTool(
GetTagToolName,
mcp.WithToolAnnotation(annotation.ReadOnly("Get tag details")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("tag_name", mcp.Required()),
)
ListTagsTool = mcp.NewTool(
ListTagsToolName,
mcp.WithToolAnnotation(annotation.ReadOnly("List tags")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(20), mcp.Min(1)),
)
)
func init() {
Tool.RegisterWrite(server.ServerTool{
Tool: CreateTagTool,
Handler: CreateTagFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteTagTool,
Handler: DeleteTagFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetTagTool,
Handler: GetTagFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListTagsTool,
Handler: ListTagsFn,
})
}
func CreateTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
tagName, err := params.GetString(args, "tag_name")
if err != nil {
return to.ErrorResult(err)
}
target, _ := args["target"].(string)
message, _ := args["message"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, _, err = client.Repositories.CreateTag(ctx, owner, repo, gitea_sdk.CreateTagOption{
TagName: tagName,
Target: target,
Message: message,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("create tag error: %v", err))
}
return to.TextResult("Tag Created")
}
func DeleteTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
tagName, err := params.GetString(args, "tag_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.Repositories.DeleteTag(ctx, owner, repo, tagName)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete tag error: %v", err))
}
return to.TextResult("Tag deleted")
}
func GetTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
tagName, err := params.GetString(args, "tag_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))
}
tag, _, err := client.Repositories.GetTag(ctx, owner, repo, tagName)
if err != nil {
return to.ErrorResult(fmt.Errorf("get tag error: %v", err))
}
return to.TextResult(slimTag(tag))
}
func ListTagsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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 := params.GetOptionalInt(args, "page", 1)
pageSize := params.GetOptionalInt(args, "per_page", 20)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
tags, _, err := client.Repositories.ListRepoTags(ctx, owner, repo, gitea_sdk.ListRepoTagsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: int(page),
PageSize: int(pageSize),
},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list tags error: %v", err))
}
return to.TextResult(slimTags(tags))
}

73
operation/repo/tree.go Normal file
View File

@@ -0,0 +1,73 @@
package repo
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
GetRepoTreeToolName = "get_repository_tree"
)
var GetRepoTreeTool = mcp.NewTool(
GetRepoTreeToolName,
mcp.WithToolAnnotation(annotation.ReadOnly("Get repository file tree")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("tree_sha", mcp.Required(), mcp.Description("SHA, branch, or tag")),
mcp.WithBoolean("recursive"),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: GetRepoTreeTool,
Handler: GetRepoTreeFn,
})
}
func GetRepoTreeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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.Git.GetTrees(ctx, owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("get repository tree err: %v", err))
}
return to.TextResult(slimTree(tree))
}

View File

@@ -0,0 +1,52 @@
package repo
import (
"slices"
"testing"
gitea_sdk "gitea.dev/sdk"
)
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)
}
}
}

222
operation/search/search.go Normal file
View File

@@ -0,0 +1,222 @@
package search
import (
"context"
"fmt"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/slim"
"gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
SearchUsersToolName = "search_users"
SearchOrgTeamsToolName = "search_org_teams"
SearchReposToolName = "search_repos"
SearchIssuesToolName = "search_issues"
)
var (
SearchUsersTool = mcp.NewTool(
SearchUsersToolName,
mcp.WithToolAnnotation(annotation.ReadOnly("Search users")),
mcp.WithString("query", mcp.Required()),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
SearOrgTeamsTool = mcp.NewTool(
SearchOrgTeamsToolName,
mcp.WithToolAnnotation(annotation.ReadOnly("Search organization teams")),
mcp.WithString("org", mcp.Required()),
mcp.WithString("query", mcp.Required()),
mcp.WithBoolean("includeDescription"),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
SearchReposTool = mcp.NewTool(
SearchReposToolName,
mcp.WithToolAnnotation(annotation.ReadOnly("Search repositories")),
mcp.WithString("query", mcp.Required()),
mcp.WithBoolean("keywordIsTopic"),
mcp.WithBoolean("keywordInDescription"),
mcp.WithNumber("ownerID"),
mcp.WithBoolean("isPrivate"),
mcp.WithBoolean("isArchived"),
mcp.WithString("sort"),
mcp.WithString("order"),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
SearchIssuesTool = mcp.NewTool(
SearchIssuesToolName,
mcp.WithDescription("Search issues and PRs across repositories"),
mcp.WithToolAnnotation(annotation.ReadOnly("Search issues")),
mcp.WithString("query", mcp.Required()),
mcp.WithString("state", mcp.Enum("open", "closed", "all")),
mcp.WithString("type", mcp.Enum("issues", "pulls")),
mcp.WithString("labels", mcp.Description("comma-separated")),
mcp.WithString("owner", mcp.Description("filter by owner")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: SearchUsersTool,
Handler: UsersFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: SearOrgTeamsTool,
Handler: OrgTeamsFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: SearchReposTool,
Handler: ReposFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: SearchIssuesTool,
Handler: IssuesFn,
})
}
func UsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
keyword, err := params.GetString(req.GetArguments(), "query")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.SearchUsersOption{
KeyWord: keyword,
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
users, _, err := client.Users.SearchUsers(ctx, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("search users err: %v", err))
}
return to.TextResult(slimUserDetails(users))
}
func OrgTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
query, err := params.GetString(req.GetArguments(), "query")
if err != nil {
return to.ErrorResult(err)
}
includeDescription, _ := req.GetArguments()["includeDescription"].(bool)
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.SearchTeamsOptions{
Query: query,
IncludeDescription: includeDescription,
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
teams, _, err := client.Organizations.SearchOrgTeams(ctx, org, &opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("search organization teams error: %v", err))
}
return to.TextResult(slimTeams(teams))
}
func ReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
keyword, err := params.GetString(req.GetArguments(), "query")
if err != nil {
return to.ErrorResult(err)
}
args := req.GetArguments()
keywordIsTopic, _ := args["keywordIsTopic"].(bool)
keywordInDescription, _ := args["keywordInDescription"].(bool)
sort, _ := args["sort"].(string)
order, _ := args["order"].(string)
page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.SearchRepoOptions{
Keyword: keyword,
KeywordIsTopic: keywordIsTopic,
KeywordInDescription: keywordInDescription,
OwnerID: params.GetOptionalInt(args, "ownerID", 0),
IsPrivate: params.GetOptionalBoolPtr(args, "isPrivate"),
IsArchived: params.GetOptionalBoolPtr(args, "isArchived"),
Sort: sort,
Order: order,
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
repos, _, err := client.Repositories.SearchRepos(ctx, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("search repos error: %v", err))
}
return to.TextResult(slim.Repos(repos))
}
func IssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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.Issues.ListIssues(ctx, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("search issues err: %v", err))
}
return to.TextResult(slimIssues(issues))
}

View File

@@ -0,0 +1,42 @@
package search
import (
"slices"
"testing"
"github.com/mark3labs/mcp-go/mcp"
)
func TestSearchToolsRequiredFields(t *testing.T) {
tests := []struct {
name string
tool mcp.Tool
required []string
}{
{
name: "search_users",
tool: SearchUsersTool,
required: []string{"query"},
},
{
name: "search_org_teams",
tool: SearOrgTeamsTool,
required: []string{"org", "query"},
},
{
name: "search_repos",
tool: SearchReposTool,
required: []string{"query"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for _, field := range tt.required {
if !slices.Contains(tt.tool.InputSchema.Required, field) {
t.Errorf("tool %s: expected %q to be required, got required=%v", tt.name, field, tt.tool.InputSchema.Required)
}
}
})
}
}

71
operation/search/slim.go Normal file
View File

@@ -0,0 +1,71 @@
package search
import (
"gitea.com/gitea/gitea-mcp/pkg/slim"
gitea_sdk "gitea.dev/sdk"
)
func slimUserDetails(users []*gitea_sdk.User) []map[string]any {
out := make([]map[string]any, 0, len(users))
for _, u := range users {
out = append(out, slim.UserDetail(u))
}
return out
}
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,
}
}
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 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": slim.UserLogin(i.Poster),
"comments": i.Comments,
"created_at": i.Created,
"updated_at": i.Updated,
}
if len(i.Labels) > 0 {
m["labels"] = slim.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
}

View File

@@ -0,0 +1,54 @@
package search
import (
"slices"
"testing"
gitea_sdk "gitea.dev/sdk"
)
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")
}
}

View File

@@ -0,0 +1,47 @@
package timetracking
import (
gitea_sdk "gitea.dev/sdk"
)
func slimStopWatch(s *gitea_sdk.StopWatch) map[string]any {
if s == nil {
return nil
}
return map[string]any{
"issue_index": s.IssueIndex,
"issue_title": s.IssueTitle,
"repo_name": s.RepoName,
"repo_owner": s.RepoOwnerName,
"created": s.Created,
"seconds": s.Seconds,
}
}
func slimStopWatches(watches []*gitea_sdk.StopWatch) []map[string]any {
out := make([]map[string]any, 0, len(watches))
for _, s := range watches {
out = append(out, slimStopWatch(s))
}
return out
}
func slimTrackedTime(t *gitea_sdk.TrackedTime) map[string]any {
if t == nil {
return nil
}
return map[string]any{
"id": t.ID,
"time": t.Time,
"user_name": t.UserName,
"created": t.Created,
}
}
func slimTrackedTimes(times []*gitea_sdk.TrackedTime) []map[string]any {
out := make([]map[string]any, 0, len(times))
for _, t := range times {
out = append(out, slimTrackedTime(t))
}
return out
}

View File

@@ -0,0 +1,321 @@
// Package timetracking provides MCP tools for Gitea time tracking operations
package timetracking
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
TimetrackingReadToolName = "timetracking_read"
TimetrackingWriteToolName = "timetracking_write"
)
var (
TimetrackingReadTool = mcp.NewTool(
TimetrackingReadToolName,
mcp.WithDescription("Read time tracking: issue times, repo times, active stopwatches, your tracked times."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read tracked time")),
mcp.WithString("method", mcp.Required(), mcp.Enum("list_issue_times", "list_repo_times", "get_my_stopwatches", "get_my_times")),
mcp.WithString("owner", mcp.Description("for list_* methods")),
mcp.WithString("repo", mcp.Description("for list_* methods")),
mcp.WithNumber("issue_number", mcp.Description("for 'list_issue_times'")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
TimetrackingWriteTool = mcp.NewTool(
TimetrackingWriteToolName,
mcp.WithDescription("Write time tracking: stopwatches and entries."),
mcp.WithToolAnnotation(annotation.Write("Add or manage tracked time")),
mcp.WithString("method", mcp.Required(), mcp.Enum("start_stopwatch", "stop_stopwatch", "delete_stopwatch", "add_time", "delete_time")),
mcp.WithString("owner", mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Description(params.RepoDesc)),
mcp.WithNumber("issue_number"),
mcp.WithNumber("time", mcp.Description("seconds (for 'add_time')")),
mcp.WithNumber("id", mcp.Description("entry ID (for 'delete_time')")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: TimetrackingReadTool, Handler: readFn})
Tool.RegisterWrite(server.ServerTool{Tool: TimetrackingWriteTool, Handler: writeFn})
}
func readFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list_issue_times":
return listTrackedTimesFn(ctx, req)
case "list_repo_times":
return listRepoTimesFn(ctx, req)
case "get_my_stopwatches":
return getMyStopwatchesFn(ctx, req)
case "get_my_times":
return getMyTimesFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func writeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "start_stopwatch":
return startStopwatchFn(ctx, req)
case "stop_stopwatch":
return stopStopwatchFn(ctx, req)
case "delete_stopwatch":
return deleteStopwatchFn(ctx, req)
case "add_time":
return addTrackedTimeFn(ctx, req)
case "delete_time":
return deleteTrackedTimeFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func startStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
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.Issues.StartIssueStopWatch(ctx, owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("start stopwatch on %s/%s#%d err: %v", owner, repo, index, err))
}
return to.TextResult(fmt.Sprintf("Stopwatch started on issue %s/%s#%d", owner, repo, index))
}
func stopStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
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.Issues.StopIssueStopWatch(ctx, owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("stop stopwatch on %s/%s#%d err: %v", owner, repo, index, err))
}
return to.TextResult(fmt.Sprintf("Stopwatch stopped on issue %s/%s#%d - time recorded", owner, repo, index))
}
func deleteStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
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.Issues.DeleteIssueStopwatch(ctx, owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete stopwatch on %s/%s#%d err: %v", owner, repo, index, err))
}
return to.TextResult(fmt.Sprintf("Stopwatch deleted/cancelled on issue %s/%s#%d", owner, repo, index))
}
func getMyStopwatchesFn(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
stopwatches, _, err := client.Issues.ListMyStopwatches(ctx, gitea_sdk.ListStopwatchesOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("get stopwatches err: %v", err))
}
if len(stopwatches) == 0 {
return to.TextResult("No active stopwatches")
}
return to.TextResult(slimStopWatches(stopwatches))
}
func listTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
times, _, err := client.Issues.ListIssueTrackedTimes(ctx, owner, repo, index, gitea_sdk.ListTrackedTimesOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list tracked times for %s/%s#%d err: %v", owner, repo, index, err))
}
if len(times) == 0 {
return to.TextResult(fmt.Sprintf("No tracked times for issue %s/%s#%d", owner, repo, index))
}
return to.TextResult(slimTrackedTimes(times))
}
func addTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
timeSeconds, err := params.GetIndex(req.GetArguments(), "time")
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))
}
trackedTime, _, err := client.Issues.AddTime(ctx, owner, repo, index, gitea_sdk.AddTimeOption{
Time: timeSeconds,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("add tracked time to %s/%s#%d err: %v", owner, repo, index, err))
}
return to.TextResult(slimTrackedTime(trackedTime))
}
func deleteTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
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))
}
_, err = client.Issues.DeleteTime(ctx, owner, repo, index, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete tracked time %d from %s/%s#%d err: %v", id, owner, repo, index, err))
}
return to.TextResult(fmt.Sprintf("Tracked time entry %d deleted from issue %s/%s#%d", id, owner, repo, index))
}
func listRepoTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
times, _, err := client.Issues.ListRepoTrackedTimes(ctx, owner, repo, gitea_sdk.ListTrackedTimesOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo tracked times for %s/%s err: %v", owner, repo, err))
}
if len(times) == 0 {
return to.TextResult(fmt.Sprintf("No tracked times for repository %s/%s", owner, repo))
}
return to.TextResult(slimTrackedTimes(times))
}
func getMyTimesFn(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
times, _, err := client.Issues.ListMyTrackedTimes(ctx, gitea_sdk.ListTrackedTimesOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("get tracked times err: %v", err))
}
if len(times) == 0 {
return to.TextResult("No tracked times found")
}
return to.TextResult(slimTrackedTimes(times))
}

27
operation/user/slim.go Normal file
View File

@@ -0,0 +1,27 @@
package user
import (
gitea_sdk "gitea.dev/sdk"
)
func slimOrg(o *gitea_sdk.Organization) map[string]any {
if o == nil {
return nil
}
return map[string]any{
"id": o.ID,
"name": o.Name,
"full_name": o.FullName,
"description": o.Description,
"avatar_url": o.AvatarURL,
"website": o.Website,
}
}
func slimOrgs(orgs []*gitea_sdk.Organization) []map[string]any {
out := make([]map[string]any, 0, len(orgs))
for _, o := range orgs {
out = append(out, slimOrg(o))
}
return out
}

77
operation/user/user.go Normal file
View File

@@ -0,0 +1,77 @@
package user
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/slim"
"gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
GetMyUserInfoToolName = "get_me"
GetUserOrgsToolName = "get_user_orgs"
)
var Tool = tool.New()
var (
GetMyUserInfoTool = mcp.NewTool(
GetMyUserInfoToolName,
mcp.WithDescription("Get current user"),
mcp.WithToolAnnotation(annotation.ReadOnly("Get current user information")),
)
GetUserOrgsTool = mcp.NewTool(
GetUserOrgsToolName,
mcp.WithDescription("List current user's organizations"),
mcp.WithToolAnnotation(annotation.ReadOnly("Get user organizations")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: GetMyUserInfoTool, Handler: GetUserInfoFn})
Tool.RegisterRead(server.ServerTool{Tool: GetUserOrgsTool, Handler: GetUserOrgsFn})
}
func GetUserInfoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
user, _, err := client.Users.GetMyUserInfo(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get user info err: %v", err))
}
return to.TextResult(slim.UserDetail(user))
}
func GetUserOrgsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.ListOrgsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
orgs, _, err := client.Organizations.ListMyOrgs(ctx, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("get user orgs err: %v", err))
}
return to.TextResult(slimOrgs(orgs))
}

View File

@@ -0,0 +1,40 @@
package version
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
GetGiteaMCPServerVersion = "get_gitea_mcp_server_version"
)
var GetGiteaMCPServerVersionTool = mcp.NewTool(
GetGiteaMCPServerVersion,
mcp.WithToolAnnotation(annotation.ReadOnly("Get server version")),
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: GetGiteaMCPServerVersionTool,
Handler: GetGiteaMCPServerVersionFn,
})
}
func GetGiteaMCPServerVersionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
version := flag.Version
if version == "" {
version = "dev"
}
return to.TextResult(fmt.Sprintf("Gitea MCP Server version: %v", version))
}

269
operation/wiki/wiki.go Normal file
View File

@@ -0,0 +1,269 @@
package wiki
import (
"context"
"encoding/base64"
"fmt"
"net/url"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
WikiReadToolName = "wiki_read"
WikiWriteToolName = "wiki_write"
)
var (
WikiReadTool = mcp.NewTool(
WikiReadToolName,
mcp.WithDescription("Read wiki: list pages, get content, revision history."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read wiki pages")),
mcp.WithString("method", mcp.Required(), mcp.Enum("list", "get", "get_revisions")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("pageName", mcp.Description("for 'get'/'get_revisions'")),
)
WikiWriteTool = mcp.NewTool(
WikiWriteToolName,
mcp.WithDescription("Write wiki pages: create, update, delete."),
mcp.WithToolAnnotation(annotation.Destructive("Create, update, or delete wiki pages")),
mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("pageName", mcp.Description("for 'update'/'delete'")),
mcp.WithString("title", mcp.Description("for 'create'")),
mcp.WithString("content", mcp.Description("for 'create'/'update'")),
mcp.WithString("message", mcp.Description("commit message")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: WikiReadTool,
Handler: wikiReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: WikiWriteTool,
Handler: wikiWriteFn,
})
}
func wikiReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list":
return listWikiPagesFn(ctx, req)
case "get":
return getWikiPageFn(ctx, req)
case "get_revisions":
return getWikiRevisionsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func wikiWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createWikiPageFn(ctx, req)
case "update":
return updateWikiPageFn(ctx, req)
case "delete":
return deleteWikiPageFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listWikiPagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
var result any
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/wiki/pages", url.PathEscape(owner), url.PathEscape(repo)), nil, nil, &result)
if err != nil {
return to.ErrorResult(fmt.Errorf("list wiki pages err: %v", err))
}
return to.TextResult(result)
}
func getWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
pageName, err := params.GetString(args, "pageName")
if err != nil {
return to.ErrorResult(err)
}
var result any
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(pageName)), nil, nil, &result)
if err != nil {
return to.ErrorResult(fmt.Errorf("get wiki page err: %v", err))
}
return to.TextResult(result)
}
func getWikiRevisionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
pageName, err := params.GetString(args, "pageName")
if err != nil {
return to.ErrorResult(err)
}
var result any
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/wiki/revisions/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(pageName)), nil, nil, &result)
if err != nil {
return to.ErrorResult(fmt.Errorf("get wiki revisions err: %v", err))
}
return to.TextResult(result)
}
func createWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
content, err := params.GetString(args, "content")
if err != nil {
return to.ErrorResult(err)
}
message, _ := args["message"].(string)
if message == "" {
message = fmt.Sprintf("Create wiki page '%s'", title)
}
requestBody := map[string]string{
"title": title,
"content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
"message": message,
}
var result any
_, err = gitea.DoJSON(ctx, "POST", fmt.Sprintf("repos/%s/%s/wiki/new", url.PathEscape(owner), url.PathEscape(repo)), nil, requestBody, &result)
if err != nil {
return to.ErrorResult(fmt.Errorf("create wiki page err: %v", err))
}
return to.TextResult(result)
}
func updateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
pageName, err := params.GetString(args, "pageName")
if err != nil {
return to.ErrorResult(err)
}
content, err := params.GetString(args, "content")
if err != nil {
return to.ErrorResult(err)
}
requestBody := map[string]string{
"content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
}
// If title is given, use it. Otherwise, keep current page name
if title, ok := args["title"].(string); ok && title != "" {
requestBody["title"] = title
} else {
requestBody["title"] = pageName
}
if message, ok := args["message"].(string); ok && message != "" {
requestBody["message"] = message
} else {
requestBody["message"] = fmt.Sprintf("Update wiki page '%s'", pageName)
}
var result any
_, err = gitea.DoJSON(ctx, "PATCH", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(pageName)), nil, requestBody, &result)
if err != nil {
return to.ErrorResult(fmt.Errorf("update wiki page err: %v", err))
}
return to.TextResult(result)
}
func deleteWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
pageName, err := params.GetString(args, "pageName")
if err != nil {
return to.ErrorResult(err)
}
_, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(pageName)), nil, nil, nil)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete wiki page err: %v", err))
}
return to.TextResult(map[string]string{"message": "Wiki page deleted successfully"})
}

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)
}
})
}
}

View File

@@ -0,0 +1,18 @@
package annotation
import "github.com/mark3labs/mcp-go/mcp"
func ReadOnly(title string) mcp.ToolAnnotation {
t := true
return mcp.ToolAnnotation{Title: title, ReadOnlyHint: &t}
}
func Write(title string) mcp.ToolAnnotation {
f := false
return mcp.ToolAnnotation{Title: title, ReadOnlyHint: &f}
}
func Destructive(title string) mcp.ToolAnnotation {
f, t := false, true
return mcp.ToolAnnotation{Title: title, ReadOnlyHint: &f, DestructiveHint: &t}
}

7
pkg/context/context.go Normal file
View File

@@ -0,0 +1,7 @@
package context
type contextKey string
const (
TokenContextKey = contextKey("token")
)

14
pkg/flag/flag.go Normal file
View File

@@ -0,0 +1,14 @@
package flag
var (
Host string
Port int
Token string
Version string
Mode string
Insecure bool
ReadOnly bool
Debug bool
AllowedTools map[string]struct{}
)

82
pkg/gitea/gitea.go Normal file
View File

@@ -0,0 +1,82 @@
package gitea
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net/http"
"sync"
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.dev/sdk"
)
var (
clientCache sync.Map // token -> *gitea.Client
sharedTransOnce sync.Once
sharedTrans *http.Transport
)
func sharedTransport() *http.Transport {
sharedTransOnce.Do(func() {
sharedTrans = http.DefaultTransport.(*http.Transport).Clone()
if flag.Insecure {
sharedTrans.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // user-requested insecure mode
}
})
return sharedTrans
}
// NewClient returns a cached *gitea.Client keyed by host+token. The SDK's per-client
// version cache and the shared transport let us reuse keep-alive connections
// and avoid the SDK's /api/v1/version preflight on every tool call.
func NewClient(token string) (*gitea.Client, error) {
key := flag.Host + "\x00" + token
if v, ok := clientCache.Load(key); ok {
return v.(*gitea.Client), nil
}
httpClient := &http.Client{
Transport: sharedTransport(),
CheckRedirect: checkRedirect,
}
opts := []gitea.ClientOption{
gitea.SetToken(token),
gitea.SetHTTPClient(httpClient),
}
if flag.Debug {
opts = append(opts, gitea.SetDebugMode())
}
client, err := gitea.NewClient(flag.Host, opts...)
if err != nil {
return nil, fmt.Errorf("create gitea client err: %w", err)
}
client.SetUserAgent("gitea-mcp-server/" + flag.Version)
actual, _ := clientCache.LoadOrStore(key, client)
return actual.(*gitea.Client), nil
}
// 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.
func checkRedirect(_ *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
if via[0].Method != http.MethodGet && via[0].Method != http.MethodHead {
return http.ErrUseLastResponse
}
return nil
}
func ClientFromContext(ctx context.Context) (*gitea.Client, error) {
token, ok := ctx.Value(mcpContext.TokenContextKey).(string)
if !ok {
token = flag.Token
}
return NewClient(token)
}

120
pkg/gitea/redirect_test.go Normal file
View File

@@ -0,0 +1,120 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"gitea.com/gitea/gitea-mcp/pkg/flag"
)
func TestCheckRedirect(t *testing.T) {
for _, tc := range []struct {
name string
method string
wantErr error
}{
{"allows GET", http.MethodGet, nil},
{"allows HEAD", http.MethodHead, nil},
{"blocks PATCH", http.MethodPatch, http.ErrUseLastResponse},
{"blocks POST", http.MethodPost, http.ErrUseLastResponse},
{"blocks PUT", http.MethodPut, http.ErrUseLastResponse},
{"blocks DELETE", http.MethodDelete, http.ErrUseLastResponse},
} {
t.Run(tc.name, func(t *testing.T) {
via := []*http.Request{{Method: tc.method}}
err := checkRedirect(nil, via)
if err != tc.wantErr {
t.Fatalf("expected %v, got %v", tc.wantErr, err)
}
})
}
t.Run("stops after 10 redirects", func(t *testing.T) {
via := make([]*http.Request, 10)
for i := range via {
via[i] = &http.Request{Method: http.MethodGet}
}
err := checkRedirect(nil, via)
if err == nil || err == http.ErrUseLastResponse {
t.Fatalf("expected redirect limit error, got %v", err)
}
})
}
// TestDoJSON_RepoRenameRedirect is a regression test for the bug where a PATCH
// request to a renamed repo got a 301 redirect, Go's http.Client silently
// changed the method to GET, and the write appeared to succeed without error.
func TestDoJSON_RepoRenameRedirect(t *testing.T) {
// Simulate a Gitea API that returns 301 for the old repo name (like a renamed repo).
mux := http.NewServeMux()
mux.HandleFunc("PATCH /api/v1/repos/owner/old-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/api/v1/repos/owner/new-name/pulls/1", http.StatusMovedPermanently)
})
mux.HandleFunc("PATCH /api/v1/repos/owner/new-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"id":1,"title":"updated"}`)
})
mux.HandleFunc("GET /api/v1/repos/owner/new-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"id":1,"title":"not-updated"}`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
origHost := flag.Host
defer func() { flag.Host = origHost }()
flag.Host = srv.URL
var result map[string]any
status, err := DoJSON(context.Background(), http.MethodPatch, "repos/owner/old-name/pulls/1", nil, map[string]string{"title": "updated"}, &result)
if err != nil {
// The redirect should be blocked, returning the 301 response directly.
// DoJSON treats non-2xx as an error, which is the correct behavior.
if status != http.StatusMovedPermanently {
t.Fatalf("expected status 301, got %d (err: %v)", status, err)
}
return
}
// If we reach here without error, the redirect was followed. Verify the
// method was preserved (title should be "updated", not "not-updated").
title, _ := result["title"].(string)
if title == "not-updated" {
t.Fatal("PATCH was silently converted to GET on 301 redirect — write was lost")
}
}
// TestDoJSON_GETRedirectFollowed verifies that GET requests still follow redirects normally.
func TestDoJSON_GETRedirectFollowed(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/repos/owner/old-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/api/v1/repos/owner/new-name/pulls/1", http.StatusMovedPermanently)
})
mux.HandleFunc("GET /api/v1/repos/owner/new-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "title": "found"})
})
srv := httptest.NewServer(mux)
defer srv.Close()
origHost := flag.Host
defer func() { flag.Host = origHost }()
flag.Host = srv.URL
var result map[string]any
status, err := DoJSON(context.Background(), http.MethodGet, "repos/owner/old-name/pulls/1", nil, nil, &result)
if err != nil {
t.Fatalf("GET redirect should be followed, got error: %v (status %d)", err, status)
}
title, _ := result["title"].(string)
if title != "found" {
t.Fatalf("expected title 'found', got %q", title)
}
}

184
pkg/gitea/rest.go Normal file
View File

@@ -0,0 +1,184 @@
package gitea
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
"gitea.com/gitea/gitea-mcp/pkg/flag"
)
const (
httpClientTimeout = 60 * time.Second
errBodySnippetSize = 8192
)
type HTTPError struct {
StatusCode int
Body string
}
func (e *HTTPError) Error() string {
if e.Body == "" {
return fmt.Sprintf("request failed with status %d", e.StatusCode)
}
return fmt.Sprintf("request failed with status %d: %s", e.StatusCode, e.Body)
}
func tokenFromContext(ctx context.Context) string {
if ctx != nil {
if token, ok := ctx.Value(mcpContext.TokenContextKey).(string); ok && token != "" {
return token
}
}
return flag.Token
}
var (
restClientOnce sync.Once
restClient *http.Client
)
func restHTTPClient() *http.Client {
restClientOnce.Do(func() {
restClient = &http.Client{
Transport: sharedTransport(),
Timeout: httpClientTimeout,
CheckRedirect: checkRedirect,
}
})
return restClient
}
func buildAPIURL(path string, query url.Values) (string, error) {
host := strings.TrimRight(flag.Host, "/")
if host == "" {
return "", errors.New("gitea host is empty")
}
p := strings.TrimLeft(path, "/")
u, err := url.Parse(fmt.Sprintf("%s/api/v1/%s", host, p))
if err != nil {
return "", err
}
if query != nil {
u.RawQuery = query.Encode()
}
return u.String(), nil
}
// DoJSON performs an API request and decodes a JSON response into respOut (if non-nil).
// It returns the HTTP status code.
func DoJSON(ctx context.Context, method, path string, query url.Values, body, respOut any) (int, error) {
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return 0, fmt.Errorf("marshal request body: %w", err)
}
bodyReader = bytes.NewReader(b)
}
u, err := buildAPIURL(path, query)
if err != nil {
return 0, err
}
req, err := http.NewRequestWithContext(ctx, method, u, bodyReader)
if err != nil {
return 0, fmt.Errorf("create request: %w", err)
}
token := tokenFromContext(ctx)
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
client := restHTTPClient()
resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodySnippet, _ := io.ReadAll(io.LimitReader(resp.Body, errBodySnippetSize))
return resp.StatusCode, &HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(bodySnippet))}
}
if respOut == nil {
_, _ = io.Copy(io.Discard, resp.Body) // best-effort
return resp.StatusCode, nil
}
if err := json.NewDecoder(resp.Body).Decode(respOut); err != nil {
return resp.StatusCode, fmt.Errorf("decode response: %w", err)
}
return resp.StatusCode, nil
}
// DoBytes performs an API request and returns the raw response bytes.
// It returns the HTTP status code.
func DoBytes(ctx context.Context, method, path string, query url.Values, body any, accept string) ([]byte, int, error) {
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, 0, fmt.Errorf("marshal request body: %w", err)
}
bodyReader = bytes.NewReader(b)
}
u, err := buildAPIURL(path, query)
if err != nil {
return nil, 0, err
}
req, err := http.NewRequestWithContext(ctx, method, u, bodyReader)
if err != nil {
return nil, 0, fmt.Errorf("create request: %w", err)
}
token := tokenFromContext(ctx)
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
if accept != "" {
req.Header.Set("Accept", accept)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
client := restHTTPClient()
resp, err := client.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodySnippet := respBytes
if len(bodySnippet) > errBodySnippetSize {
bodySnippet = bodySnippet[:errBodySnippetSize]
}
return nil, resp.StatusCode, &HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(bodySnippet))}
}
return respBytes, resp.StatusCode, nil
}

30
pkg/gitea/rest_test.go Normal file
View File

@@ -0,0 +1,30 @@
package gitea
import (
"context"
"testing"
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
"gitea.com/gitea/gitea-mcp/pkg/flag"
)
func TestTokenFromContext(t *testing.T) {
orig := flag.Token
defer func() { flag.Token = orig }()
flag.Token = "flag-token"
t.Run("context token wins", func(t *testing.T) {
ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "ctx-token")
if got := tokenFromContext(ctx); got != "ctx-token" {
t.Fatalf("tokenFromContext() = %q, want %q", got, "ctx-token")
}
})
t.Run("fallback to flag token", func(t *testing.T) {
ctx := context.Background()
if got := tokenFromContext(ctx); got != "flag-token" {
t.Fatalf("tokenFromContext() = %q, want %q", got, "flag-token")
}
})
}

120
pkg/log/log.go Normal file
View File

@@ -0,0 +1,120 @@
package log
import (
"os"
"sync"
"time"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
)
var (
defaultLoggerOnce sync.Once
defaultLogger *zap.Logger
)
func Default() *zap.Logger {
defaultLoggerOnce.Do(func() {
if defaultLogger != nil {
return
}
ec := zap.NewProductionEncoderConfig()
ec.EncodeTime = zapcore.TimeEncoderOfLayout(time.DateTime)
ec.EncodeLevel = zapcore.CapitalLevelEncoder
var ws zapcore.WriteSyncer
var wss []zapcore.WriteSyncer
home, _ := os.UserHomeDir()
if home == "" {
home = os.TempDir()
}
logDir := home + "/.gitea-mcp"
if err := os.MkdirAll(logDir, 0o700); err != nil {
// Fallback to temp directory if creation fails
logDir = os.TempDir()
}
wss = append(wss, zapcore.AddSync(&lumberjack.Logger{
Filename: logDir + "/gitea-mcp.log",
MaxSize: 100,
MaxBackups: 10,
MaxAge: 30,
}))
if flag.Mode == "http" {
wss = append(wss, zapcore.AddSync(os.Stdout))
}
ws = zapcore.NewMultiWriteSyncer(wss...)
enc := zapcore.NewConsoleEncoder(ec)
var level zapcore.Level
if flag.Debug {
level = zapcore.DebugLevel
} else {
level = zapcore.InfoLevel
}
core := zapcore.NewCore(enc, ws, level)
options := []zap.Option{
zap.AddStacktrace(zapcore.DPanicLevel),
zap.AddCaller(),
zap.AddCallerSkip(1),
}
defaultLogger = zap.New(core, options...)
})
return defaultLogger
}
func SetDefault(logger *zap.Logger) {
if logger != nil {
defaultLogger = logger
}
}
func Debug(msg string, fields ...zap.Field) {
Default().Debug(msg, fields...)
}
func Info(msg string, fields ...zap.Field) {
Default().Info(msg, fields...)
}
func Warn(msg string, fields ...zap.Field) {
Default().Warn(msg, fields...)
}
func Error(msg string, fields ...zap.Field) {
Default().Error(msg, fields...)
}
func Panic(msg string, fields ...zap.Field) {
Default().Panic(msg, fields...)
}
func Debugf(format string, args ...any) {
Default().Sugar().Debugf(format, args...)
}
func Infof(format string, args ...any) {
Default().Sugar().Infof(format, args...)
}
func Warnf(format string, args ...any) {
Default().Sugar().Warnf(format, args...)
}
func Errorf(format string, args ...any) {
Default().Sugar().Errorf(format, args...)
}
func Fatalf(format string, args ...any) {
Default().Sugar().Fatalf(format, args...)
}

156
pkg/params/params.go Normal file
View File

@@ -0,0 +1,156 @@
package params
import (
"fmt"
"strconv"
"time"
)
// Shared parameter description strings used across tools. Extracted to avoid
// repeating the same boilerplate in every tool schema (saves tokens in the
// tool list sent to MCP clients).
const (
OwnerDesc = "repo owner"
RepoDesc = "repo name"
PageDesc = "page"
PaginationDesc = "results per page"
)
// GetString extracts a required string parameter. Empty strings are treated as missing.
func GetString(args map[string]any, key string) (string, error) {
val, ok := args[key].(string)
if !ok || val == "" {
return "", fmt.Errorf("%s is required", key)
}
return val, nil
}
func GetOptionalString(args map[string]any, key, defaultVal string) string {
if val, ok := args[key].(string); ok {
return val
}
return defaultVal
}
func GetStringSlice(args map[string]any, key string) []string {
val, ok := args[key]
if !ok {
return nil
}
sliceVal, ok := val.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(sliceVal))
for _, item := range sliceVal {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
func GetPagination(args map[string]any, defaultPageSize int64) (page, pageSize int) {
return int(GetOptionalInt(args, "page", 1)), int(GetOptionalInt(args, "per_page", defaultPageSize))
}
// ToInt64 accepts float64 (JSON number) and string representations.
func ToInt64(val any) (int64, bool) {
switch v := val.(type) {
case float64:
return int64(v), true
case string:
i, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, false
}
return i, true
default:
return 0, false
}
}
// GetIndex extracts a required integer. Accepts numeric or string forms — LLM callers
// often pass identifiers like issue/PR numbers as strings.
func GetIndex(args map[string]any, key string) (int64, error) {
val, exists := args[key]
if !exists {
return 0, fmt.Errorf("%s is required", key)
}
if i, ok := ToInt64(val); ok {
return i, nil
}
if s, ok := val.(string); ok {
return 0, fmt.Errorf("%s must be a valid integer (got %q)", key, s)
}
return 0, fmt.Errorf("%s must be a number or numeric string", key)
}
func GetInt64Slice(args map[string]any, key string) ([]int64, error) {
raw, ok := args[key].([]any)
if !ok {
return nil, fmt.Errorf("%s (array of IDs) is required", key)
}
out := make([]int64, 0, len(raw))
for _, v := range raw {
id, ok := ToInt64(v)
if !ok {
return nil, fmt.Errorf("invalid ID in %s array", key)
}
out = append(out, id)
}
return out, nil
}
// GetOptionalTime parses RFC3339, 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
}
func GetOptionalInt(args map[string]any, key string, defaultVal int64) int64 {
val, exists := args[key]
if !exists {
return defaultVal
}
if i, ok := ToInt64(val); ok {
return i
}
return defaultVal
}
// GetOptionalBoolPtr is for SDK fields where nil/false/true are distinct (e.g. "no change" vs "set to false").
func GetOptionalBoolPtr(args map[string]any, key string) *bool {
if v, ok := args[key].(bool); ok {
return &v
}
return nil
}
// GetOptionalStringPtr returns nil when the key is missing OR the value is an empty string.
// Use this for create/fork-style fields where "" is meaningless (e.g. fork target name).
func GetOptionalStringPtr(args map[string]any, key string) *string {
if v, ok := args[key].(string); ok && v != "" {
return &v
}
return nil
}
// GetPresentStringPtr returns &v whenever the key is present as a string, including "".
// Use this for PATCH-style fields where the SDK distinguishes "no change" (nil) from
// "set to empty" (&""), e.g. clearing an issue body or label description.
func GetPresentStringPtr(args map[string]any, key string) *string {
if v, ok := args[key].(string); ok {
return &v
}
return nil
}

208
pkg/params/params_test.go Normal file
View File

@@ -0,0 +1,208 @@
package params
import (
"strings"
"testing"
)
func TestGetPagination(t *testing.T) {
page, perPage := GetPagination(map[string]any{"page": float64(2), "per_page": float64(40)}, 30)
if page != 2 || perPage != 40 {
t.Errorf("GetPagination = (%d, %d), want (2, 40)", page, perPage)
}
page, perPage = GetPagination(map[string]any{}, 30)
if page != 1 || perPage != 30 {
t.Errorf("GetPagination defaults = (%d, %d), want (1, 30)", page, perPage)
}
}
func TestToInt64(t *testing.T) {
tests := []struct {
name string
val any
want int64
ok bool
}{
{"float64", float64(42), 42, true},
{"float64 zero", float64(0), 0, true},
{"float64 negative", float64(-5), -5, true},
{"string", "123", 123, true},
{"string zero", "0", 0, true},
{"string negative", "-10", -10, true},
{"invalid string", "abc", 0, false},
{"decimal string", "1.5", 0, false},
{"bool", true, 0, false},
{"nil", nil, 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := ToInt64(tt.val)
if ok != tt.ok {
t.Errorf("ToInt64() ok = %v, want %v", ok, tt.ok)
}
if got != tt.want {
t.Errorf("ToInt64() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetOptionalInt(t *testing.T) {
tests := []struct {
name string
args map[string]any
key string
defaultVal int64
want int64
}{
{"present float64", map[string]any{"page": float64(3)}, "page", 1, 3},
{"present string", map[string]any{"page": "5"}, "page", 1, 5},
{"missing key", map[string]any{}, "page", 1, 1},
{"invalid string", map[string]any{"page": "abc"}, "page", 1, 1},
{"invalid type", map[string]any{"page": true}, "page", 1, 1},
{"zero value", map[string]any{"id": float64(0)}, "id", 99, 0},
{"string zero", map[string]any{"id": "0"}, "id", 99, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetOptionalInt(tt.args, tt.key, tt.defaultVal)
if got != tt.want {
t.Errorf("GetOptionalInt() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetOptionalStringPtr(t *testing.T) {
if p := GetOptionalStringPtr(map[string]any{}, "k"); p != nil {
t.Errorf("missing key: got %v, want nil", p)
}
if p := GetOptionalStringPtr(map[string]any{"k": ""}, "k"); p != nil {
t.Errorf("empty string: got %v, want nil", p)
}
if p := GetOptionalStringPtr(map[string]any{"k": 42}, "k"); p != nil {
t.Errorf("non-string: got %v, want nil", p)
}
if p := GetOptionalStringPtr(map[string]any{"k": nil}, "k"); p != nil {
t.Errorf("nil value (JSON null): got %v, want nil", p)
}
if p := GetOptionalStringPtr(map[string]any{"k": "x"}, "k"); p == nil || *p != "x" {
t.Errorf("non-empty: got %v, want &\"x\"", p)
}
}
func TestGetPresentStringPtr(t *testing.T) {
if p := GetPresentStringPtr(map[string]any{}, "k"); p != nil {
t.Errorf("missing key: got %v, want nil", p)
}
if p := GetPresentStringPtr(map[string]any{"k": 42}, "k"); p != nil {
t.Errorf("non-string: got %v, want nil", p)
}
if p := GetPresentStringPtr(map[string]any{"k": nil}, "k"); p != nil {
t.Errorf("nil value (JSON null): got %v, want nil", p)
}
if p := GetPresentStringPtr(map[string]any{"k": ""}, "k"); p == nil || *p != "" {
t.Errorf("empty string: got %v, want &\"\"", p)
}
if p := GetPresentStringPtr(map[string]any{"k": "x"}, "k"); p == nil || *p != "x" {
t.Errorf("non-empty: got %v, want &\"x\"", p)
}
}
func TestGetIndex(t *testing.T) {
tests := []struct {
name string
args map[string]any
key string
wantIndex int64
wantErr bool
errMsg string
}{
{
name: "valid float64",
args: map[string]any{"index": float64(123)},
key: "index",
wantIndex: 123,
wantErr: false,
},
{
name: "valid string",
args: map[string]any{"index": "456"},
key: "index",
wantIndex: 456,
wantErr: false,
},
{
name: "valid string with large number",
args: map[string]any{"index": "999999"},
key: "index",
wantIndex: 999999,
wantErr: false,
},
{
name: "missing parameter",
args: map[string]any{},
key: "index",
wantErr: true,
errMsg: "index is required",
},
{
name: "invalid string (not a number)",
args: map[string]any{"index": "abc"},
key: "index",
wantErr: true,
errMsg: "must be a valid integer",
},
{
name: "invalid string (decimal)",
args: map[string]any{"index": "12.34"},
key: "index",
wantErr: true,
errMsg: "must be a valid integer",
},
{
name: "invalid type (bool)",
args: map[string]any{"index": true},
key: "index",
wantErr: true,
errMsg: "must be a number or numeric string",
},
{
name: "invalid type (map)",
args: map[string]any{"index": map[string]string{"foo": "bar"}},
key: "index",
wantErr: true,
errMsg: "must be a number or numeric string",
},
{
name: "custom key name",
args: map[string]any{"pr_index": "789"},
key: "pr_index",
wantIndex: 789,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotIndex, err := GetIndex(tt.args, tt.key)
if tt.wantErr {
if err == nil {
t.Errorf("GetIndex() expected error but got nil")
return
}
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("GetIndex() error = %v, want error containing %q", err, tt.errMsg)
}
return
}
if err != nil {
t.Errorf("GetIndex() unexpected error = %v", err)
return
}
if gotIndex != tt.wantIndex {
t.Errorf("GetIndex() = %v, want %v", gotIndex, tt.wantIndex)
}
})
}
}

135
pkg/slim/slim.go Normal file
View File

@@ -0,0 +1,135 @@
package slim
import (
"fmt"
"strings"
gitea_sdk "gitea.dev/sdk"
)
func UserLogin(u *gitea_sdk.User) string {
if u == nil {
return ""
}
return u.UserName
}
func UserLogins(users []*gitea_sdk.User) []string {
if len(users) == 0 {
return nil
}
out := make([]string, 0, len(users))
for _, u := range users {
if u != nil {
out = append(out, u.UserName)
}
}
return out
}
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 BodyWithAttachments(body string, atts []*gitea_sdk.Attachment) string {
links := make([]string, 0, len(atts))
for _, a := range atts {
if a == nil || a.DownloadURL == "" {
continue
}
links = append(links, fmt.Sprintf("[%s](%s)", a.Name, a.DownloadURL))
}
if len(links) == 0 {
return body
}
joined := strings.Join(links, "\n")
if body == "" {
return joined
}
return body + "\n\n" + joined
}
func UserDetail(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,
"html_url": u.HTMLURL,
"is_admin": u.IsAdmin,
}
}
func Repo(r *gitea_sdk.Repository) map[string]any {
if r == nil {
return nil
}
m := map[string]any{
"id": r.ID,
"full_name": r.FullName,
"description": r.Description,
"html_url": r.HTMLURL,
"clone_url": r.CloneURL,
"ssh_url": r.SSHURL,
"default_branch": r.DefaultBranch,
"private": r.Private,
"fork": r.Fork,
"archived": r.Archived,
"language": r.Language,
"stars_count": r.Stars,
"forks_count": r.Forks,
"open_issues_count": r.OpenIssues,
"open_pr_counter": r.OpenPulls,
"created_at": r.Created,
"updated_at": r.Updated,
}
if r.Owner != nil {
m["owner"] = r.Owner.UserName
}
if len(r.Topics) > 0 {
m["topics"] = r.Topics
}
return m
}
func Repos(repos []*gitea_sdk.Repository) []map[string]any {
out := make([]map[string]any, 0, len(repos))
for _, r := range repos {
out = append(out, Repo(r))
}
return out
}
func Label(l *gitea_sdk.Label) map[string]any {
if l == nil {
return nil
}
return map[string]any{
"id": l.ID,
"name": l.Name,
"color": l.Color,
"description": l.Description,
"exclusive": l.Exclusive,
}
}
func Labels(labels []*gitea_sdk.Label) []map[string]any {
out := make([]map[string]any, 0, len(labels))
for _, l := range labels {
out = append(out, Label(l))
}
return out
}

110
pkg/slim/slim_test.go Normal file
View File

@@ -0,0 +1,110 @@
package slim
import (
"testing"
gitea_sdk "gitea.dev/sdk"
)
func TestUserDetail(t *testing.T) {
u := &gitea_sdk.User{
ID: 42,
UserName: "alice",
FullName: "Alice Smith",
Email: "alice@example.com",
AvatarURL: "https://gitea.com/avatars/42",
HTMLURL: "https://gitea.com/alice",
IsAdmin: true,
}
m := UserDetail(u)
if m["id"] != int64(42) {
t.Errorf("expected id 42, got %v", m["id"])
}
if m["login"] != "alice" {
t.Errorf("expected login alice, got %v", m["login"])
}
if m["full_name"] != "Alice Smith" {
t.Errorf("expected full_name Alice Smith, got %v", m["full_name"])
}
if m["is_admin"] != true {
t.Errorf("expected is_admin true, got %v", m["is_admin"])
}
}
func TestUserDetail_Nil(t *testing.T) {
if m := UserDetail(nil); m != nil {
t.Errorf("expected nil for nil user, got %v", m)
}
}
func TestLabel(t *testing.T) {
l := &gitea_sdk.Label{
ID: 1,
Name: "bug",
Color: "#d73a4a",
Description: "Something isn't working",
Exclusive: false,
}
m := Label(l)
if m["name"] != "bug" {
t.Errorf("expected name bug, got %v", m["name"])
}
if m["color"] != "#d73a4a" {
t.Errorf("expected color, got %v", m["color"])
}
}
func TestRepo(t *testing.T) {
r := &gitea_sdk.Repository{
ID: 1,
FullName: "org/repo",
Description: "A test repo",
HTMLURL: "https://gitea.com/org/repo",
CloneURL: "https://gitea.com/org/repo.git",
SSHURL: "git@gitea.com:org/repo.git",
DefaultBranch: "main",
Language: "Go",
Stars: 10,
Forks: 2,
Owner: &gitea_sdk.User{UserName: "org"},
Topics: []string{"mcp", "gitea"},
}
m := Repo(r)
if m["full_name"] != "org/repo" {
t.Errorf("expected full_name org/repo, got %v", m["full_name"])
}
if m["owner"] != "org" {
t.Errorf("expected owner org, got %v", m["owner"])
}
topics := m["topics"].([]string)
if len(topics) != 2 {
t.Errorf("expected 2 topics, got %d", len(topics))
}
}
func TestBodyWithAttachments(t *testing.T) {
atts := []*gitea_sdk.Attachment{
{Name: "shot.png", DownloadURL: "https://example/shot.png"},
{Name: "log.txt", DownloadURL: "https://example/log.txt"},
}
got := BodyWithAttachments("see attached", atts)
want := "see attached\n\n[shot.png](https://example/shot.png)\n[log.txt](https://example/log.txt)"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
if got := BodyWithAttachments("only body", nil); got != "only body" {
t.Errorf("nil attachments should return body unchanged, got %q", got)
}
if got := BodyWithAttachments("", atts); got != "[shot.png](https://example/shot.png)\n[log.txt](https://example/log.txt)" {
t.Errorf("empty body should drop separator, got %q", got)
}
skipped := []*gitea_sdk.Attachment{nil, {Name: "noop", DownloadURL: ""}}
if got := BodyWithAttachments("body", skipped); got != "body" {
t.Errorf("nil/empty-URL attachments should be skipped, got %q", got)
}
}

27
pkg/to/to.go Normal file
View File

@@ -0,0 +1,27 @@
package to
import (
"encoding/json"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.com/gitea/gitea-mcp/pkg/log"
"github.com/mark3labs/mcp-go/mcp"
)
func TextResult(v any) (*mcp.CallToolResult, error) {
resultBytes, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("marshal result err: %v", err)
}
if flag.Debug {
log.Debugf("Text Result: %s", string(resultBytes))
}
return mcp.NewToolResultText(string(resultBytes)), nil
}
func ErrorResult(err error) (*mcp.CallToolResult, error) {
log.Errorf("%s", err.Error())
return nil, err
}

78
pkg/tool/tool.go Normal file
View File

@@ -0,0 +1,78 @@
package tool
import (
"slices"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.com/gitea/gitea-mcp/pkg/log"
"github.com/mark3labs/mcp-go/server"
)
type Tool struct {
write []server.ServerTool
read []server.ServerTool
}
func New() *Tool {
return &Tool{
write: make([]server.ServerTool, 0, 100),
read: make([]server.ServerTool, 0, 100),
}
}
func (t *Tool) RegisterWrite(s server.ServerTool) {
t.write = append(t.write, s)
}
func (t *Tool) RegisterRead(s server.ServerTool) {
t.read = append(t.read, s)
}
func (t *Tool) Tools() []server.ServerTool {
all := make([]server.ServerTool, 0, len(t.write)+len(t.read))
if !flag.ReadOnly {
all = append(all, t.write...)
}
all = append(all, t.read...)
if len(flag.AllowedTools) == 0 {
return all
}
filtered := make([]server.ServerTool, 0, len(all))
for _, st := range all {
if _, ok := flag.AllowedTools[st.Tool.Name]; ok {
filtered = append(filtered, st)
}
}
return filtered
}
// WarnUnmatchedAllowedTools logs any names in flag.AllowedTools that don't
// match a tool registered on any of the given domains. No-op if the allowlist
// is empty.
func WarnUnmatchedAllowedTools(domains ...*Tool) {
if len(flag.AllowedTools) == 0 {
return
}
known := map[string]struct{}{}
for _, d := range domains {
for _, st := range d.read {
known[st.Tool.Name] = struct{}{}
}
for _, st := range d.write {
known[st.Tool.Name] = struct{}{}
}
}
var unmatched []string
for name := range flag.AllowedTools {
if _, ok := known[name]; !ok {
unmatched = append(unmatched, name)
}
}
if len(unmatched) == 0 {
return
}
slices.Sort(unmatched)
log.Warnf("Unknown tools in --tools allowlist (ignored): %s", strings.Join(unmatched, ", "))
}

100
pkg/tool/tool_test.go Normal file
View File

@@ -0,0 +1,100 @@
package tool
import (
"slices"
"testing"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
func makeTool(name string) server.ServerTool {
return server.ServerTool{Tool: mcp.NewTool(name)}
}
func names(sts []server.ServerTool) []string {
out := make([]string, len(sts))
for i, st := range sts {
out[i] = st.Tool.Name
}
return out
}
func TestTools(t *testing.T) {
tests := []struct {
name string
readOnly bool
allowed map[string]struct{}
read []string
write []string
want []string
}{
{
name: "no filters returns write then read",
read: []string{"r1", "r2"},
write: []string{"w1", "w2"},
want: []string{"w1", "w2", "r1", "r2"},
},
{
name: "read-only excludes write",
readOnly: true,
read: []string{"r1", "r2"},
write: []string{"w1"},
want: []string{"r1", "r2"},
},
{
name: "allowlist keeps only listed",
allowed: map[string]struct{}{"r1": {}, "w1": {}},
read: []string{"r1", "r2"},
write: []string{"w1", "w2"},
want: []string{"w1", "r1"},
},
{
name: "allowlist intersected with read-only drops write entries",
readOnly: true,
allowed: map[string]struct{}{"r1": {}, "w1": {}},
read: []string{"r1", "r2"},
write: []string{"w1", "w2"},
want: []string{"r1"},
},
{
name: "allowlist with only unknown names returns empty",
allowed: map[string]struct{}{"unknown": {}},
read: []string{"r1"},
write: []string{"w1"},
want: []string{},
},
{
name: "empty allowlist map passes through",
allowed: map[string]struct{}{},
read: []string{"r1"},
write: []string{"w1"},
want: []string{"w1", "r1"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
origRO, origAllow := flag.ReadOnly, flag.AllowedTools
t.Cleanup(func() {
flag.ReadOnly, flag.AllowedTools = origRO, origAllow
})
flag.ReadOnly = tt.readOnly
flag.AllowedTools = tt.allowed
tr := New()
for _, n := range tt.read {
tr.RegisterRead(makeTool(n))
}
for _, n := range tt.write {
tr.RegisterWrite(makeTool(n))
}
got := names(tr.Tools())
if !slices.Equal(got, tt.want) {
t.Errorf("Tools() = %v, want %v", got, tt.want)
}
})
}
}