// 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() }