14 Commits

Author SHA1 Message Date
silverwind
606f387da6 Revert build help text to 'build the application'
Co-Authored-By: Claude (claude-opus-4-6) <noreply@anthropic.com>
2026-03-25 11:51:08 +01:00
silverwind
ac71207af9 Align Makefile help text with gitea/gitea
Co-Authored-By: Claude (claude-opus-4-6) <noreply@anthropic.com>
2026-03-25 11:50:45 +01:00
silverwind
5bfab0dc74 Lowercase Makefile help descriptions
Co-Authored-By: Claude (claude-opus-4-6) <noreply@anthropic.com>
2026-03-25 11:49:58 +01:00
silverwind
3f28aa3614 Add make fmt target and fix gofumpt formatting
Co-Authored-By: Claude (claude-opus-4-6) <noreply@anthropic.com>
2026-03-25 11:48:34 +01:00
silverwind
c04d9314d3 Add missing tool parameters from Gitea SDK
Expose additional parameters that the Gitea SDK supports but were not
yet wired through the MCP tool definitions:

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

Also adds params.GetOptionalTime helper for RFC3339 timestamp parsing
and tests for the most important new parameters.

Co-Authored-By: Claude (claude-opus-4-6) <noreply@anthropic.com>
2026-03-25 08:11:27 +01:00
silverwind
9056a5ef27 Add get_commit, get_repository_tree, and search_issues tools (#162)
Add three new read-only tools inspired by the GitHub MCP server:

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

---------

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

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

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

## Changes

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

---------

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

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

Closes #151

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

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

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/155
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-03-13 19:56:42 +00:00
silverwind
c57e4c2e57 fix: prevent silent write loss on 301 redirects (#154)
When a Gitea repo is renamed, the API returns a 301 redirect. Go's default `http.Client` follows 301/302/303 redirects by changing the HTTP method from PATCH/POST/PUT to GET and dropping the request body. This causes mutating API calls (edit PR, create issue, etc.) to silently appear to succeed while no write actually occurs — the client receives the current resource data via the redirected GET and returns it as if the edit worked.

## Fix

Add a `CheckRedirect` function to both HTTP clients (SDK client in `gitea.go` and REST client in `rest.go`) that returns `http.ErrUseLastResponse` for non-GET/HEAD methods. This surfaces the redirect as an error instead of silently downgrading the request. GET/HEAD reads continue to follow redirects normally.

## Tests

- `TestCheckRedirect`: table-driven unit tests for all HTTP methods + redirect limit
- `TestDoJSON_RepoRenameRedirect`: regression test with `httptest` server proving PATCH to a 301 endpoint returns an error instead of silently succeeding
- `TestDoJSON_GETRedirectFollowed`: verifies GET reads still follow 301 redirects

*This PR was authored by Claude.*

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/154
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-03-13 17:45:59 +00:00
silverwind
22fc663387 Partially revert #149: remove Content-Type middleware, keep ErrServerClosed fix (#150)
Reverts the Content-Type middleware and custom server/mux plumbing from #149, keeping only the `http.ErrServerClosed` fix which is a legitimate bug.

The middleware is unnecessary because rmcp's `post_message` already returns early for 202/204 before any Content-Type validation. Both Go MCP SDKs (`modelcontextprotocol/go-sdk` used by GitHub's MCP server, and `mark3labs/mcp-go` used here) intentionally omit Content-Type on 202 responses per the MCP spec. See #148 for the full analysis.

*PR written by Claude on behalf of @silverwind*

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/150
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-03-13 09:31:26 +00:00
Bo-Yi Wu
e0abd256a3 feat(mcp): add MCP tool to list organization repositories (#152)
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/152
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-committed-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2026-03-12 05:46:07 +00:00
Mutex
73263e74d0 http: set Content-Type for 202/204 streamable responses (#149)
Fixes: #148
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/149
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Mutex <gitea314@libertyprime.org>
Co-committed-by: Mutex <gitea314@libertyprime.org>
2026-03-09 13:07:27 +00:00
25 changed files with 1344 additions and 46 deletions

View File

@@ -8,7 +8,7 @@ GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1
GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2 GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2
.PHONY: help .PHONY: help
help: ## Print this help message. help: ## print this help message
@echo "Usage: make [target]" @echo "Usage: make [target]"
@echo "" @echo ""
@echo "Targets:" @echo "Targets:"
@@ -16,7 +16,7 @@ help: ## Print this help message.
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: install .PHONY: install
install: build ## Install the application. install: build ## install the application
@echo "Installing $(EXECUTABLE)..." @echo "Installing $(EXECUTABLE)..."
@mkdir -p $(GOPATH)/bin @mkdir -p $(GOPATH)/bin
@cp $(EXECUTABLE) $(GOPATH)/bin/$(EXECUTABLE) @cp $(EXECUTABLE) $(GOPATH)/bin/$(EXECUTABLE)
@@ -24,23 +24,23 @@ install: build ## Install the application.
@echo "Please add $(GOPATH)/bin to your PATH if it is not already there." @echo "Please add $(GOPATH)/bin to your PATH if it is not already there."
.PHONY: uninstall .PHONY: uninstall
uninstall: ## Uninstall the application. uninstall: ## uninstall the application
@echo "Uninstalling $(EXECUTABLE)..." @echo "Uninstalling $(EXECUTABLE)..."
@rm -f $(GOPATH)/bin/$(EXECUTABLE) @rm -f $(GOPATH)/bin/$(EXECUTABLE)
@echo "Uninstalled $(EXECUTABLE) from $(GOPATH)/bin/$(EXECUTABLE)" @echo "Uninstalled $(EXECUTABLE) from $(GOPATH)/bin/$(EXECUTABLE)"
.PHONY: clean .PHONY: clean
clean: ## Clean the build artifacts. clean: ## delete build artifacts
@echo "Cleaning up build artifacts..." @echo "Cleaning up build artifacts..."
@rm -f $(EXECUTABLE) @rm -f $(EXECUTABLE)
@echo "Cleaned up $(EXECUTABLE)" @echo "Cleaned up $(EXECUTABLE)"
.PHONY: build .PHONY: build
build: ## Build the application. build: ## build the application
$(GO) build -v -ldflags '-s -w $(LDFLAGS)' -o $(EXECUTABLE) $(GO) build -v -ldflags '-s -w $(LDFLAGS)' -o $(EXECUTABLE)
.PHONY: air .PHONY: air
air: ## Install air for hot reload. air: ## install air for hot reload
@hash air > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ @hash air > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GO) install github.com/air-verse/air@latest; \ $(GO) install github.com/air-verse/air@latest; \
fi fi
@@ -49,6 +49,10 @@ air: ## Install air for hot reload.
dev: air ## run the application with hot reload dev: air ## run the application with hot reload
air --build.cmd "make build" --build.bin ./gitea-mcp air --build.cmd "make build" --build.bin ./gitea-mcp
.PHONY: fmt
fmt: ## format the Go code
$(GO) run $(GOFUMPT_PACKAGE) -w .
.PHONY: lint .PHONY: lint
lint: lint-go ## lint everything lint: lint-go ## lint everything

2
go.mod
View File

@@ -4,7 +4,7 @@ go 1.26.0
require ( require (
code.gitea.io/sdk/gitea v0.23.2 code.gitea.io/sdk/gitea v0.23.2
github.com/mark3labs/mcp-go v0.44.0 github.com/mark3labs/mcp-go v0.45.0
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
) )

4
go.sum
View File

@@ -28,8 +28,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/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 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I= github.com/mark3labs/mcp-go v0.45.0 h1:s0S8qR/9fWaQ3pHxz7pm1uQ0DrswoSnRIxKIjbiQtkc=
github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=

View File

@@ -1,6 +1,8 @@
package main package main
import ( import (
"runtime/debug"
"gitea.com/gitea/gitea-mcp/cmd" "gitea.com/gitea/gitea-mcp/cmd"
"gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/flag"
) )
@@ -8,6 +10,11 @@ import (
var Version = "dev" var Version = "dev"
func init() { 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 flag.Version = Version
} }

View File

@@ -30,6 +30,9 @@ var (
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("state", mcp.Description("issue state"), mcp.DefaultString("all")), mcp.WithString("state", mcp.Description("issue state"), mcp.DefaultString("all")),
mcp.WithArray("labels", mcp.Description("filter by label names"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithString("since", mcp.Description("filter issues updated after this ISO 8601 timestamp")),
mcp.WithString("before", mcp.Description("filter issues updated before this ISO 8601 timestamp")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)), mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)), mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
) )
@@ -56,8 +59,10 @@ var (
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'create', 'update')")), mcp.WithNumber("milestone", mcp.Description("milestone number (for 'create', 'update')")),
mcp.WithString("state", mcp.Description("issue state, one of open, closed, all (for 'update')")), mcp.WithString("state", mcp.Description("issue state, one of open, closed, all (for 'update')")),
mcp.WithNumber("commentID", mcp.Description("id of issue comment (required for 'edit_comment')")), mcp.WithNumber("commentID", mcp.Description("id of issue comment (required for 'edit_comment')")),
mcp.WithArray("labels", mcp.Description("array of label IDs (for 'add_labels', 'replace_labels')"), mcp.Items(map[string]any{"type": "number"})), mcp.WithArray("labels", mcp.Description("array of label IDs (for 'create', 'add_labels', 'replace_labels')"), mcp.Items(map[string]any{"type": "number"})),
mcp.WithNumber("label_id", mcp.Description("label ID to remove (required for 'remove_label')")), mcp.WithNumber("label_id", mcp.Description("label ID to remove (required for 'remove_label')")),
mcp.WithString("deadline", mcp.Description("due date in ISO 8601 format (for 'create', 'update')")),
mcp.WithBoolean("remove_deadline", mcp.Description("unset due date (for 'update')")),
) )
) )
@@ -162,14 +167,22 @@ func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if !ok { if !ok {
state = "all" state = "all"
} }
labels := params.GetStringSlice(req.GetArguments(), "labels")
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.ListIssueOption{ opt := gitea_sdk.ListIssueOption{
State: gitea_sdk.StateType(state), State: gitea_sdk.StateType(state),
Labels: labels,
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: page, Page: page,
PageSize: pageSize, PageSize: pageSize,
}, },
} }
if t := params.GetOptionalTime(req.GetArguments(), "since"); t != nil {
opt.Since = *t
}
if t := params.GetOptionalTime(req.GetArguments(), "before"); t != nil {
opt.Before = *t
}
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
@@ -213,6 +226,10 @@ func createIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
opt.Milestone = milestone opt.Milestone = milestone
} }
} }
if labelIDs, err := params.GetInt64Slice(req.GetArguments(), "labels"); err == nil {
opt.Labels = labelIDs
}
opt.Deadline = params.GetOptionalTime(req.GetArguments(), "deadline")
issue, _, err := client.CreateIssue(owner, repo, opt) issue, _, err := client.CreateIssue(owner, repo, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/issue err: %v", owner, repo, err)) return to.ErrorResult(fmt.Errorf("create %v/%v/issue err: %v", owner, repo, err))
@@ -289,6 +306,10 @@ func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
if ok { if ok {
opt.State = new(gitea_sdk.StateType(state)) opt.State = new(gitea_sdk.StateType(state))
} }
opt.Deadline = params.GetOptionalTime(req.GetArguments(), "deadline")
if removeDeadline, ok := req.GetArguments()["remove_deadline"].(bool); ok {
opt.RemoveDeadline = &removeDeadline
}
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {

View File

@@ -0,0 +1,167 @@
package issue
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/mark3labs/mcp-go/mcp"
)
func Test_listRepoIssuesFn_filters(t *testing.T) {
const (
owner = "octo"
repo = "demo"
)
var (
mu sync.Mutex
gotQuery string
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case r.URL.Path == fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case r.URL.Path == fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo):
mu.Lock()
gotQuery = r.URL.RawQuery
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
origVersion := flag.Version
flag.Host = server.URL
flag.Token = ""
flag.Version = "test"
defer func() {
flag.Host = origHost
flag.Token = origToken
flag.Version = origVersion
}()
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"labels": []any{"bug", "enhancement"},
"since": "2026-01-01T00:00:00Z",
},
},
}
_, err := listRepoIssuesFn(context.Background(), req)
if err != nil {
t.Fatalf("listRepoIssuesFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
if !strings.Contains(gotQuery, "labels=bug%2Cenhancement") {
t.Fatalf("expected labels query param, got %s", gotQuery)
}
if !strings.Contains(gotQuery, "since=2026-01-01") {
t.Fatalf("expected since query param, got %s", gotQuery)
}
}
func Test_createIssueFn_labels(t *testing.T) {
const (
owner = "octo"
repo = "demo"
)
var (
mu sync.Mutex
gotBody map[string]any
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo):
mu.Lock()
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotBody = body
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":1,"title":"test","state":"open"}`))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
origVersion := flag.Version
flag.Host = server.URL
flag.Token = ""
flag.Version = "test"
defer func() {
flag.Host = origHost
flag.Token = origToken
flag.Version = origVersion
}()
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"title": "test issue",
"body": "body",
"labels": []any{float64(10), float64(20)},
"deadline": "2026-06-01T00:00:00Z",
},
},
}
_, err := createIssueFn(context.Background(), req)
if err != nil {
t.Fatalf("createIssueFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
labels, ok := gotBody["labels"].([]any)
if !ok || len(labels) != 2 {
t.Fatalf("expected 2 labels, got %v", gotBody["labels"])
}
if labels[0] != float64(10) || labels[1] != float64(20) {
t.Fatalf("expected labels [10,20], got %v", labels)
}
if gotBody["due_date"] == nil {
t.Fatalf("expected due_date to be set")
}
}

View File

@@ -47,6 +47,7 @@ var (
mcp.WithString("color", mcp.Description("label color hex code e.g. #RRGGBB (required for create, optional for edit)")), mcp.WithString("color", mcp.Description("label color hex code e.g. #RRGGBB (required for create, optional for edit)")),
mcp.WithString("description", mcp.Description("label description")), mcp.WithString("description", mcp.Description("label description")),
mcp.WithBoolean("exclusive", mcp.Description("whether the label is exclusive (org labels only)")), mcp.WithBoolean("exclusive", mcp.Description("whether the label is exclusive (org labels only)")),
mcp.WithBoolean("is_archived", mcp.Description("whether the label is archived (for create/edit repo label methods)")),
) )
) )
@@ -178,10 +179,13 @@ func createRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
} }
description, _ := req.GetArguments()["description"].(string) // Optional description, _ := req.GetArguments()["description"].(string) // Optional
isArchived, _ := req.GetArguments()["is_archived"].(bool)
opt := gitea_sdk.CreateLabelOption{ opt := gitea_sdk.CreateLabelOption{
Name: name, Name: name,
Color: color, Color: color,
Description: description, Description: description,
IsArchived: isArchived,
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -220,6 +224,9 @@ func editRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
if description, ok := req.GetArguments()["description"].(string); ok { if description, ok := req.GetArguments()["description"].(string); ok {
opt.Description = new(description) opt.Description = new(description)
} }
if isArchived, ok := req.GetArguments()["is_archived"].(bool); ok {
opt.IsArchived = &isArchived
}
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {

View File

@@ -2,6 +2,7 @@ package operation
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@@ -136,7 +137,7 @@ func Run() error {
close(shutdownDone) close(shutdownDone)
}() }()
if err := httpServer.Start(fmt.Sprintf(":%d", flag.Port)); err != nil { if err := httpServer.Start(fmt.Sprintf(":%d", flag.Port)); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err return err
} }
<-shutdownDone // Wait for shutdown to finish <-shutdownDone // Wait for shutdown to finish

View File

@@ -3,6 +3,7 @@ package pull
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
@@ -66,11 +67,18 @@ var (
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'update')")), mcp.WithNumber("milestone", mcp.Description("milestone number (for 'update')")),
mcp.WithString("state", mcp.Description("PR state (for 'update')"), mcp.Enum("open", "closed")), mcp.WithString("state", mcp.Description("PR state (for 'update')"), mcp.Enum("open", "closed")),
mcp.WithBoolean("allow_maintainer_edit", mcp.Description("allow maintainer to edit (for 'update')")), mcp.WithBoolean("allow_maintainer_edit", mcp.Description("allow maintainer to edit (for 'update')")),
mcp.WithArray("labels", mcp.Description("array of label IDs (for 'create', 'update')"), mcp.Items(map[string]any{"type": "number"})),
mcp.WithString("deadline", mcp.Description("due date in ISO 8601 format (for 'create', 'update')")),
mcp.WithBoolean("remove_deadline", mcp.Description("unset due date (for 'update')")),
mcp.WithString("merge_style", mcp.Description("merge style (for 'merge')"), mcp.Enum("merge", "rebase", "rebase-merge", "squash", "fast-forward-only"), mcp.DefaultString("merge")), mcp.WithString("merge_style", mcp.Description("merge style (for 'merge')"), mcp.Enum("merge", "rebase", "rebase-merge", "squash", "fast-forward-only"), mcp.DefaultString("merge")),
mcp.WithString("message", mcp.Description("merge commit message (for 'merge') or dismissal reason")), mcp.WithString("message", mcp.Description("merge commit message (for 'merge') or dismissal reason")),
mcp.WithBoolean("delete_branch", mcp.Description("delete branch after merge (for 'merge')")), mcp.WithBoolean("delete_branch", mcp.Description("delete branch after merge (for 'merge')")),
mcp.WithBoolean("force_merge", mcp.Description("force merge even if checks are not passing (for 'merge')")),
mcp.WithBoolean("merge_when_checks_succeed", mcp.Description("auto-merge when checks succeed (for 'merge')")),
mcp.WithString("head_commit_id", mcp.Description("expected head commit SHA for merge conflict detection (for 'merge')")),
mcp.WithArray("reviewers", mcp.Description("reviewer usernames (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})), mcp.WithArray("reviewers", mcp.Description("reviewer usernames (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithArray("team_reviewers", mcp.Description("team reviewer names (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})), mcp.WithArray("team_reviewers", mcp.Description("team reviewer names (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithBoolean("draft", mcp.Description("mark PR as draft (for 'create', 'update'). Gitea uses a 'WIP: ' title prefix for drafts.")),
) )
PullRequestReviewWriteTool = mcp.NewTool( PullRequestReviewWriteTool = mcp.NewTool(
@@ -271,6 +279,28 @@ func listRepoPullRequestsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.
return to.TextResult(slimPullRequests(pullRequests)) 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) { func createPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createPullRequestFn") log.Debugf("Called createPullRequestFn")
args := req.GetArguments() args := req.GetArguments()
@@ -298,16 +328,26 @@ func createPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
if draft, ok := args["draft"].(bool); ok {
title = applyDraftPrefix(title, draft)
}
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
pr, _, err := client.CreatePullRequest(owner, repo, gitea_sdk.CreatePullRequestOption{ opt := gitea_sdk.CreatePullRequestOption{
Title: title, Title: title,
Body: body, Body: body,
Head: head, Head: head,
Base: base, Base: base,
}) }
if labelIDs, err := params.GetInt64Slice(args, "labels"); err == nil {
opt.Labels = labelIDs
}
opt.Deadline = params.GetOptionalTime(args, "deadline")
pr, _, err := client.CreatePullRequest(owner, repo, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/pull_request err: %v", owner, repo, err)) return to.ErrorResult(fmt.Errorf("create %v/%v/pull_request err: %v", owner, repo, err))
} }
@@ -722,11 +762,18 @@ func mergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
forceMerge, _ := args["force_merge"].(bool)
mergeWhenChecksSucceed, _ := args["merge_when_checks_succeed"].(bool)
headCommitID, _ := args["head_commit_id"].(string)
opt := gitea_sdk.MergePullRequestOption{ opt := gitea_sdk.MergePullRequestOption{
Style: gitea_sdk.MergeStyle(mergeStyle), Style: gitea_sdk.MergeStyle(mergeStyle),
Title: title, Title: title,
Message: message, Message: message,
DeleteBranchAfterMerge: deleteBranch, DeleteBranchAfterMerge: deleteBranch,
ForceMerge: forceMerge,
MergeWhenChecksSucceed: mergeWhenChecksSucceed,
HeadCommitId: headCommitID,
} }
merged, resp, err := client.MergePullRequest(owner, repo, index, opt) merged, resp, err := client.MergePullRequest(owner, repo, index, opt)
@@ -774,6 +821,22 @@ func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if title, ok := args["title"].(string); ok { if title, ok := args["title"].(string); ok {
opt.Title = title opt.Title = title
} }
if draft, ok := args["draft"].(bool); ok {
if opt.Title == "" {
// Fetch current title so the caller doesn't have to provide it
// just to toggle draft status.
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
pr, _, err := client.GetPullRequest(owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v err: %v", owner, repo, index, err))
}
opt.Title = pr.Title
}
opt.Title = applyDraftPrefix(opt.Title, draft)
}
if body, ok := args["body"].(string); ok { if body, ok := args["body"].(string); ok {
opt.Body = new(body) opt.Body = new(body)
} }
@@ -797,6 +860,13 @@ func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if allowMaintainerEdit, ok := args["allow_maintainer_edit"].(bool); ok { if allowMaintainerEdit, ok := args["allow_maintainer_edit"].(bool); ok {
opt.AllowMaintainerEdit = new(allowMaintainerEdit) opt.AllowMaintainerEdit = new(allowMaintainerEdit)
} }
if labelIDs, err := params.GetInt64Slice(args, "labels"); err == nil {
opt.Labels = labelIDs
}
opt.Deadline = params.GetOptionalTime(args, "deadline")
if removeDeadline, ok := args["remove_deadline"].(bool); ok {
opt.RemoveDeadline = &removeDeadline
}
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {

View File

@@ -254,6 +254,398 @@ func Test_mergePullRequestFn(t *testing.T) {
} }
} }
func Test_mergePullRequestFn_newParams(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 8
)
var (
mu sync.Mutex
gotBody map[string]any
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index):
mu.Lock()
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotBody = body
mu.Unlock()
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
origVersion := flag.Version
flag.Host = server.URL
flag.Token = ""
flag.Version = "test"
defer func() {
flag.Host = origHost
flag.Token = origToken
flag.Version = origVersion
}()
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"index": float64(index),
"merge_style": "merge",
"force_merge": true,
"merge_when_checks_succeed": true,
"head_commit_id": "abc123",
},
},
}
_, err := mergePullRequestFn(context.Background(), req)
if err != nil {
t.Fatalf("mergePullRequestFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
if gotBody["force_merge"] != true {
t.Fatalf("expected force_merge true, got %v", gotBody["force_merge"])
}
if gotBody["merge_when_checks_succeed"] != true {
t.Fatalf("expected merge_when_checks_succeed true, got %v", gotBody["merge_when_checks_succeed"])
}
if gotBody["head_commit_id"] != "abc123" {
t.Fatalf("expected head_commit_id 'abc123', got %v", gotBody["head_commit_id"])
}
}
func Test_createPullRequestFn_labels(t *testing.T) {
const (
owner = "octo"
repo = "demo"
)
var (
mu sync.Mutex
gotBody map[string]any
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls", owner, repo):
mu.Lock()
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotBody = body
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":1,"title":"test","state":"open"}`))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
origVersion := flag.Version
flag.Host = server.URL
flag.Token = ""
flag.Version = "test"
defer func() {
flag.Host = origHost
flag.Token = origToken
flag.Version = origVersion
}()
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"title": "test",
"body": "body",
"head": "feature",
"base": "main",
"labels": []any{float64(1), float64(2)},
"deadline": "2026-06-01T00:00:00Z",
},
},
}
_, err := createPullRequestFn(context.Background(), req)
if err != nil {
t.Fatalf("createPullRequestFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
labels, ok := gotBody["labels"].([]any)
if !ok || len(labels) != 2 {
t.Fatalf("expected 2 labels, got %v", gotBody["labels"])
}
if labels[0] != float64(1) || labels[1] != float64(2) {
t.Fatalf("expected labels [1,2], got %v", labels)
}
if gotBody["due_date"] == nil {
t.Fatalf("expected due_date to be set")
}
}
func Test_applyDraftPrefix(t *testing.T) {
tests := []struct {
name string
title string
isDraft bool
want string
}{
{"add prefix", "my feature", true, "WIP: my feature"},
{"already prefixed WIP:", "WIP: my feature", true, "WIP: my feature"},
{"already prefixed WIP: no space", "WIP:my feature", true, "WIP:my feature"},
{"already prefixed [WIP]", "[WIP] my feature", true, "[WIP] my feature"},
{"already prefixed case insensitive", "wip: my feature", true, "wip: my feature"},
{"already prefixed [wip]", "[wip] my feature", true, "[wip] my feature"},
{"remove WIP: prefix", "WIP: my feature", false, "my feature"},
{"remove WIP: no space", "WIP:my feature", false, "my feature"},
{"remove [WIP] prefix", "[WIP] my feature", false, "my feature"},
{"remove [wip] prefix", "[wip] my feature", false, "my feature"},
{"remove wip: lowercase", "wip: my feature", false, "my feature"},
{"no prefix not draft", "my feature", false, "my feature"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := applyDraftPrefix(tt.title, tt.isDraft)
if got != tt.want {
t.Fatalf("applyDraftPrefix(%q, %v) = %q, want %q", tt.title, tt.isDraft, got, tt.want)
}
})
}
}
func Test_createPullRequestFn_draft(t *testing.T) {
const (
owner = "octo"
repo = "demo"
)
tests := []struct {
name string
title string
draft any // bool or nil (omitted)
wantTitle string
}{
{"draft true", "my feature", true, "WIP: my feature"},
{"draft false strips WIP:", "WIP: my feature", false, "my feature"},
{"draft false strips [WIP]", "[WIP] my feature", false, "my feature"},
{"draft omitted preserves title", "WIP: my feature", nil, "WIP: my feature"},
{"draft true already prefixed", "WIP: my feature", true, "WIP: my feature"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var (
mu sync.Mutex
gotBody map[string]any
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls", owner, repo):
mu.Lock()
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotBody = body
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(fmt.Appendf(nil, `{"number":1,"title":%q,"state":"open"}`, body["title"]))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
origVersion := flag.Version
flag.Host = server.URL
flag.Token = ""
flag.Version = "test"
defer func() {
flag.Host = origHost
flag.Token = origToken
flag.Version = origVersion
}()
args := map[string]any{
"owner": owner,
"repo": repo,
"title": tc.title,
"body": "test body",
"head": "feature",
"base": "main",
}
if tc.draft != nil {
args["draft"] = tc.draft
}
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: args,
},
}
_, err := createPullRequestFn(context.Background(), req)
if err != nil {
t.Fatalf("createPullRequestFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
if gotBody["title"] != tc.wantTitle {
t.Fatalf("expected title %q, got %v", tc.wantTitle, gotBody["title"])
}
})
}
}
func Test_editPullRequestFn_draft(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 7
)
tests := []struct {
name string
title string // title arg passed to the tool; empty means omitted
draft any
wantTitle string
}{
{"set draft with title", "my feature", true, "WIP: my feature"},
{"unset draft with title", "WIP: my feature", false, "my feature"},
{"set draft without title fetches current", "", true, "WIP: existing title"},
{"unset draft without title fetches current", "", false, "existing title"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var (
mu sync.Mutex
gotBody map[string]any
)
prPath := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case prPath:
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodGet {
// Auto-fetch: return the existing PR with its current title
_, _ = w.Write(fmt.Appendf(nil, `{"number":%d,"title":"existing title","state":"open"}`, index))
return
}
mu.Lock()
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotBody = body
mu.Unlock()
title := "existing title"
if s, ok := body["title"].(string); ok {
title = s
}
_, _ = w.Write(fmt.Appendf(nil, `{"number":%d,"title":%q,"state":"open"}`, index, title))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
origVersion := flag.Version
flag.Host = server.URL
flag.Token = ""
flag.Version = "test"
defer func() {
flag.Host = origHost
flag.Token = origToken
flag.Version = origVersion
}()
args := map[string]any{
"owner": owner,
"repo": repo,
"index": float64(index),
}
if tc.title != "" {
args["title"] = tc.title
}
if tc.draft != nil {
args["draft"] = tc.draft
}
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: args,
},
}
_, err := editPullRequestFn(context.Background(), req)
if err != nil {
t.Fatalf("editPullRequestFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
if gotBody["title"] != tc.wantTitle {
t.Fatalf("expected title %q, got %v", tc.wantTitle, gotBody["title"])
}
})
}
}
func Test_getPullRequestDiffFn(t *testing.T) { func Test_getPullRequestDiffFn(t *testing.T) {
const ( const (
owner = "octo" owner = "octo"

View File

@@ -43,6 +43,8 @@ var (
mcp.WithDescription("List branches"), mcp.WithDescription("List branches"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
) )
) )
@@ -131,10 +133,11 @@ func ListBranchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListRepoBranchesOptions{ opt := gitea_sdk.ListRepoBranchesOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: 1, Page: page,
PageSize: 30, PageSize: pageSize,
}, },
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)

View File

@@ -16,17 +16,28 @@ import (
const ( const (
ListRepoCommitsToolName = "list_commits" ListRepoCommitsToolName = "list_commits"
GetCommitToolName = "get_commit"
) )
var ListRepoCommitsTool = mcp.NewTool( var (
ListRepoCommitsToolName, ListRepoCommitsTool = mcp.NewTool(
mcp.WithDescription("List repository commits"), ListRepoCommitsToolName,
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithDescription("List repository commits"),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("sha", mcp.Description("SHA or branch to start listing commits from")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("path", mcp.Description("path indicates that only commits that include the path's file/dir should be returned.")), mcp.WithString("sha", mcp.Description("SHA or branch to start listing commits from")),
mcp.WithNumber("page", mcp.Required(), mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)), mcp.WithString("path", mcp.Description("path indicates that only commits that include the path's file/dir should be returned.")),
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)), mcp.WithNumber("page", mcp.Required(), mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
)
GetCommitTool = mcp.NewTool(
GetCommitToolName,
mcp.WithDescription("Get details of a specific commit"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("sha", mcp.Required(), mcp.Description("commit SHA")),
)
) )
func init() { func init() {
@@ -34,6 +45,10 @@ func init() {
Tool: ListRepoCommitsTool, Tool: ListRepoCommitsTool,
Handler: ListRepoCommitsFn, Handler: ListRepoCommitsFn,
}) })
Tool.RegisterRead(server.ServerTool{
Tool: GetCommitTool,
Handler: GetCommitFn,
})
} }
func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -75,3 +90,29 @@ func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
} }
return to.TextResult(slimCommits(commits)) return to.TextResult(slimCommits(commits))
} }
func GetCommitFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetCommitFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
sha, err := params.GetString(args, "sha")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
commit, _, err := client.GetSingleCommit(owner, repo, sha)
if err != nil {
return to.ErrorResult(fmt.Errorf("get commit %v err: %v", sha, err))
}
return to.TextResult(slimCommit(commit))
}

View File

@@ -2,6 +2,7 @@ package repo
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
@@ -18,9 +19,10 @@ import (
var Tool = tool.New() var Tool = tool.New()
const ( const (
CreateRepoToolName = "create_repo" CreateRepoToolName = "create_repo"
ForkRepoToolName = "fork_repo" ForkRepoToolName = "fork_repo"
ListMyReposToolName = "list_my_repos" ListMyReposToolName = "list_my_repos"
ListOrgReposToolName = "list_org_repos"
) )
var ( var (
@@ -37,6 +39,8 @@ var (
mcp.WithString("license", mcp.Description("License to use")), mcp.WithString("license", mcp.Description("License to use")),
mcp.WithString("readme", mcp.Description("Readme of the repository to create")), mcp.WithString("readme", mcp.Description("Readme of the repository to create")),
mcp.WithString("default_branch", mcp.Description("DefaultBranch of the repository (used when initializes and in template)")), mcp.WithString("default_branch", mcp.Description("DefaultBranch of the repository (used when initializes and in template)")),
mcp.WithString("trust_model", mcp.Description("Trust model for verifying GPG signatures"), mcp.Enum("default", "collaborator", "committer", "collaboratorcommitter")),
mcp.WithString("object_format_name", mcp.Description("Object format: sha1 or sha256"), mcp.Enum("sha1", "sha256")),
mcp.WithString("organization", mcp.Description("Organization name to create repository in (optional - defaults to personal account)")), mcp.WithString("organization", mcp.Description("Organization name to create repository in (optional - defaults to personal account)")),
) )
@@ -55,6 +59,14 @@ var (
mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)), mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)), mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
) )
ListOrgReposTool = mcp.NewTool(
ListOrgReposToolName,
mcp.WithDescription("List repositories of an organization"),
mcp.WithString("org", mcp.Required(), mcp.Description("Organization name")),
mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("pageSize", mcp.Required(), mcp.Description("Page size number"), mcp.DefaultNumber(100), mcp.Min(1)),
)
) )
func init() { func init() {
@@ -70,6 +82,10 @@ func init() {
Tool: ListMyReposTool, Tool: ListMyReposTool,
Handler: ListMyReposFn, Handler: ListMyReposFn,
}) })
Tool.RegisterRead(server.ServerTool{
Tool: ListOrgReposTool,
Handler: ListOrgReposFn,
})
} }
func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -88,19 +104,23 @@ func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
license, _ := args["license"].(string) license, _ := args["license"].(string)
readme, _ := args["readme"].(string) readme, _ := args["readme"].(string)
defaultBranch, _ := args["default_branch"].(string) defaultBranch, _ := args["default_branch"].(string)
trustModel, _ := args["trust_model"].(string)
objectFormatName, _ := args["object_format_name"].(string)
organization, _ := args["organization"].(string) organization, _ := args["organization"].(string)
opt := gitea_sdk.CreateRepoOption{ opt := gitea_sdk.CreateRepoOption{
Name: name, Name: name,
Description: description, Description: description,
Private: private, Private: private,
IssueLabels: issueLabels, IssueLabels: issueLabels,
AutoInit: autoInit, AutoInit: autoInit,
Template: template, Template: template,
Gitignores: gitignores, Gitignores: gitignores,
License: license, License: license,
Readme: readme, Readme: readme,
DefaultBranch: defaultBranch, DefaultBranch: defaultBranch,
TrustModel: gitea_sdk.TrustModel(trustModel),
ObjectFormatName: objectFormatName,
} }
var repo *gitea_sdk.Repository var repo *gitea_sdk.Repository
@@ -178,3 +198,34 @@ func ListMyReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
return to.TextResult(slimRepos(repos)) return to.TextResult(slimRepos(repos))
} }
func ListOrgReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListOrgReposFn")
org, ok := req.GetArguments()["org"].(string)
if !ok {
return to.ErrorResult(errors.New("organization name is required"))
}
page, ok := req.GetArguments()["page"].(float64)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
}
opt := gitea_sdk.ListOrgReposOptions{
ListOptions: gitea_sdk.ListOptions{
Page: int(page),
PageSize: int(pageSize),
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
repos, _, err := client.ListOrgRepos(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list organization '%s' repositories error: %v", org, err))
}
return to.TextResult(repos)
}

View File

@@ -184,6 +184,28 @@ func slimContents(c *gitea_sdk.ContentsResponse) map[string]any {
return m 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 { func slimDirEntries(entries []*gitea_sdk.ContentsResponse) []map[string]any {
out := make([]map[string]any, 0, len(entries)) out := make([]map[string]any, 0, len(entries))
for _, c := range entries { for _, c := range entries {

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

@@ -0,0 +1,74 @@
package repo
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
GetRepoTreeToolName = "get_repository_tree"
)
var GetRepoTreeTool = mcp.NewTool(
GetRepoTreeToolName,
mcp.WithDescription("Get the file tree of a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("tree_sha", mcp.Required(), mcp.Description("SHA, branch name, or tag name")),
mcp.WithBoolean("recursive", mcp.Description("whether to get the tree recursively")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: GetRepoTreeTool,
Handler: GetRepoTreeFn,
})
}
func GetRepoTreeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetRepoTreeFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
treeSHA, err := params.GetString(args, "tree_sha")
if err != nil {
return to.ErrorResult(err)
}
recursive, _ := args["recursive"].(bool)
page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListTreeOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
Ref: treeSHA,
Recursive: recursive,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
tree, _, err := client.GetTrees(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("get repository tree err: %v", err))
}
return to.TextResult(slimTree(tree))
}

View File

@@ -0,0 +1,52 @@
package repo
import (
"slices"
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimTree(t *testing.T) {
tree := &gitea_sdk.GitTreeResponse{
SHA: "abc123",
TotalCount: 2,
Truncated: false,
Entries: []gitea_sdk.GitEntry{
{Path: "src", Mode: "040000", Type: "tree", Size: 0, SHA: "def456"},
{Path: "main.go", Mode: "100644", Type: "blob", Size: 42, SHA: "789abc"},
},
}
m := slimTree(tree)
if m["sha"] != "abc123" {
t.Errorf("expected sha abc123, got %v", m["sha"])
}
if m["total_count"] != 2 {
t.Errorf("expected total_count 2, got %v", m["total_count"])
}
entries := m["tree"].([]map[string]any)
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
if entries[0]["path"] != "src" {
t.Errorf("expected first entry path src, got %v", entries[0]["path"])
}
if entries[1]["type"] != "blob" {
t.Errorf("expected second entry type blob, got %v", entries[1]["type"])
}
}
func TestSlimTreeNil(t *testing.T) {
if m := slimTree(nil); m != nil {
t.Errorf("expected nil, got %v", m)
}
}
func TestGetRepoTreeToolRequired(t *testing.T) {
for _, field := range []string{"owner", "repo", "tree_sha"} {
if !slices.Contains(GetRepoTreeTool.InputSchema.Required, field) {
t.Errorf("expected %q to be required", field)
}
}
}

View File

@@ -3,6 +3,7 @@ package search
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
@@ -21,6 +22,7 @@ const (
SearchUsersToolName = "search_users" SearchUsersToolName = "search_users"
SearchOrgTeamsToolName = "search_org_teams" SearchOrgTeamsToolName = "search_org_teams"
SearchReposToolName = "search_repos" SearchReposToolName = "search_repos"
SearchIssuesToolName = "search_issues"
) )
var ( var (
@@ -56,6 +58,18 @@ var (
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)), mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)), mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
) )
SearchIssuesTool = mcp.NewTool(
SearchIssuesToolName,
mcp.WithDescription("Search for issues and pull requests across all accessible repositories"),
mcp.WithString("query", mcp.Required(), mcp.Description("search keyword")),
mcp.WithString("state", mcp.Description("filter by state: open, closed, all"), mcp.Enum("open", "closed", "all")),
mcp.WithString("type", mcp.Description("filter by type: issues, pulls"), mcp.Enum("issues", "pulls")),
mcp.WithString("labels", mcp.Description("comma-separated list of label names")),
mcp.WithString("owner", mcp.Description("filter by repository owner")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
) )
func init() { func init() {
@@ -71,6 +85,10 @@ func init() {
Tool: SearchReposTool, Tool: SearchReposTool,
Handler: ReposFn, Handler: ReposFn,
}) })
Tool.RegisterRead(server.ServerTool{
Tool: SearchIssuesTool,
Handler: IssuesFn,
})
} }
func UsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func UsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -175,3 +193,42 @@ func ReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult,
} }
return to.TextResult(slimRepos(repos)) return to.TextResult(slimRepos(repos))
} }
func IssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called IssuesFn")
args := req.GetArguments()
query, err := params.GetString(args, "query")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListIssueOption{
KeyWord: query,
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
if state, ok := args["state"].(string); ok {
opt.State = gitea_sdk.StateType(state)
}
if issueType, ok := args["type"].(string); ok {
opt.Type = gitea_sdk.IssueType(issueType)
}
if labels, ok := args["labels"].(string); ok && labels != "" {
opt.Labels = strings.Split(labels, ",")
}
if owner, ok := args["owner"].(string); ok {
opt.Owner = owner
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
issues, _, err := client.ListIssues(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("search issues err: %v", err))
}
return to.TextResult(slimIssues(issues))
}

View File

@@ -86,3 +86,53 @@ func slimRepos(repos []*gitea_sdk.Repository) []map[string]any {
} }
return out return out
} }
func userLogin(u *gitea_sdk.User) string {
if u == nil {
return ""
}
return u.UserName
}
func labelNames(labels []*gitea_sdk.Label) []string {
if len(labels) == 0 {
return nil
}
out := make([]string, 0, len(labels))
for _, l := range labels {
if l != nil {
out = append(out, l.Name)
}
}
return out
}
func slimIssues(issues []*gitea_sdk.Issue) []map[string]any {
out := make([]map[string]any, 0, len(issues))
for _, i := range issues {
if i == nil {
continue
}
m := map[string]any{
"number": i.Index,
"title": i.Title,
"state": i.State,
"html_url": i.HTMLURL,
"user": userLogin(i.Poster),
"comments": i.Comments,
"created_at": i.Created,
"updated_at": i.Updated,
}
if len(i.Labels) > 0 {
m["labels"] = labelNames(i.Labels)
}
if i.Repository != nil {
m["repository"] = i.Repository.FullName
}
if i.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 "code.gitea.io/sdk/gitea"
)
func TestSlimIssues(t *testing.T) {
issues := []*gitea_sdk.Issue{
{
Index: 1,
Title: "Bug report",
State: gitea_sdk.StateOpen,
HTMLURL: "https://gitea.com/org/repo/issues/1",
Poster: &gitea_sdk.User{UserName: "alice"},
Labels: []*gitea_sdk.Label{{Name: "bug"}},
Repository: &gitea_sdk.RepositoryMeta{FullName: "org/repo"},
PullRequest: nil,
},
{
Index: 2,
Title: "Add feature",
State: gitea_sdk.StateOpen,
Poster: &gitea_sdk.User{UserName: "bob"},
Repository: &gitea_sdk.RepositoryMeta{FullName: "org/repo"},
PullRequest: &gitea_sdk.PullRequestMeta{},
},
}
result := slimIssues(issues)
if len(result) != 2 {
t.Fatalf("expected 2 issues, got %d", len(result))
}
if result[0]["repository"] != "org/repo" {
t.Errorf("expected repository org/repo, got %v", result[0]["repository"])
}
if result[0]["labels"].([]string)[0] != "bug" {
t.Errorf("expected label bug, got %v", result[0]["labels"])
}
if _, ok := result[0]["is_pull"]; ok {
t.Error("issue should not have is_pull")
}
if result[1]["is_pull"] != true {
t.Error("PR should have is_pull=true")
}
}
func TestSearchIssuesToolRequired(t *testing.T) {
if !slices.Contains(SearchIssuesTool.InputSchema.Required, "query") {
t.Error("search_issues should require query")
}
}

View File

@@ -2,6 +2,7 @@ package wiki
import ( import (
"context" "context"
"encoding/base64"
"fmt" "fmt"
"net/url" "net/url"
@@ -40,7 +41,7 @@ var (
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Description("wiki page name (required for 'update', 'delete')")), mcp.WithString("pageName", mcp.Description("wiki page name (required for 'update', 'delete')")),
mcp.WithString("title", mcp.Description("wiki page title (required for 'create', optional for 'update')")), mcp.WithString("title", mcp.Description("wiki page title (required for 'create', optional for 'update')")),
mcp.WithString("content_base64", mcp.Description("page content, base64 encoded (required for 'create', 'update')")), mcp.WithString("content", mcp.Description("page content (required for 'create', 'update')")),
mcp.WithString("message", mcp.Description("commit message")), mcp.WithString("message", mcp.Description("commit message")),
) )
) )
@@ -176,7 +177,7 @@ func createWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
contentBase64, err := params.GetString(args, "content_base64") content, err := params.GetString(args, "content")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -188,7 +189,7 @@ func createWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
requestBody := map[string]string{ requestBody := map[string]string{
"title": title, "title": title,
"content_base64": contentBase64, "content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
"message": message, "message": message,
} }
@@ -216,13 +217,13 @@ func updateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
contentBase64, err := params.GetString(args, "content_base64") content, err := params.GetString(args, "content")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
requestBody := map[string]string{ requestBody := map[string]string{
"content_base64": contentBase64, "content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
} }
// If title is given, use it. Otherwise, keep current page name // If title is given, use it. Otherwise, keep current page name

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

@@ -3,6 +3,7 @@ package gitea
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"errors"
"fmt" "fmt"
"net/http" "net/http"
@@ -13,7 +14,8 @@ import (
func NewClient(token string) (*gitea.Client, error) { func NewClient(token string) (*gitea.Client, error) {
httpClient := &http.Client{ httpClient := &http.Client{
Transport: http.DefaultTransport, Transport: http.DefaultTransport,
CheckRedirect: checkRedirect,
} }
opts := []gitea.ClientOption{ opts := []gitea.ClientOption{
@@ -38,6 +40,19 @@ func NewClient(token string) (*gitea.Client, error) {
return client, nil return 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) { func ClientFromContext(ctx context.Context) (*gitea.Client, error) {
token, ok := ctx.Value(mcpContext.TokenContextKey).(string) token, ok := ctx.Value(mcpContext.TokenContextKey).(string)
if !ok { if !ok {

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

View File

@@ -44,8 +44,9 @@ func newRESTHTTPClient() *http.Client {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // user-requested insecure mode transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // user-requested insecure mode
} }
return &http.Client{ return &http.Client{
Transport: transport, Transport: transport,
Timeout: 60 * time.Second, Timeout: 60 * time.Second,
CheckRedirect: checkRedirect,
} }
} }

View File

@@ -3,6 +3,7 @@ package params
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"time"
) )
// GetString extracts a required string parameter from MCP tool arguments. // GetString extracts a required string parameter from MCP tool arguments.
@@ -101,6 +102,18 @@ func GetInt64Slice(args map[string]any, key string) ([]int64, error) {
return out, nil return out, nil
} }
// GetOptionalTime extracts an optional RFC3339 timestamp parameter, returning nil if missing or unparseable.
func GetOptionalTime(args map[string]any, key string) *time.Time {
val, ok := args[key].(string)
if !ok {
return nil
}
if t, err := time.Parse(time.RFC3339, val); err == nil {
return &t
}
return nil
}
// GetOptionalInt extracts an optional integer parameter from MCP tool arguments. // GetOptionalInt extracts an optional integer parameter from MCP tool arguments.
// Returns defaultVal if the key is missing or the value cannot be parsed. // Returns defaultVal if the key is missing or the value cannot be parsed.
// Accepts both float64 (JSON number) and string representations. // Accepts both float64 (JSON number) and string representations.