316c2c4275
check-and-test / check-and-test (pull_request) Has been cancelled
Closes #13. Today GITEA_DEBUG=true only flips the zap level to debug, dumps a contextless `Text Result:` blob per tool call, and enables the gitea SDK's own debug stream (which doesn't merge with our log file). That is not enough to diagnose a misbehaving tool call in production. This change introduces: - A `pkg/middleware.ToolLogging` ToolHandlerMiddleware that logs each tool invocation on entry and exit with structured fields: tool name, redacted args, request_id, token source, duration_ms, and status. The request_id is stashed in the context so downstream layers can correlate. - A `loggingRoundTripper` in `pkg/gitea` that wraps the shared HTTP transport and emits one structured line per upstream Gitea API call (method, token-stripped URL, status, duration_ms, bytes). This replaces — and is far more useful than — `gitea.SetDebugMode()`. - An effective-config dump at startup (`logEffectiveConfig`) when debug+config scope is on, so "did my env var get picked up?" becomes answerable from the log file. - Token source tracking. CLI flag / GITEA_ACCESS_TOKEN / GITEA_ACCESS_TOKEN_FILE / per-request Authorization header are now distinguished in logs as flag/env/env-file/header. - A baseline redactor in `pkg/debug` that masks args / query params whose keys look like secrets (token, password, secret, api_key, authorization, …). This is the placeholder for issue #5's shared redactor; once that lands, callers here should defer to it. - `GITEA_DEBUG_SCOPES` env var to scope debug output to any subset of `tools,http,config`. Default (unset) is "everything". Test coverage was added for the redactor, scope flag, request-id helpers, preview truncation (multibyte-safe), and the middleware's context wiring + error propagation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
102 lines
3.0 KiB
Go
102 lines
3.0 KiB
Go
package gitea
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
|
|
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
|
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
|
|
|
"code.gitea.io/sdk/gitea"
|
|
)
|
|
|
|
var (
|
|
clientCache sync.Map // token -> *gitea.Client
|
|
sharedTransOnce sync.Once
|
|
sharedTrans *http.Transport
|
|
sharedRTOnce sync.Once
|
|
sharedRT http.RoundTripper
|
|
)
|
|
|
|
func sharedTransport() *http.Transport {
|
|
sharedTransOnce.Do(func() {
|
|
sharedTrans = http.DefaultTransport.(*http.Transport).Clone()
|
|
if flag.Insecure {
|
|
sharedTrans.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // user-requested insecure mode
|
|
}
|
|
})
|
|
return sharedTrans
|
|
}
|
|
|
|
// sharedRoundTripper returns the RoundTripper used by every HTTP client in
|
|
// this package. In debug mode it wraps the underlying transport with a
|
|
// logger that records one entry per upstream Gitea call. Wrapping at the
|
|
// RoundTripper layer means both the SDK-driven path (NewClient) and the
|
|
// hand-rolled REST helpers (DoJSON/DoBytes) get the same instrumentation.
|
|
func sharedRoundTripper() http.RoundTripper {
|
|
sharedRTOnce.Do(func() {
|
|
var rt http.RoundTripper = sharedTransport()
|
|
if flag.Debug {
|
|
rt = &loggingRoundTripper{base: rt}
|
|
}
|
|
sharedRT = rt
|
|
})
|
|
return sharedRT
|
|
}
|
|
|
|
// 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.
|
|
func NewClient(token string) (*gitea.Client, error) {
|
|
key := flag.Host + "\x00" + token
|
|
if v, ok := clientCache.Load(key); ok {
|
|
return v.(*gitea.Client), nil
|
|
}
|
|
|
|
httpClient := &http.Client{
|
|
Transport: sharedRoundTripper(),
|
|
CheckRedirect: checkRedirect,
|
|
}
|
|
// We intentionally don't enable gitea.SetDebugMode() even when
|
|
// flag.Debug is on: that mode writes to a separate, unstructured stream
|
|
// that doesn't merge with our zap log file. The loggingRoundTripper
|
|
// installed above provides equivalent (and structured) visibility.
|
|
opts := []gitea.ClientOption{
|
|
gitea.SetToken(token),
|
|
gitea.SetHTTPClient(httpClient),
|
|
}
|
|
client, err := gitea.NewClient(flag.Host, opts...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create gitea client err: %w", err)
|
|
}
|
|
client.SetUserAgent("gitea-mcp-server/" + flag.Version)
|
|
|
|
actual, _ := clientCache.LoadOrStore(key, client)
|
|
return actual.(*gitea.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 {
|
|
token = flag.Token
|
|
}
|
|
return NewClient(token)
|
|
}
|