diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ddf8763 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,71 @@ +# AGENTS.md + +This file provides guidance to AI coding agents when working with code in this repository. + +## Development Commands + +**Build**: `make build` - Build the gitea-mcp binary +**Install**: `make install` - Build and install to GOPATH/bin +**Clean**: `make clean` - Remove build artifacts +**Test**: `go test ./...` - Run all tests +**Hot reload**: `make dev` - Start development server with hot reload (requires air) +**Dependencies**: `make vendor` - Tidy and verify module dependencies + +## Architecture Overview + +This is a **Gitea MCP (Model Context Protocol) Server** written in Go that provides MCP tools for interacting with Gitea repositories, issues, pull requests, users, and more. + +**Core Components**: + +- `main.go` + `cmd/cmd.go`: CLI entry point and flag parsing +- `operation/operation.go`: Main server setup and tool registration +- `pkg/tool/tool.go`: Tool registry with read/write categorization +- `operation/*/`: Individual tool modules (user, repo, issue, pull, search, wiki, etc.) + +**Transport Modes**: + +- **stdio** (default): Standard input/output for MCP clients +- **http**: HTTP server mode on configurable port (default 8080) + +**Authentication**: + +- Global token via `--token` flag or `GITEA_ACCESS_TOKEN` env var +- HTTP mode supports per-request Bearer token override in Authorization header +- Token precedence: HTTP Authorization header > CLI flag > environment variable + +**Tool Organization**: + +- Tools are categorized as read-only or write operations +- `--read-only` flag exposes only read tools +- Tool modules register via `Tool.RegisterRead()` and `Tool.RegisterWrite()` + +**Key Configuration**: + +- Default Gitea host: `https://gitea.com` (override with `--host` or `GITEA_HOST`) +- Environment variables can override CLI flags: `MCP_MODE`, `GITEA_READONLY`, `GITEA_DEBUG`, `GITEA_INSECURE` +- Logs are written to `~/.gitea-mcp/gitea-mcp.log` with rotation + +## Available Tools + +The server provides 40+ MCP tools covering: + +- **User**: get_my_user_info, get_user_orgs, search_users +- **Repository**: create_repo, fork_repo, list_my_repos, search_repos +- **Branches/Tags**: create_branch, delete_branch, list_branches, create_tag, list_tags +- **Files**: get_file_content, create_file, update_file, delete_file, get_dir_content +- **Issues**: create_issue, list_repo_issues, create_issue_comment, edit_issue +- **Pull Requests**: create_pull_request, list_repo_pull_requests, get_pull_request_by_index +- **Releases**: create_release, list_releases, get_latest_release +- **Wiki**: create_wiki_page, update_wiki_page, list_wiki_pages +- **Search**: search_repos, search_users, search_org_teams +- **Version**: get_gitea_mcp_server_version + +## Common Development Patterns + +**Testing**: Use `go test ./operation -run TestFunctionName` for specific tests + +**Token Context**: HTTP requests use `pkg/context.TokenContextKey` for request-scoped token access + +**Flag Access**: All packages access configuration via global variables in `pkg/flag/flag.go` + +**Graceful Shutdown**: HTTP mode implements graceful shutdown with 10-second timeout on SIGTERM/SIGINT diff --git a/README.md b/README.md index 0553b97..37e8d40 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,7 @@ The Gitea MCP Server supports the following tools: | edit_issue_comment | Issue | Edit a comment on an issue | | get_issue_comments_by_index | Issue | Get comments of an issue by its index | | get_pull_request_by_index | Pull Request | Get a pull request by its index | +| get_pull_request_diff | Pull Request | Get a pull request diff | | list_repo_pull_requests | Pull Request | List all pull requests in a repository | | create_pull_request | Pull Request | Create a new pull request | | create_pull_request_reviewer | Pull Request | Add reviewers to a pull request | diff --git a/operation/pull/pull.go b/operation/pull/pull.go index 59c2d50..f5ff583 100644 --- a/operation/pull/pull.go +++ b/operation/pull/pull.go @@ -18,6 +18,7 @@ var Tool = tool.New() const ( GetPullRequestByIndexToolName = "get_pull_request_by_index" + GetPullRequestDiffToolName = "get_pull_request_diff" ListRepoPullRequestsToolName = "list_repo_pull_requests" CreatePullRequestToolName = "create_pull_request" CreatePullRequestReviewerToolName = "create_pull_request_reviewer" @@ -40,6 +41,15 @@ var ( mcp.WithNumber("index", mcp.Required(), mcp.Description("repository pull request index")), ) + GetPullRequestDiffTool = mcp.NewTool( + GetPullRequestDiffToolName, + mcp.WithDescription("get pull request diff"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithNumber("index", mcp.Required(), mcp.Description("repository pull request index")), + mcp.WithBoolean("binary", mcp.Description("whether to include binary file changes")), + ) + ListRepoPullRequestsTool = mcp.NewTool( ListRepoPullRequestsToolName, mcp.WithDescription("List repository pull requests"), @@ -167,6 +177,10 @@ func init() { Tool: GetPullRequestByIndexTool, Handler: GetPullRequestByIndexFn, }) + Tool.RegisterRead(server.ServerTool{ + Tool: GetPullRequestDiffTool, + Handler: GetPullRequestDiffFn, + }) Tool.RegisterRead(server.ServerTool{ Tool: ListRepoPullRequestsTool, Handler: ListRepoPullRequestsFn, @@ -239,6 +253,43 @@ func GetPullRequestByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp return to.TextResult(pr) } +func GetPullRequestDiffFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called GetPullRequestDiffFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + index, ok := req.GetArguments()["index"].(float64) + if !ok { + return to.ErrorResult(fmt.Errorf("index is required")) + } + binary, _ := req.GetArguments()["binary"].(bool) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + diffBytes, _, err := client.GetPullRequestDiff(owner, repo, int64(index), gitea_sdk.PullRequestDiffOptions{ + Binary: binary, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v diff err: %v", owner, repo, int64(index), err)) + } + + result := map[string]interface{}{ + "diff": string(diffBytes), + "binary": binary, + "index": int64(index), + "repo": repo, + "owner": owner, + } + return to.TextResult(result) +} + func ListRepoPullRequestsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { log.Debugf("Called ListRepoPullRequests") owner, ok := req.GetArguments()["owner"].(string) diff --git a/operation/pull/pull_test.go b/operation/pull/pull_test.go new file mode 100644 index 0000000..8da2831 --- /dev/null +++ b/operation/pull/pull_test.go @@ -0,0 +1,140 @@ +package pull + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "gitea.com/gitea/gitea-mcp/pkg/flag" + "github.com/mark3labs/mcp-go/mcp" +) + +func TestGetPullRequestDiffFn(t *testing.T) { + const ( + owner = "octo" + repo = "demo" + index = 12 + diffRaw = "diff --git a/file.txt b/file.txt\n+line\n" + ) + + var ( + mu sync.Mutex + diffRequested bool + binaryValue string + ) + errCh := make(chan error, 1) + + 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("/%s/%s/pulls/%d.diff", owner, repo, index): + if r.Method != http.MethodGet { + select { + case errCh <- fmt.Errorf("unexpected method: %s", r.Method): + default: + } + } + mu.Lock() + diffRequested = true + binaryValue = r.URL.Query().Get("binary") + mu.Unlock() + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte(diffRaw)) + default: + select { + case errCh <- fmt.Errorf("unexpected request path: %s", r.URL.Path): + default: + } + } + }) + + 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), + "binary": true, + }, + }, + } + + result, err := GetPullRequestDiffFn(context.Background(), req) + if err != nil { + t.Fatalf("GetPullRequestDiffFn() error = %v", err) + } + + select { + case reqErr := <-errCh: + t.Fatalf("handler error: %v", reqErr) + default: + } + + mu.Lock() + requested := diffRequested + gotBinary := binaryValue + mu.Unlock() + + if !requested { + t.Fatalf("expected diff request to be made") + } + if gotBinary != "true" { + t.Fatalf("expected binary=true query param, got %q", gotBinary) + } + + if len(result.Content) == 0 { + t.Fatalf("expected content in result") + } + + textContent, ok := mcp.AsTextContent(result.Content[0]) + if !ok { + t.Fatalf("expected text content, got %T", result.Content[0]) + } + + var parsed struct { + Result map[string]any `json:"Result"` + } + if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil { + t.Fatalf("unmarshal result text: %v", err) + } + + if got, ok := parsed.Result["diff"].(string); !ok || got != diffRaw { + t.Fatalf("diff = %q, want %q", got, diffRaw) + } + if got, ok := parsed.Result["binary"].(bool); !ok || got != true { + t.Fatalf("binary = %v, want true", got) + } + if got, ok := parsed.Result["index"].(float64); !ok || int64(got) != int64(index) { + t.Fatalf("index = %v, want %d", got, index) + } + if got, ok := parsed.Result["owner"].(string); !ok || got != owner { + t.Fatalf("owner = %q, want %q", got, owner) + } + if got, ok := parsed.Result["repo"].(string); !ok || got != repo { + t.Fatalf("repo = %q, want %q", got, repo) + } +}