6 Commits
v0.4.0 ... main

Author SHA1 Message Date
appleboy
e4aa29b0f9 docs: document development workflow and MCP tool usage
- Add CLAUDE.md file with guidance for using Claude Code in this repository
- Document development commands, architecture overview, and tool organization
- List available MCP tools with their main categories and functions
- Provide notes on configuration, authentication, and common development practices

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2025-09-27 19:17:00 +08:00
appleboy
32eaf86426 feat: implement graceful server shutdown on interrupt or SIGTERM (#98)
- Add graceful shutdown for HTTP server on interrupt or SIGTERM signals
- Wait for server to finish shutting down before exiting

Signed-off-by: appleboy <appleboy.tw@gmail.com>

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/98
Co-authored-by: appleboy <appleboy.tw@gmail.com>
Co-committed-by: appleboy <appleboy.tw@gmail.com>
2025-09-27 08:56:41 +00:00
appleboy
8c028ec48b chore: update project and indirect dependencies to latest versions
- Update dependencies to newer versions for multiple modules
- Upgrade code.gitea.io/sdk/gitea to v0.22.0
- Upgrade github.com/mark3labs/mcp-go to v0.40.0
- Update indirect dependencies: github.com/mailru/easyjson, github.com/spf13/cast, golang.org/x/crypto, and golang.org/x/sys

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2025-09-27 16:17:14 +08:00
appleboy
88471b5de0 refactor: remove SSE transport support from code and documentation (#97)
- Remove support and documentation for sse mode across all language README files
- Update CLI flags and help text to exclude references to sse mode
- Remove SSE server initialization in operation logic
- Adjust error messages to only mention stdio and http transport types
- Update logging setup to remove sse mode conditional logging

See the latest documentation: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports

Signed-off-by: appleboy <appleboy.tw@gmail.com>

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/97
Co-authored-by: appleboy <appleboy.tw@gmail.com>
Co-committed-by: appleboy <appleboy.tw@gmail.com>
2025-09-27 08:01:56 +00:00
appleboy
e9840cf6c0 refactor: improve Bearer token parsing and validation with tests (#96)
- Refactor Bearer token parsing into a dedicated function for improved validation and readability
- Add comprehensive tests for edge cases in Bearer token extraction

Signed-off-by: appleboy <appleboy.tw@gmail.com>

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/96
Co-authored-by: appleboy <appleboy.tw@gmail.com>
Co-committed-by: appleboy <appleboy.tw@gmail.com>
2025-09-27 07:31:32 +00:00
Thierry PROST
95ab3a4b73 feat: add wiki management tools (#95)
Fix #94

## Summary
This PR adds wiki management support to gitea-mcp adding new tools: creating, reading, updating, and deleting wiki pages.

## Changes
- Added `operation/wiki/wiki.go` with wiki tools
- Updated `operation/operation.go` to register it
- Updated `README.md`

## New Tools
- `list_wiki_pages` - List all wiki pages in a repository
- `get_wiki_page` - Get wiki page content and metadata
- `get_wiki_revisions` - Get revision history of a wiki page
- `create_wiki_page` - Create a new wiki page
- `update_wiki_page` - Update an existing wiki page
- `delete_wiki_page` - Delete a wiki page

## Implementation Details
- Uses direct HTTP calls to Gitea wiki API endpoints (v1.16.0+)
- Follows existing MCP patterns and error handling
- Includes fallback logic to prevent "unnamed" pages during updates
- Proper base64 content encoding as per Gitea API spec

## Testing
- All 6 tools tested and working correctly
- Error handling validated
- Integration with existing MCP server confirmed
- Made a test repo & simulated a drone construction using Claude Code (in french sorry) at https://git.kernelpanik.fr/Test-Organization/test_wiki_tools/wiki

Ready for review.
Closes #[94]

Co-authored-by: nox <nox@noxen.net>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/95
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: Bo-Yi Wu (吳柏毅) <appleboy.tw@gmail.com>
Co-authored-by: Thierry PROST <3kynox@noreply.gitea.com>
Co-committed-by: Thierry PROST <3kynox@noreply.gitea.com>
2025-09-27 07:29:10 +00:00
11 changed files with 596 additions and 89 deletions

71
CLAUDE.md Normal file
View 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

View File

@@ -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

View File

@@ -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
```
## 🛠 疑难排解

View File

@@ -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
```
## 🛠 疑難排解

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

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

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

View File

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