mirror of
https://gitea.com/gitea/gitea-mcp.git
synced 2025-10-14 01:51:50 +00:00
Compare commits
6 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
e4aa29b0f9 | ||
|
32eaf86426 | ||
|
8c028ec48b | ||
|
88471b5de0 | ||
|
e9840cf6c0 | ||
|
95ab3a4b73 |
71
CLAUDE.md
Normal file
71
CLAUDE.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# 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 40+ MCP tools covering:
|
||||
|
||||
- **User**: get_my_user_info, get_user_orgs, search_users
|
||||
- **Repository**: create_repo, fork_repo, list_my_repos, search_repos
|
||||
- **Branches/Tags**: create_branch, delete_branch, list_branches, create_tag, list_tags
|
||||
- **Files**: get_file_content, create_file, update_file, delete_file, get_dir_content
|
||||
- **Issues**: create_issue, list_repo_issues, create_issue_comment, edit_issue
|
||||
- **Pull Requests**: create_pull_request, list_repo_pull_requests, get_pull_request_by_index
|
||||
- **Releases**: create_release, list_releases, get_latest_release
|
||||
- **Wiki**: create_wiki_page, update_wiki_page, list_wiki_pages
|
||||
- **Search**: search_repos, search_users, search_org_teams
|
||||
- **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
|
25
README.md
25
README.md
@@ -133,21 +133,6 @@ To configure the MCP server for Gitea, add the following to your MCP configurati
|
||||
}
|
||||
```
|
||||
|
||||
- **sse mode**
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitea": {
|
||||
"url": "http://localhost:8080/sse",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <your personal access token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **http mode**
|
||||
|
||||
```json
|
||||
@@ -218,13 +203,19 @@ The Gitea MCP Server supports the following tools:
|
||||
| search_org_teams | Organization | Search for teams in an organization |
|
||||
| search_repos | Repository | Search for repositories |
|
||||
| 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 sse mode:
|
||||
To enable debug mode, add the `-d` flag when running the Gitea MCP Server with http mode:
|
||||
|
||||
```sh
|
||||
./gitea-mcp -t sse [--port 8080] --token <your personal access token> -d
|
||||
./gitea-mcp -t http [--port 8080] --token <your personal access token> -d
|
||||
```
|
||||
|
||||
## 🛠 Troubleshooting
|
||||
|
@@ -133,21 +133,6 @@ cp gitea-mcp /usr/local/bin/
|
||||
}
|
||||
```
|
||||
|
||||
- **sse 模式**
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitea": {
|
||||
"url": "http://localhost:8080/sse",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <your personal access token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **http 模式**
|
||||
|
||||
```json
|
||||
@@ -221,10 +206,10 @@ Gitea MCP 服务器支持以下工具:
|
||||
|
||||
## 🐛 调试
|
||||
|
||||
要启用调试模式,请在使用 sse 模式运行 Gitea MCP 服务器时添加 `-d` 标志:
|
||||
要启用调试模式,请在使用 http 模式运行 Gitea MCP 服务器时添加 `-d` 标志:
|
||||
|
||||
```sh
|
||||
./gitea-mcp -t sse [--port 8080] --token <your personal access token> -d
|
||||
./gitea-mcp -t http [--port 8080] --token <your personal access token> -d
|
||||
```
|
||||
|
||||
## 🛠 疑难排解
|
||||
|
@@ -133,21 +133,6 @@ cp gitea-mcp /usr/local/bin/
|
||||
}
|
||||
```
|
||||
|
||||
- **sse 模式**
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitea": {
|
||||
"url": "http://localhost:8080/sse",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <your personal access token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **http 模式**
|
||||
|
||||
```json
|
||||
@@ -221,10 +206,10 @@ Gitea MCP 伺服器支持以下工具:
|
||||
|
||||
## 🐛 調試
|
||||
|
||||
要啟用調試模式,請在使用 sse 模式運行 Gitea MCP 伺服器時添加 `-d` 旗標:
|
||||
要啟用調試模式,請在使用 http 模式運行 Gitea MCP 伺服器時添加 `-d` 旗標:
|
||||
|
||||
```sh
|
||||
./gitea-mcp -t sse [--port 8080] --token <your personal access token> -d
|
||||
./gitea-mcp -t http [--port 8080] --token <your personal access token> -d
|
||||
```
|
||||
|
||||
## 🛠 疑難排解
|
||||
|
@@ -21,13 +21,13 @@ func init() {
|
||||
&flagPkg.Mode,
|
||||
"t",
|
||||
"stdio",
|
||||
"Transport type (stdio, sse or http)",
|
||||
"Transport type (stdio or http)",
|
||||
)
|
||||
flag.StringVar(
|
||||
&flagPkg.Mode,
|
||||
"transport",
|
||||
"stdio",
|
||||
"Transport type (stdio, sse or http)",
|
||||
"Transport type (stdio or http)",
|
||||
)
|
||||
flag.StringVar(
|
||||
&host,
|
||||
@@ -39,7 +39,7 @@ func init() {
|
||||
&port,
|
||||
"port",
|
||||
8080,
|
||||
"see or http port",
|
||||
"http port",
|
||||
)
|
||||
flag.StringVar(
|
||||
&token,
|
||||
|
12
go.mod
12
go.mod
@@ -3,8 +3,8 @@ module gitea.com/gitea/gitea-mcp
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
code.gitea.io/sdk/gitea v0.21.0
|
||||
github.com/mark3labs/mcp-go v0.36.0
|
||||
code.gitea.io/sdk/gitea v0.22.0
|
||||
github.com/mark3labs/mcp-go v0.40.0
|
||||
go.uber.org/zap v1.27.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
)
|
||||
@@ -18,12 +18,12 @@ require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/spf13/cast v1.9.2 // 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.40.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/crypto v0.42.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
29
go.sum
29
go.sum
@@ -1,5 +1,5 @@
|
||||
code.gitea.io/sdk/gitea v0.21.0 h1:69n6oz6kEVHRo1+APQQyizkhrZrLsTLXey9142pfkD4=
|
||||
code.gitea.io/sdk/gitea v0.21.0/go.mod h1:tnBjVhuKJCn8ibdyyhvUyxrR1Ca2KHEoTWoukNhXQPA=
|
||||
code.gitea.io/sdk/gitea v0.22.0 h1:HCKq7bX/HQ85Nw7c/HAhWgRye+vBp5nQOE8Md1+9Ef0=
|
||||
code.gitea.io/sdk/gitea v0.22.0/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
|
||||
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
@@ -22,21 +22,20 @@ github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKe
|
||||
github.com/hashicorp/go-version v1.7.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/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
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.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mark3labs/mcp-go v0.36.0 h1:rIZaijrRYPeSbJG8/qNDe0hWlGrCJ7FWHNMz2SQpTis=
|
||||
github.com/mark3labs/mcp-go v0.36.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
|
||||
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.40.0 h1:M0oqK412OHBKut9JwXSsj4KanSmEKpzoW8TcxoPOkAU=
|
||||
github.com/mark3labs/mcp-go v0.40.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
|
||||
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.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
@@ -52,18 +51,18 @@ go.uber.org/zap v1.27.0/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.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
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.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
|
||||
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
|
||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||
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=
|
||||
|
@@ -4,7 +4,10 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/gitea-mcp/operation/issue"
|
||||
@@ -14,6 +17,7 @@ import (
|
||||
"gitea.com/gitea/gitea-mcp/operation/search"
|
||||
"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"
|
||||
@@ -45,21 +49,40 @@ func RegisterTool(s *server.MCPServer) {
|
||||
// Version Tool
|
||||
s.AddTools(version.Tool.Tools()...)
|
||||
|
||||
// Wiki Tool
|
||||
s.AddTools(wiki.Tool.Tools()...)
|
||||
|
||||
s.DeleteTools("")
|
||||
}
|
||||
|
||||
// parseBearerToken extracts the Bearer token from an Authorization header.
|
||||
// Returns the token and true if valid, empty string and false otherwise.
|
||||
func parseBearerToken(authHeader string) (string, bool) {
|
||||
const bearerPrefix = "Bearer "
|
||||
if len(authHeader) < len(bearerPrefix) || !strings.HasPrefix(authHeader, bearerPrefix) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(authHeader[len(bearerPrefix):])
|
||||
if token == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return token, true
|
||||
}
|
||||
|
||||
func getContextWithToken(ctx context.Context, r *http.Request) context.Context {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return ctx
|
||||
}
|
||||
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
token, ok := parseBearerToken(authHeader)
|
||||
if !ok {
|
||||
return ctx
|
||||
}
|
||||
|
||||
return context.WithValue(ctx, mcpContext.TokenContextKey, parts[1])
|
||||
return context.WithValue(ctx, mcpContext.TokenContextKey, token)
|
||||
}
|
||||
|
||||
func Run() error {
|
||||
@@ -72,15 +95,6 @@ func Run() error {
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
case "sse":
|
||||
sseServer := server.NewSSEServer(
|
||||
mcpServer,
|
||||
server.WithSSEContextFunc(getContextWithToken),
|
||||
)
|
||||
log.Infof("Gitea MCP SSE server listening on :%d", flag.Port)
|
||||
if err := sseServer.Start(fmt.Sprintf(":%d", flag.Port)); err != nil {
|
||||
return err
|
||||
}
|
||||
case "http":
|
||||
httpServer := server.NewStreamableHTTPServer(
|
||||
mcpServer,
|
||||
@@ -89,11 +103,29 @@ func Run() error {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
<-shutdownDone // Wait for shutdown to finish
|
||||
default:
|
||||
return fmt.Errorf("invalid transport type: %s. Must be 'stdio', 'sse' or 'http'", flag.Mode)
|
||||
return fmt.Errorf("invalid transport type: %s. Must be 'stdio' or 'http'", flag.Mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
81
operation/operation_test.go
Normal file
81
operation/operation_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package operation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseBearerToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
wantToken string
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "valid token",
|
||||
header: "Bearer validtoken",
|
||||
wantToken: "validtoken",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "token with spaces trimmed",
|
||||
header: "Bearer spacedToken ",
|
||||
wantToken: "spacedToken",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "lowercase bearer should fail",
|
||||
header: "bearer lowercase",
|
||||
wantToken: "",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
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: "different auth type",
|
||||
header: "Basic dXNlcjpwYXNz",
|
||||
wantToken: "",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "empty header",
|
||||
header: "",
|
||||
wantToken: "",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "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 := parseBearerToken(tt.header)
|
||||
if gotToken != tt.wantToken {
|
||||
t.Errorf("parseBearerToken() token = %q, want %q", gotToken, tt.wantToken)
|
||||
}
|
||||
if gotOK != tt.wantOK {
|
||||
t.Errorf("parseBearerToken() ok = %v, want %v", gotOK, tt.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
363
operation/wiki/wiki.go
Normal file
363
operation/wiki/wiki.go
Normal file
@@ -0,0 +1,363 @@
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/gitea"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/log"
|
||||
"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 (
|
||||
ListWikiPagesToolName = "list_wiki_pages"
|
||||
GetWikiPageToolName = "get_wiki_page"
|
||||
GetWikiRevisionsToolName = "get_wiki_revisions"
|
||||
CreateWikiPageToolName = "create_wiki_page"
|
||||
UpdateWikiPageToolName = "update_wiki_page"
|
||||
DeleteWikiPageToolName = "delete_wiki_page"
|
||||
)
|
||||
|
||||
var (
|
||||
ListWikiPagesTool = mcp.NewTool(
|
||||
ListWikiPagesToolName,
|
||||
mcp.WithDescription("List all wiki pages in a repository"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
)
|
||||
|
||||
GetWikiPageTool = mcp.NewTool(
|
||||
GetWikiPageToolName,
|
||||
mcp.WithDescription("Get a wiki page content and metadata"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithString("pageName", mcp.Required(), mcp.Description("wiki page name")),
|
||||
)
|
||||
|
||||
GetWikiRevisionsTool = mcp.NewTool(
|
||||
GetWikiRevisionsToolName,
|
||||
mcp.WithDescription("Get revisions history of a wiki page"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithString("pageName", mcp.Required(), mcp.Description("wiki page name")),
|
||||
)
|
||||
|
||||
CreateWikiPageTool = mcp.NewTool(
|
||||
CreateWikiPageToolName,
|
||||
mcp.WithDescription("Create a new wiki page"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithString("title", mcp.Required(), mcp.Description("wiki page title")),
|
||||
mcp.WithString("content_base64", mcp.Required(), mcp.Description("page content, base64 encoded")),
|
||||
mcp.WithString("message", mcp.Description("commit message (optional)")),
|
||||
)
|
||||
|
||||
UpdateWikiPageTool = mcp.NewTool(
|
||||
UpdateWikiPageToolName,
|
||||
mcp.WithDescription("Update an existing wiki page"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithString("pageName", mcp.Required(), mcp.Description("current wiki page name")),
|
||||
mcp.WithString("title", mcp.Description("new page title (optional)")),
|
||||
mcp.WithString("content_base64", mcp.Required(), mcp.Description("page content, base64 encoded")),
|
||||
mcp.WithString("message", mcp.Description("commit message (optional)")),
|
||||
)
|
||||
|
||||
DeleteWikiPageTool = mcp.NewTool(
|
||||
DeleteWikiPageToolName,
|
||||
mcp.WithDescription("Delete a wiki page"),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithString("pageName", mcp.Required(), mcp.Description("wiki page name to delete")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
Tool.RegisterRead(server.ServerTool{
|
||||
Tool: ListWikiPagesTool,
|
||||
Handler: ListWikiPagesFn,
|
||||
})
|
||||
Tool.RegisterRead(server.ServerTool{
|
||||
Tool: GetWikiPageTool,
|
||||
Handler: GetWikiPageFn,
|
||||
})
|
||||
Tool.RegisterRead(server.ServerTool{
|
||||
Tool: GetWikiRevisionsTool,
|
||||
Handler: GetWikiRevisionsFn,
|
||||
})
|
||||
Tool.RegisterWrite(server.ServerTool{
|
||||
Tool: CreateWikiPageTool,
|
||||
Handler: CreateWikiPageFn,
|
||||
})
|
||||
Tool.RegisterWrite(server.ServerTool{
|
||||
Tool: UpdateWikiPageTool,
|
||||
Handler: UpdateWikiPageFn,
|
||||
})
|
||||
Tool.RegisterWrite(server.ServerTool{
|
||||
Tool: DeleteWikiPageTool,
|
||||
Handler: DeleteWikiPageFn,
|
||||
})
|
||||
}
|
||||
|
||||
func ListWikiPagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called ListWikiPagesFn")
|
||||
owner, ok := req.GetArguments()["owner"].(string)
|
||||
if !ok {
|
||||
return to.ErrorResult(fmt.Errorf("owner is required"))
|
||||
}
|
||||
repo, ok := req.GetArguments()["repo"].(string)
|
||||
if !ok {
|
||||
return to.ErrorResult(fmt.Errorf("repo is required"))
|
||||
}
|
||||
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
|
||||
// Use direct HTTP request because SDK does not support yet wikis
|
||||
result, err := makeWikiAPIRequest(ctx, client, "GET", fmt.Sprintf("repos/%s/%s/wiki/pages", url.QueryEscape(owner), url.QueryEscape(repo)), nil)
|
||||
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) {
|
||||
log.Debugf("Called GetWikiPageFn")
|
||||
owner, ok := req.GetArguments()["owner"].(string)
|
||||
if !ok {
|
||||
return to.ErrorResult(fmt.Errorf("owner is required"))
|
||||
}
|
||||
repo, ok := req.GetArguments()["repo"].(string)
|
||||
if !ok {
|
||||
return to.ErrorResult(fmt.Errorf("repo is required"))
|
||||
}
|
||||
pageName, ok := req.GetArguments()["pageName"].(string)
|
||||
if !ok {
|
||||
return to.ErrorResult(fmt.Errorf("pageName is required"))
|
||||
}
|
||||
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
|
||||
result, err := makeWikiAPIRequest(ctx, client, "GET", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), nil)
|
||||
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) {
|
||||
log.Debugf("Called GetWikiRevisionsFn")
|
||||
owner, ok := req.GetArguments()["owner"].(string)
|
||||
if !ok {
|
||||
return to.ErrorResult(fmt.Errorf("owner is required"))
|
||||
}
|
||||
repo, ok := req.GetArguments()["repo"].(string)
|
||||
if !ok {
|
||||
return to.ErrorResult(fmt.Errorf("repo is required"))
|
||||
}
|
||||
pageName, ok := req.GetArguments()["pageName"].(string)
|
||||
if !ok {
|
||||
return to.ErrorResult(fmt.Errorf("pageName is required"))
|
||||
}
|
||||
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
|
||||
result, err := makeWikiAPIRequest(ctx, client, "GET", fmt.Sprintf("repos/%s/%s/wiki/revisions/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), nil)
|
||||
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) {
|
||||
log.Debugf("Called CreateWikiPageFn")
|
||||
owner, ok := req.GetArguments()["owner"].(string)
|
||||
if !ok {
|
||||
return to.ErrorResult(fmt.Errorf("owner is required"))
|
||||
}
|
||||
repo, ok := req.GetArguments()["repo"].(string)
|
||||
if !ok {
|
||||
return to.ErrorResult(fmt.Errorf("repo is required"))
|
||||
}
|
||||
title, ok := req.GetArguments()["title"].(string)
|
||||
if !ok {
|
||||
return to.ErrorResult(fmt.Errorf("title is required"))
|
||||
}
|
||||
contentBase64, ok := req.GetArguments()["content_base64"].(string)
|
||||
if !ok {
|
||||
return to.ErrorResult(fmt.Errorf("content_base64 is required"))
|
||||
}
|
||||
|
||||
message, _ := req.GetArguments()["message"].(string)
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("Create wiki page '%s'", title)
|
||||
}
|
||||
|
||||
requestBody := map[string]string{
|
||||
"title": title,
|
||||
"content_base64": contentBase64,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
|
||||
result, err := makeWikiAPIRequest(ctx, client, "POST", fmt.Sprintf("repos/%s/%s/wiki/new", url.QueryEscape(owner), url.QueryEscape(repo)), requestBody)
|
||||
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) {
|
||||
log.Debugf("Called UpdateWikiPageFn")
|
||||
owner, ok := req.GetArguments()["owner"].(string)
|
||||
if !ok {
|
||||
return to.ErrorResult(fmt.Errorf("owner is required"))
|
||||
}
|
||||
repo, ok := req.GetArguments()["repo"].(string)
|
||||
if !ok {
|
||||
return to.ErrorResult(fmt.Errorf("repo is required"))
|
||||
}
|
||||
pageName, ok := req.GetArguments()["pageName"].(string)
|
||||
if !ok {
|
||||
return to.ErrorResult(fmt.Errorf("pageName is required"))
|
||||
}
|
||||
contentBase64, ok := req.GetArguments()["content_base64"].(string)
|
||||
if !ok {
|
||||
return to.ErrorResult(fmt.Errorf("content_base64 is required"))
|
||||
}
|
||||
|
||||
requestBody := map[string]string{
|
||||
"content_base64": contentBase64,
|
||||
}
|
||||
|
||||
// If title is given, use it. Otherwise, keep current page name
|
||||
if title, ok := req.GetArguments()["title"].(string); ok && title != "" {
|
||||
requestBody["title"] = title
|
||||
} else {
|
||||
// Utiliser pageName comme fallback pour éviter "unnamed"
|
||||
requestBody["title"] = pageName
|
||||
}
|
||||
|
||||
if message, ok := req.GetArguments()["message"].(string); ok && message != "" {
|
||||
requestBody["message"] = message
|
||||
} else {
|
||||
requestBody["message"] = fmt.Sprintf("Update wiki page '%s'", pageName)
|
||||
}
|
||||
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
|
||||
result, err := makeWikiAPIRequest(ctx, client, "PATCH", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), requestBody)
|
||||
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) {
|
||||
log.Debugf("Called DeleteWikiPageFn")
|
||||
owner, ok := req.GetArguments()["owner"].(string)
|
||||
if !ok {
|
||||
return to.ErrorResult(fmt.Errorf("owner is required"))
|
||||
}
|
||||
repo, ok := req.GetArguments()["repo"].(string)
|
||||
if !ok {
|
||||
return to.ErrorResult(fmt.Errorf("repo is required"))
|
||||
}
|
||||
pageName, ok := req.GetArguments()["pageName"].(string)
|
||||
if !ok {
|
||||
return to.ErrorResult(fmt.Errorf("pageName is required"))
|
||||
}
|
||||
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||
}
|
||||
|
||||
_, err = makeWikiAPIRequest(ctx, client, "DELETE", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), 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"})
|
||||
}
|
||||
|
||||
// Helper function to make HTTP requests to Gitea Wiki API
|
||||
func makeWikiAPIRequest(ctx context.Context, client interface{}, method, path string, body interface{}) (interface{}, error) {
|
||||
// Use flags to get base URL and token
|
||||
apiURL := fmt.Sprintf("%s/api/v1/%s", flag.Host, path)
|
||||
|
||||
httpClient := &http.Client{}
|
||||
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
bodyBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
reqBody = strings.NewReader(string(bodyBytes))
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, apiURL, reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", fmt.Sprintf("token %s", flag.Token))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to make request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("API request failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if method == "DELETE" {
|
||||
return map[string]string{"message": "success"}, nil
|
||||
}
|
||||
|
||||
var result interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
@@ -48,7 +48,7 @@ func Default() *zap.Logger {
|
||||
MaxAge: 30,
|
||||
}))
|
||||
|
||||
if flag.Mode == "http" || flag.Mode == "sse" {
|
||||
if flag.Mode == "http" {
|
||||
wss = append(wss, zapcore.AddSync(os.Stdout))
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user