From 6744cab627c9d43594add892a8646869cb1f8eea Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sun, 23 Aug 2026 23:33:36 -0700 Subject: [PATCH] feat(gitea): support extra outbound HTTP headers via GITEA_EXTRA_HEADERS Add a GITEA_EXTRA_HEADERS environment variable that accepts a JSON object of header name/value pairs (e.g. Cloudflare Access credentials) and applies them to every outbound request to Gitea, both the raw pkg/gitea.DoJSON/DoBytes path and the SDK-backed pkg/gitea.NewClient path, without overriding Authorization, Content-Type, or Accept. Co-Authored-By: Codet (GPT-5-Codex) --- README.md | 8 +++++++ cmd/cmd.go | 15 ++++++++++++ cmd/cmd_test.go | 52 +++++++++++++++++++++++++++++++++++++++++ pkg/flag/flag.go | 3 +++ pkg/gitea/gitea.go | 28 +++++++++++++++++++++- pkg/gitea/gitea_test.go | 49 ++++++++++++++++++++++++++++++++++++++ pkg/gitea/rest.go | 4 ++-- pkg/gitea/rest_test.go | 41 ++++++++++++++++++++++++++++++++ 8 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 pkg/gitea/gitea_test.go diff --git a/README.md b/README.md index 91311de..6427188 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,14 @@ make install Pass the Gitea host and access token as command-line flags or environment variables, flags take precedence. Run `gitea-mcp --help` for the full list of flags and environment variables. Logs are written to `$HOME/.gitea-mcp/gitea-mcp.log`, add `-d` for debug logging. +Set `GITEA_EXTRA_HEADERS` to a JSON object of header name/value pairs to send with every outbound request to Gitea, for example when Gitea sits behind Cloudflare Access: + +```bash +export GITEA_EXTRA_HEADERS='{"CF-Access-Client-Id":"id","CF-Access-Client-Secret":"secret"}' +``` + +These headers never override `Authorization`, `Content-Type`, or `Accept` set by `gitea-mcp` itself. + ### MCP protocol and HTTP transport The server supports MCP up to `2026-07-28` and negotiates down to the client's version, advertising only the `tools` capability. Tool and Gitea failures return a `tools/call` result with `result.isError: true`, while malformed requests and server faults stay JSON-RPC errors. diff --git a/cmd/cmd.go b/cmd/cmd.go index 70566c4..e5430e5 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -2,9 +2,11 @@ package cmd import ( "context" + "encoding/json" "flag" "fmt" "io" + "net/http" "os" "strconv" "strings" @@ -86,6 +88,7 @@ func initFlagSet(fs *flag.FlagSet, args []string, getenv func(string) string, re fmt.Fprintf(w, " GITEA_ACCESS_TOKEN\tProvide access token\n") fmt.Fprintf(w, " GITEA_ACCESS_TOKEN_FILE\tPath to a file containing the access token (e.g. a Docker secret)\n") fmt.Fprintf(w, " GITEA_DEBUG\tSet to 'true' for debug mode\n") + fmt.Fprintf(w, " GITEA_EXTRA_HEADERS\tJSON object of extra HTTP headers to send with Gitea API requests\n") fmt.Fprintf(w, " GITEA_HOST\tOverride Gitea host URL\n") fmt.Fprintf(w, " GITEA_INSECURE\tSet to 'true' to ignore TLS errors\n") fmt.Fprintf(w, " GITEA_MAX_INLINE_ATTACHMENT_BYTES\tOverride inline image attachment size limit in bytes\n") @@ -164,6 +167,18 @@ func initFlagSet(fs *flag.FlagSet, args []string, getenv func(string) string, re flagPkg.MaxInlineAttachmentBytes = parsed } } + if val := getenv("GITEA_EXTRA_HEADERS"); val != "" { + var headers map[string]string + if err := json.Unmarshal([]byte(val), &headers); err != nil { + fmt.Fprintf(stderr, "invalid GITEA_EXTRA_HEADERS: %v\n", err) + osExit(1) + } + extraHeaders := make(http.Header, len(headers)) + for name, value := range headers { + extraHeaders.Set(name, value) + } + flagPkg.ExtraHeaders = extraHeaders + } } // normalizeScope trims whitespace, lowercases, and converts internal spaces diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 24ed0fb..d6eaea3 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -5,6 +5,7 @@ import ( "flag" "maps" "slices" + "strings" "testing" flagPkg "gitea.com/gitea/gitea-mcp/pkg/flag" @@ -95,3 +96,54 @@ func TestInitFlagSetScopes(t *testing.T) { }) } } + +func TestInitFlagSetExtraHeaders(t *testing.T) { + t.Cleanup(func() { flagPkg.ExtraHeaders = nil }) + + getenv := func(key string) string { + if key == "GITEA_EXTRA_HEADERS" { + return `{"CF-Access-Client-Id":"id","CF-Access-Client-Secret":"secret"}` + } + return "" + } + readFile := func(string) ([]byte, error) { return nil, nil } + fs := flag.NewFlagSet("test", flag.ContinueOnError) + var stderr bytes.Buffer + + initFlagSet(fs, []string{}, getenv, readFile, &stderr) + + if got := flagPkg.ExtraHeaders.Get("CF-Access-Client-Id"); got != "id" { + t.Errorf("ExtraHeaders[CF-Access-Client-Id] = %q, want %q", got, "id") + } + if got := flagPkg.ExtraHeaders.Get("CF-Access-Client-Secret"); got != "secret" { + t.Errorf("ExtraHeaders[CF-Access-Client-Secret] = %q, want %q", got, "secret") + } +} + +func TestInitFlagSetExtraHeadersInvalidJSON(t *testing.T) { + t.Cleanup(func() { flagPkg.ExtraHeaders = nil }) + + origOsExit := osExit + var exitCode int + osExit = func(code int) { exitCode = code } + t.Cleanup(func() { osExit = origOsExit }) + + getenv := func(key string) string { + if key == "GITEA_EXTRA_HEADERS" { + return `not-json` + } + return "" + } + readFile := func(string) ([]byte, error) { return nil, nil } + fs := flag.NewFlagSet("test", flag.ContinueOnError) + var stderr bytes.Buffer + + initFlagSet(fs, []string{}, getenv, readFile, &stderr) + + if exitCode != 1 { + t.Errorf("exitCode = %d, want 1", exitCode) + } + if !strings.Contains(stderr.String(), "GITEA_EXTRA_HEADERS") { + t.Errorf("stderr = %q, want mention of GITEA_EXTRA_HEADERS", stderr.String()) + } +} diff --git a/pkg/flag/flag.go b/pkg/flag/flag.go index 1790ee8..5b6fba9 100644 --- a/pkg/flag/flag.go +++ b/pkg/flag/flag.go @@ -1,5 +1,7 @@ package flag +import "net/http" + var ( Host string Bind string @@ -15,4 +17,5 @@ var ( Debug bool AllowedTools map[string]struct{} AllowedScopes map[string]struct{} + ExtraHeaders http.Header ) diff --git a/pkg/gitea/gitea.go b/pkg/gitea/gitea.go index de8122c..00e7cfc 100644 --- a/pkg/gitea/gitea.go +++ b/pkg/gitea/gitea.go @@ -30,6 +30,32 @@ func sharedTransport() *http.Transport { return sharedTrans } +// extraHeaderTransport injects flag.ExtraHeaders into every request, without +// overriding headers the caller already set (e.g. Authorization, Content-Type, +// Accept). It reads flag.ExtraHeaders on each round trip rather than caching +// it, so tests can change it between requests. +type extraHeaderTransport struct { + base http.RoundTripper +} + +func (t *extraHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) { + headers := flag.ExtraHeaders + if len(headers) == 0 { + return t.base.RoundTrip(req) + } + cloned := req.Clone(req.Context()) + for name, values := range headers { + if cloned.Header.Get(name) == "" { + cloned.Header[name] = values + } + } + return t.base.RoundTrip(cloned) +} + +func giteaTransport() http.RoundTripper { + return &extraHeaderTransport{base: sharedTransport()} +} + // NewClient returns a cached *gitea.Client keyed by host+token. The SDK's per-client // version cache and the shared transport let us reuse keep-alive connections // and avoid the SDK's /api/v1/version preflight on every tool call. @@ -40,7 +66,7 @@ func NewClient(token string) (*gitea.Client, error) { } httpClient := &http.Client{ - Transport: sharedTransport(), + Transport: giteaTransport(), CheckRedirect: checkRedirect, } opts := []gitea.ClientOption{ diff --git a/pkg/gitea/gitea_test.go b/pkg/gitea/gitea_test.go new file mode 100644 index 0000000..8223a98 --- /dev/null +++ b/pkg/gitea/gitea_test.go @@ -0,0 +1,49 @@ +package gitea + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "gitea.com/gitea/gitea-mcp/pkg/flag" +) + +func TestNewClient_SendsExtraHeaders(t *testing.T) { + var gotClientID, gotAuthorization string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotClientID = r.Header.Get("CF-Access-Client-Id") + gotAuthorization = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"login":"octocat"}`)) + })) + defer srv.Close() + + origHost := flag.Host + origExtraHeaders := flag.ExtraHeaders + defer func() { + flag.Host = origHost + flag.ExtraHeaders = origExtraHeaders + }() + flag.Host = srv.URL + flag.ExtraHeaders = http.Header{ + "Cf-Access-Client-Id": []string{"client-id"}, + "Authorization": []string{"should-not-override"}, + } + + client, err := NewClient("the-token") + if err != nil { + t.Fatalf("NewClient returned error: %v", err) + } + if _, _, err := client.Users.GetMyUserInfo(context.Background()); err != nil { + t.Fatalf("GetMyUserInfo returned error: %v", err) + } + + if gotClientID != "client-id" { + t.Fatalf("CF-Access-Client-Id header = %q, want %q", gotClientID, "client-id") + } + if gotAuthorization != "token the-token" { + t.Fatalf("Authorization header = %q, want %q", gotAuthorization, "token the-token") + } +} diff --git a/pkg/gitea/rest.go b/pkg/gitea/rest.go index 76fe1ad..cf9c2f2 100644 --- a/pkg/gitea/rest.go +++ b/pkg/gitea/rest.go @@ -59,7 +59,7 @@ var ( func restHTTPClient() *http.Client { restClientOnce.Do(func() { restClient = &http.Client{ - Transport: sharedTransport(), + Transport: giteaTransport(), Timeout: httpClientTimeout, CheckRedirect: checkRedirect, } @@ -180,7 +180,7 @@ func DoJSON(ctx context.Context, method, path string, query url.Values, body, re func attachmentHTTPClient(origin *url.URL) *http.Client { return &http.Client{ - Transport: sharedTransport(), + Transport: giteaTransport(), CheckRedirect: func(req *http.Request, via []*http.Request) error { if err := checkRedirect(req, via); err != nil { return err diff --git a/pkg/gitea/rest_test.go b/pkg/gitea/rest_test.go index 2d89e46..8cfa322 100644 --- a/pkg/gitea/rest_test.go +++ b/pkg/gitea/rest_test.go @@ -62,3 +62,44 @@ func TestDoJSON_LimitsErrorResponseBody(t *testing.T) { t.Fatalf("expected body length %d, got %d", errBodySnippetSize, len(httpErr.Body)) } } + +func TestDoJSON_SendsExtraHeaders(t *testing.T) { + var gotClientID, gotAuthorization string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotClientID = r.Header.Get("CF-Access-Client-Id") + gotAuthorization = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "{}") + })) + defer srv.Close() + + origHost := flag.Host + origToken := flag.Token + origExtraHeaders := flag.ExtraHeaders + defer func() { + flag.Host = origHost + flag.Token = origToken + flag.ExtraHeaders = origExtraHeaders + }() + flag.Host = srv.URL + flag.Token = "the-token" + flag.ExtraHeaders = http.Header{ + "Cf-Access-Client-Id": []string{"client-id"}, + "Authorization": []string{"should-not-override"}, + } + + var out map[string]any + status, err := DoJSON(context.Background(), http.MethodGet, "repos/owner/repo", nil, nil, &out) + if err != nil { + t.Fatalf("DoJSON returned error: %v", err) + } + if status != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, status) + } + if gotClientID != "client-id" { + t.Fatalf("CF-Access-Client-Id header = %q, want %q", gotClientID, "client-id") + } + if gotAuthorization != "token the-token" { + t.Fatalf("Authorization header = %q, want %q", gotAuthorization, "token the-token") + } +}