Files
gitea-mcp/pkg/debug/debug.go
Klaus 316c2c4275
check-and-test / check-and-test (pull_request) Has been cancelled
debug: structured tool/HTTP logging, request IDs, config dump
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>
2026-05-23 19:04:28 +00:00

149 lines
3.8 KiB
Go

// Package debug provides helpers for the structured debug-mode plumbing:
// request IDs, baseline argument/result redaction, and result-preview
// truncation. See issue #13.
//
// Redaction here is intentionally a thin baseline. Issue #5 will introduce a
// shared redactor; once that lands the helpers in this file should defer to
// it instead of doing their own key-name matching.
package debug
import (
"context"
"crypto/rand"
"encoding/hex"
"net/url"
"strings"
"unicode/utf8"
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
)
// MaxResultPreview caps the size of result snippets in debug logs so a huge
// response body doesn't dominate the log file.
const MaxResultPreview = 1024
// NewRequestID returns a short hex-encoded identifier used to correlate
// log lines for a single tool invocation across the middleware, the result
// formatter, and the HTTP RoundTripper.
func NewRequestID() string {
var b [6]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand failing is exceptional; fall back to a fixed marker
// rather than panicking inside an observability-only code path.
return "req-unknown"
}
return hex.EncodeToString(b[:])
}
// RequestIDFromContext returns the request ID from ctx, or "" if absent.
func RequestIDFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
if v, ok := ctx.Value(mcpContext.RequestIDContextKey).(string); ok {
return v
}
return ""
}
// ToolNameFromContext returns the tool name recorded by the logging
// middleware, or "" if the call wasn't routed through it (e.g. direct REST
// helper calls in tests).
func ToolNameFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
if v, ok := ctx.Value(mcpContext.ToolNameContextKey).(string); ok {
return v
}
return ""
}
// sensitiveKeys are argument names that should never appear verbatim in logs.
// Match is case-insensitive and substring-based to catch variants like
// "access_token", "api_key", "client_secret".
var sensitiveKeys = []string{
"token", "password", "passwd", "secret", "api_key", "apikey",
"authorization", "auth", "credential",
}
// RedactArgs walks a tool-argument value and returns a copy with sensitive
// fields masked. Non-map / non-slice values are returned as-is. The input is
// not mutated.
func RedactArgs(v any) any {
switch t := v.(type) {
case map[string]any:
out := make(map[string]any, len(t))
for k, val := range t {
if isSensitiveKey(k) {
out[k] = "[REDACTED]"
continue
}
out[k] = RedactArgs(val)
}
return out
case []any:
out := make([]any, len(t))
for i, val := range t {
out[i] = RedactArgs(val)
}
return out
default:
return v
}
}
func isSensitiveKey(k string) bool {
lower := strings.ToLower(k)
for _, s := range sensitiveKeys {
if strings.Contains(lower, s) {
return true
}
}
return false
}
// StripQueryTokens removes query parameters whose names look sensitive so
// they don't end up in HTTP-level URL logs. Returns the input unchanged if
// it can't be parsed.
func StripQueryTokens(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
q := u.Query()
changed := false
for k := range q {
if isSensitiveKey(k) {
q.Set(k, "REDACTED")
changed = true
}
}
if !changed {
return rawURL
}
u.RawQuery = q.Encode()
return u.String()
}
// TruncatePreview returns s clipped to MaxResultPreview runes, with an
// ellipsis suffix if anything was dropped. Operates on runes (not bytes) so
// we don't slice through the middle of a UTF-8 sequence.
func TruncatePreview(s string) string {
if utf8.RuneCountInString(s) <= MaxResultPreview {
return s
}
var b strings.Builder
b.Grow(MaxResultPreview + 4)
count := 0
for _, r := range s {
if count >= MaxResultPreview {
break
}
b.WriteRune(r)
count++
}
b.WriteString("...")
return b.String()
}