7 Commits

Author SHA1 Message Date
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
10 changed files with 552 additions and 12 deletions

View File

@@ -1,6 +1,8 @@
package main
import (
"runtime/debug"
"gitea.com/gitea/gitea-mcp/cmd"
"gitea.com/gitea/gitea-mcp/pkg/flag"
)
@@ -8,6 +10,11 @@ import (
var Version = "dev"
func init() {
if Version == "dev" {
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
Version = info.Main.Version
}
}
flag.Version = Version
}

View File

@@ -2,6 +2,7 @@ package operation
import (
"context"
"errors"
"fmt"
"net/http"
"os"
@@ -136,7 +137,7 @@ func Run() error {
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
}
<-shutdownDone // Wait for shutdown to finish

View File

@@ -3,6 +3,7 @@ package pull
import (
"context"
"fmt"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
@@ -71,6 +72,7 @@ var (
mcp.WithBoolean("delete_branch", mcp.Description("delete branch after merge (for 'merge')")),
mcp.WithArray("reviewers", mcp.Description("reviewer usernames (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithArray("team_reviewers", mcp.Description("team reviewer names (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithBoolean("draft", mcp.Description("mark PR as draft (for 'create', 'update'). Gitea uses a 'WIP: ' title prefix for drafts.")),
)
PullRequestReviewWriteTool = mcp.NewTool(
@@ -271,6 +273,28 @@ func listRepoPullRequestsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.
return to.TextResult(slimPullRequests(pullRequests))
}
// defaultWIPPrefixes are the default Gitea title prefixes that mark a PR as
// work-in-progress / draft. Gitea matches these case-insensitively.
var defaultWIPPrefixes = []string{"WIP:", "[WIP]"}
// applyDraftPrefix adds or removes a WIP title prefix that Gitea uses to mark
// pull requests as drafts. When the title already carries a recognized prefix
// and isDraft is true, the title is returned unchanged to avoid normalization.
func applyDraftPrefix(title string, isDraft bool) string {
for _, prefix := range defaultWIPPrefixes {
if len(title) >= len(prefix) && strings.EqualFold(title[:len(prefix)], prefix) {
if isDraft {
return title
}
return strings.TrimLeft(title[len(prefix):], " ")
}
}
if isDraft {
return "WIP: " + title
}
return title
}
func createPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createPullRequestFn")
args := req.GetArguments()
@@ -298,6 +322,11 @@ func createPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal
if err != nil {
return to.ErrorResult(err)
}
if draft, ok := args["draft"].(bool); ok {
title = applyDraftPrefix(title, draft)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
@@ -774,6 +803,22 @@ func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if title, ok := args["title"].(string); ok {
opt.Title = title
}
if draft, ok := args["draft"].(bool); ok {
if opt.Title == "" {
// Fetch current title so the caller doesn't have to provide it
// just to toggle draft status.
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
pr, _, err := client.GetPullRequest(owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v err: %v", owner, repo, index, err))
}
opt.Title = pr.Title
}
opt.Title = applyDraftPrefix(opt.Title, draft)
}
if body, ok := args["body"].(string); ok {
opt.Body = new(body)
}

View File

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

View File

@@ -2,6 +2,7 @@ package repo
import (
"context"
"errors"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
@@ -18,9 +19,10 @@ import (
var Tool = tool.New()
const (
CreateRepoToolName = "create_repo"
ForkRepoToolName = "fork_repo"
ListMyReposToolName = "list_my_repos"
CreateRepoToolName = "create_repo"
ForkRepoToolName = "fork_repo"
ListMyReposToolName = "list_my_repos"
ListOrgReposToolName = "list_org_repos"
)
var (
@@ -55,6 +57,14 @@ var (
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)),
)
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() {
@@ -70,6 +80,10 @@ func init() {
Tool: ListMyReposTool,
Handler: ListMyReposFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListOrgReposTool,
Handler: ListOrgReposFn,
})
}
func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -178,3 +192,34 @@ func ListMyReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
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

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

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