Files
pad/internal/server/middleware_mcp_audit.go
T
xarmian 40621ff58d feat(metrics): session-id-keyed TTL sweep for mcp_active_sessions (TASK-1120) (#400)
* feat(metrics): session-id-keyed TTL sweep for mcp_active_sessions (TASK-1120)

Replaces the naive +1/-1 active-sessions accounting from TASK-961.
The old logic bumped on JSON-RPC `initialize` and decremented on HTTP
DELETE — but a client that crashed, lost network, or restarted
mid-session never emitted DELETE, so the gauge drifted upward
monotonically until the pad-cloud server restarted.

Approach:

- `internal/server/middleware_mcp_session.go` (new) — mcpSessionTracker
  is an in-memory map keyed by Mcp-Session-Id (the canonical header
  set by mcp-go's StreamableHTTPServer on initialize responses and
  echoed by the client on subsequent requests). Touch updates
  lastSeen on insert + refresh; evict removes; periodic sweep evicts
  entries older than the TTL.
- Gauge is `Set(len(sessions))` via an onChange callback — single
  consistent observation per state-changing op, no risk of gauge
  drifting from map size on a multi-evict sweep.
- Lifecycle: spawned by SetMCPTransport (alongside startMCPAuditWriter),
  shut down from Server.Stop. Idempotent on both sides.
- Configurable via PAD_MCP_SESSION_TTL (default 30m) and
  PAD_MCP_SESSION_SWEEP_INTERVAL (default 5m). cmd/pad calls
  Server.SetMCPSessionTrackerConfig before SetMCPTransport.

Other changes:

- `recordMCPCallMetrics` no longer touches the active-sessions gauge.
  Updated comment + signature kept (callers pass the same args; the
  unused params are explicitly underscored).
- `MCPAuditLog` middleware now calls trackMCPSession after
  next.ServeHTTP — single new line in the audit hot path.
- `TestMCPAudit_BufferFull_DropsAndIncrementsCounter` updated to also
  shut down the new session tracker before bg.Wait(), since
  SetMCPTransport now spawns two goroutines on srv.bg.

Test coverage (16 tests, all green under -race):
- Tracker unit: touch insert/dedup, empty-id no-op, evict
  remove/non-existent, sweep eviction with single onChange,
  nil-onChange safety, concurrent touch/evict, run() clean shutdown.
- Server-side integration: lifecycle happy path (initialize → call →
  DELETE leaves gauge at 0), failed initialize doesn't open,
  no-session-id no-op, nil tracker safety, idempotent start, DELETE
  evicts on any status (transient 5xx on shutdown still counts).
- Regression guard: TestRecordMCPCallMetrics_DoesNotTouchSessionGauge
  pins that the audit-side helper has migrated off the gauge.

Parent: PLAN-943. Follow-up to TASK-961 (PR #398). Closes the
"sessions drift on client crashes" caveat documented in the metric's
help text + the Grafana panel description.

* fix(metrics): emit Mcp-Session-Id + serialize gauge updates per Codex review (round 1)

Two findings from Codex review on PR #400:

1. WithStateLess(true) wired StatelessSessionIdManager whose Generate()
   returns "" — mcp-go never set the Mcp-Session-Id response header
   in production, so the new tracker no-op'd on every initialize and
   the active-sessions gauge stayed at 0.

   Fix: introduce padMCPGenerateOnlySessionIDManager in cmd/pad/main.go.
   Generates a UUID per initialize (so the response carries the
   header — tracker can observe), but Validate accepts ANY incoming
   value (including empty / arbitrary). Preserves the original
   "stateless server, every request stands alone" contract while
   making the session-id observable. Documented why mcp-go's two
   shipped stateless managers don't fit (one breaks observability,
   the other breaks back-compat for clients that never echo the ID).

2. touch / evict / sweep computed `len(sessions)` under the mutex
   then released the lock BEFORE invoking onChange. Two concurrent
   inserts could compute (n=1, n=2) under the lock and then race the
   callback writes — last writer wins on the gauge, leaving it
   permanently inconsistent with the map size.

   Fix: hold the mutex across onChange. Trade-off documented: any
   future onChange that re-enters the tracker would deadlock, but
   that's a clear failure mode rather than silent metric corruption.
   Added TestMCPSessionTracker_OnChangeUnderLock that asserts a
   strictly-monotonic observation sequence under 32-goroutine
   concurrent inserts; passes 5x in a row under -race.
2026-05-03 17:40:18 -04:00

590 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log/slog"
"net/http"
"strconv"
"sync"
"sync/atomic"
"time"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
// MCP audit log middleware (PLAN-943 TASK-960).
//
// Wraps the /mcp Streamable HTTP handler AFTER MCPBearerAuth runs, so
// the audit row carries the resolved user + token identity stashed on
// context. Records one row per HTTP request hitting /mcp:
//
// - Successful tool calls (200 OK with a JSON-RPC result envelope).
// - Tool calls that the inner handler rejected (200 OK with an error
// envelope — JSON-RPC errors are still HTTP 200 per spec).
// - Authorization gate misses (401 / 403).
// - Server errors (5xx).
//
// Writes are async via a buffered channel + worker goroutine. The
// hot path captures the HTTP-level outcome, builds an MCPAuditEntry,
// and tries a non-blocking channel send; if the buffer is full
// (DB outage / writer too slow), the entry is dropped and the
// mcp_audit_dropped_total metric increments. The request never
// blocks — the spec is "audit is best-effort, /mcp latency is not."
//
// Body sniffing:
//
// To populate tool_name + args_hash without consuming the body that
// the inner JSON-RPC handler needs, we wrap r.Body in a tee that
// buffers up to mcpAuditBodyMaxBytes. The first frame is parsed
// after-the-fact (post-handler) because Streamable HTTP can carry
// either a single JSON-RPC envelope or an SSE stream — we only sniff
// the request body, not the response. tool_name defaults to "(unknown)"
// when the body wasn't a JSON-RPC envelope or was over the cap; that's
// rare and intentionally ugly so it stands out in audit reviews.
const (
// mcpAuditBufferSize bounds the in-flight queue between the
// hot-path producer and the disk-bound writer. Sized at 4096:
// at our worst-case bursting rate (a few hundred req/s per node
// during a tool-call cascade) this absorbs ~10–30 seconds of
// backpressure before the drop path fires. Memory cost ~1MB at
// full saturation (each entry ≈ 256B), which is cheap for the
// resilience win.
mcpAuditBufferSize = 4096
// mcpAuditBodyMaxBytes caps how much of a request body we'll buffer
// for tool_name / args_hash extraction. Tool args can be large
// (item content posts), but we only need the JSON-RPC method +
// params.name + params.arguments — typically <2KB. Anything past
// the cap is opaque to audit and the hash collapses to "" rather
// than partial data, which would produce misleading "matching"
// hashes for unrelated calls.
mcpAuditBodyMaxBytes = 64 * 1024
// mcpAuditRetention is the age cutoff for the periodic sweep.
// 90 days per TASK-960's spec.
mcpAuditRetention = 90 * 24 * time.Hour
// mcpAuditSweepInterval is how often the retention sweeper runs.
// 24h is overkill for most deploys (audit growth is bounded by
// MCP traffic) but keeps the table hot-set bounded after a
// burst.
mcpAuditSweepInterval = 24 * time.Hour
// mcpAuditWriteTimeout caps how long a single InsertMCPAuditEntry
// can block the writer goroutine. Longer than the SQLite busy
// timeout (30s) so a rare contention spike doesn't drop the row,
// but bounded so a runaway DB doesn't pin the writer forever.
mcpAuditWriteTimeout = 60 * time.Second
)
// mcpAuditWriter owns the buffered queue + worker goroutine that
// drains it into the store. One per Server. Started in
// startMCPAuditWriter; stopped in Server.Stop via the bg WaitGroup
// + a stop channel.
type mcpAuditWriter struct {
store *store.Store
queue chan models.MCPAuditEntryInput
stop chan struct{}
stopped sync.Once
// dropped counts entries shed because the queue was full at
// send time. Surfaced via Prometheus when metrics are wired
// (read in mcpAuditDroppedSnapshot below). Atomic so the
// hot path doesn't lock to bump it.
dropped atomic.Uint64
}
// newMCPAuditWriter constructs a writer with a 4096-deep queue. The
// caller is responsible for kicking the worker via run() and for
// stopping it via stop().
func newMCPAuditWriter(s *store.Store) *mcpAuditWriter {
return &mcpAuditWriter{
store: s,
queue: make(chan models.MCPAuditEntryInput, mcpAuditBufferSize),
stop: make(chan struct{}),
}
}
// enqueue tries to push one entry without blocking. Returns true on
// success; on a full queue, increments the drop counter and returns
// false. The MCP middleware ignores the return value — it's strictly
// observability.
func (w *mcpAuditWriter) enqueue(in models.MCPAuditEntryInput) bool {
select {
case w.queue <- in:
return true
default:
w.dropped.Add(1)
return false
}
}
// run is the worker goroutine. Drains queue → InsertMCPAuditEntry,
// logs (but doesn't crash on) DB errors, exits on stop. Tracked via
// Server.bg so Stop() can drain in-flight writes.
func (w *mcpAuditWriter) run() {
for {
select {
case <-w.stop:
// Drain whatever's already in the queue before exit so
// a clean shutdown loses no rows. Bounded by the queue
// depth so this doesn't hang forever on a stuck DB.
for {
select {
case in := <-w.queue:
w.write(in)
default:
return
}
}
case in := <-w.queue:
w.write(in)
}
}
}
// write performs a single insert with a per-call timeout. DB errors
// log + drop the row — there's no retry path because the audit log
// is best-effort by design. Repeated failures will show up in the
// structured logs as "mcp audit insert failed" warns; ops can alert
// on that pattern.
func (w *mcpAuditWriter) write(in models.MCPAuditEntryInput) {
// Timeout via a goroutine wrapper because store methods don't
// take a context today (refactoring every store method to
// accept ctx is out of scope for this task). The timeout is
// observability-only — if InsertMCPAuditEntry returns after
// timeout, the row still lands; we just don't wait for it from
// the worker.
done := make(chan error, 1)
go func() {
done <- w.store.InsertMCPAuditEntry(in)
}()
select {
case err := <-done:
if err != nil {
slog.Warn("mcp audit insert failed",
"error", err,
"user_id", in.UserID,
"token_kind", string(in.TokenKind),
"tool_name", in.ToolName)
}
case <-time.After(mcpAuditWriteTimeout):
slog.Warn("mcp audit insert timed out",
"user_id", in.UserID,
"tool_name", in.ToolName)
}
}
// shutdown signals the worker to drain + exit. Idempotent.
func (w *mcpAuditWriter) shutdown() {
w.stopped.Do(func() {
close(w.stop)
})
}
// mcpAuditDroppedSnapshot returns the cumulative number of audit
// entries dropped due to buffer-full backpressure. Exported as a
// method on Server so admin / metrics surfaces can read it. Returns
// 0 when no audit writer is configured (self-hosted builds without
// MCP).
func (s *Server) mcpAuditDroppedSnapshot() uint64 {
if s.mcpAudit == nil {
return 0
}
return s.mcpAudit.dropped.Load()
}
// MCPAuditLog wraps next with audit recording. Mounts after
// MCPBearerAuth in registerMCPRoutes so currentUser + the token
// identity stashed by the auth middleware are already on context.
//
// No-op when the audit writer hasn't been configured (selfhost
// builds without MCP, tests that didn't call startMCPAuditWriter).
// In that case the middleware passes the request straight through —
// MCP still works, just without forensics.
func (s *Server) MCPAuditLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.mcpAudit == nil {
next.ServeHTTP(w, r)
return
}
start := time.Now().UTC()
// Sniff the request body BEFORE the inner handler runs.
//
// Why read up-front rather than tee on the inner handler's
// read: a TeeReader only fills the sniffer buffer when
// something reads from it. mcp-go's streamable HTTP server
// reads via the JSON-RPC decoder, which IS a reader — but
// when an upstream rejects the request early (auth gate,
// rate limit) the body never gets touched and the sniffer
// stays empty. Reading proactively guarantees the audit row
// has tool_name even on rejected requests, which is exactly
// the case forensics cares about most.
//
// MCP request bodies are bounded JSON-RPC envelopes (one
// per POST), not streams — reading them in full is safe.
// We cap at mcpAuditBodyMaxBytes so a pathologically large
// body doesn't pin memory; past the cap the audit row sees
// "(unknown)" tool but the inner handler still gets the
// full body via the bytes.NewReader replacement.
var snifferBytes []byte
if r.Body != nil && r.Method != http.MethodGet {
limited := io.LimitReader(r.Body, mcpAuditBodyMaxBytes)
peeked, _ := io.ReadAll(limited)
snifferBytes = peeked
// Stitch the peeked bytes back together with whatever's
// left past the cap so the inner handler sees the
// original payload intact. r.Body's Closer survives
// because we wrap, not replace.
restored := struct {
io.Reader
io.Closer
}{
Reader: io.MultiReader(bytes.NewReader(peeked), r.Body),
Closer: r.Body,
}
r.Body = restored
}
// Wrap the response writer so we can read the status code +
// body length AFTER next.ServeHTTP returns. chi already
// provides this — reuse rather than redo.
ww := chimiddleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
latencyMs := int(time.Since(start) / time.Millisecond)
// Pull identity off context. If MCPBearerAuth didn't run
// (shouldn't happen — we mount after it — but defensive),
// skip the row. We don't write rows we can't attribute.
user, _ := CurrentUserFromContext(r.Context())
if user == nil {
return
}
kind, ref := MCPTokenIdentityFromContext(r.Context())
if kind == "" || ref == "" {
return
}
// X-Request-ID is set by chi's RequestID middleware higher
// up the stack. If somehow absent, fall back to a synthesized
// "" so the column constraint (NOT NULL) doesn't reject the
// insert; downstream forensics will see an empty string and
// know to ignore correlation.
reqID := chimiddleware.GetReqID(r.Context())
if reqID == "" {
reqID = strconv.FormatInt(start.UnixNano(), 36)
}
toolName, argsHash := parseMCPRequestBody(snifferBytes)
status, errorKind := classifyMCPResult(ww.Status())
entry := models.MCPAuditEntryInput{
Timestamp: start,
UserID: user.ID,
WorkspaceID: "", // workspace context isn't reliably on the request — populated only when present below
TokenKind: models.TokenKind(kind),
TokenRef: ref,
ToolName: toolName,
ArgsHash: argsHash,
ResultStatus: status,
ErrorKind: errorKind,
LatencyMs: latencyMs,
RequestID: reqID,
}
s.mcpAudit.enqueue(entry)
// TASK-961: emit Prometheus metrics for the same hot path the
// audit row covers. Same data, different consumer:
// - audit log → forensics, per-row drill-down, retention 90d
// - metrics → real-time dashboards, alerting, no per-user PII
// Fired AFTER enqueue so a metrics misconfigure (nil registry
// in test builds) can't accidentally drop the audit row.
s.recordMCPCallMetrics(toolName, string(status), user.ID, time.Since(start), r, ww.Status())
// TASK-1120: session-id-keyed accounting for the
// pad_mcp_active_sessions gauge. Replaces the naive
// "tool==initialize → +1, method==DELETE → -1" logic that
// used to live in recordMCPCallMetrics — that path drifted
// upward on crashed clients. The tracker keys by Mcp-Session-Id
// and a periodic sweeper evicts stale entries past the TTL,
// so the gauge converges on reality even when clients vanish
// without a clean DELETE.
//
// Pulling the headers via closures (rather than passing the
// http.Header values) lets trackMCPSession be tested with
// plain map[string]string fixtures without spinning up a
// full http.Request — the seam matches the helper's signature
// in middleware_mcp_session.go.
s.trackMCPSession(r.Header.Get, ww.Header().Get, r.Method, ww.Status())
})
}
// recordMCPCallMetrics fans the audit-row data out to Prometheus.
// No-op when metrics aren't wired (selfhost / tests). Kept separate
// from MCPAuditLog so the audit hot path stays focused on its own
// concerns and metrics maintenance touches one method.
//
// Session-gauge accounting moved to trackMCPSession in TASK-1120 —
// the previous "tool==initialize → +1, method==DELETE → -1" logic
// drifted upward on crashed clients. The tracker keys by
// Mcp-Session-Id with a TTL sweeper, so the gauge converges on
// reality without depending on a clean shutdown handshake. The r
// + httpStatus parameters stay in the signature for now — callers
// already plumb them and a future per-status histogram would want
// them back — they're explicitly unused via the underscore names.
func (s *Server) recordMCPCallMetrics(tool, status, userID string, dur time.Duration, _ *http.Request, _ int) {
if s.metrics == nil {
return
}
s.metrics.MCPToolCallsTotal.WithLabelValues(userID, tool, status).Inc()
// Histogram is per-tool only — duration distributions per user
// would explode the series count without a clear analytical
// payoff (alerts care about p95 latency per tool, not per user).
s.metrics.MCPToolCallDuration.WithLabelValues(tool).Observe(dur.Seconds())
}
// parseMCPRequestBody extracts (tool_name, args_hash) from the
// JSON-RPC envelope in body. Two cases:
//
// - method == "tools/call" → tool_name = params.name,
// args_hash = SHA-256 of canonical-JSON params.arguments.
// - any other method → tool_name = method itself
// (e.g. "initialize", "tools/list"), args_hash = "" because
// these calls have no per-call arguments worth correlating.
//
// Returns ("(unknown)", "") on parse failure or empty body — gives
// the audit reader a visible signal rather than silently dropping
// the row.
func parseMCPRequestBody(body []byte) (toolName, argsHash string) {
if len(body) == 0 {
return "(unknown)", ""
}
var env struct {
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
if err := json.Unmarshal(body, &env); err != nil || env.Method == "" {
return "(unknown)", ""
}
if env.Method != "tools/call" {
return env.Method, ""
}
var p struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
if err := json.Unmarshal(env.Params, &p); err != nil || p.Name == "" {
return "tools/call", ""
}
return p.Name, hashCanonicalJSON(p.Arguments)
}
// hashCanonicalJSON returns a SHA-256 hex of a canonicalized form of
// the input JSON. "Canonical" here means: round-trip through
// encoding/json to sort object keys (Go's encoder does that natively
// since 1.12 for map[string]any). For arguments that are already a
// JSON object this produces stable hashes across calls with the same
// fields-in-different-order.
//
// Empty / null input returns "" so the column reflects "no args"
// rather than the SHA of "null" / "" — distinguishable in audit
// queries.
func hashCanonicalJSON(raw json.RawMessage) string {
if len(raw) == 0 || string(raw) == "null" {
return ""
}
// Decode-then-encode to canonicalize key order. Failure just
// hashes the raw bytes as-is — we'd rather have a hash that
// might not group exactly than no audit signal at all.
var v interface{}
if err := json.Unmarshal(raw, &v); err != nil {
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])
}
canon, err := json.Marshal(v)
if err != nil {
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])
}
sum := sha256.Sum256(canon)
return hex.EncodeToString(sum[:])
}
// classifyMCPResult maps an HTTP status to (audit_status, error_kind).
//
// JSON-RPC quirk: tool errors come back as 200 OK with an `error`
// field in the response body. We CAN'T see that here without
// snooping the response body too — and the spec for TASK-960 says
// "every MCP request results in exactly one audit-log row," not
// "every successful tool call." So we classify on transport-level
// status, with the understanding that result_status="ok" means
// "the gate let it through" rather than "the tool succeeded." That's
// the right granularity for forensics; per-tool success would need
// the dispatcher to emit its own audit signal post-execution, which
// is out of scope here (and probably belongs as a separate JSON-RPC
// success/failure stream).
func classifyMCPResult(httpStatus int) (models.MCPAuditResultStatus, string) {
switch {
case httpStatus == 0 || httpStatus == http.StatusOK:
return models.MCPAuditResultOK, ""
case httpStatus == http.StatusUnauthorized:
return models.MCPAuditResultDenied, "unauthorized"
case httpStatus == http.StatusForbidden:
return models.MCPAuditResultDenied, "forbidden"
case httpStatus == http.StatusTooManyRequests:
return models.MCPAuditResultDenied, "rate_limited"
case httpStatus >= 500:
return models.MCPAuditResultError, "server_error_" + strconv.Itoa(httpStatus)
case httpStatus >= 400:
return models.MCPAuditResultError, "client_error_" + strconv.Itoa(httpStatus)
}
return models.MCPAuditResultOK, ""
}
// startMCPAuditWriter constructs the audit writer + spawns its
// worker goroutine + spawns the periodic retention sweeper. Called
// once at startup from cmd/pad/main.go (cloud mode only).
//
// No-op if the writer is already running — supports the test pattern
// where multiple Server instances over a shared store would otherwise
// double-spawn.
func (s *Server) startMCPAuditWriter() {
if s.mcpAudit != nil {
return
}
s.mcpAudit = newMCPAuditWriter(s.store)
s.goAsync(func() {
s.mcpAudit.run()
})
s.goAsync(func() {
s.runMCPAuditSweeper()
})
}
// runMCPAuditSweeper drives the 90-day retention sweep on a 24h
// ticker. Exits when the audit writer's stop channel closes (i.e.
// when Server.Stop() runs). One sweep on startup so a long-stopped
// server catches up immediately rather than waiting 24h for the
// first tick.
func (s *Server) runMCPAuditSweeper() {
if s.mcpAudit == nil {
return
}
sweep := func() {
cutoff := time.Now().UTC().Add(-mcpAuditRetention)
n, err := s.store.SweepMCPAuditOlderThan(cutoff)
if err != nil {
slog.Warn("mcp audit retention sweep failed", "error", err)
return
}
if n > 0 {
slog.Info("mcp audit retention swept", "rows", n, "cutoff", cutoff.Format(time.RFC3339))
}
}
sweep()
t := time.NewTicker(mcpAuditSweepInterval)
defer t.Stop()
for {
select {
case <-s.mcpAudit.stop:
return
case <-t.C:
sweep()
}
}
}
// stopMCPAuditWriter signals the writer to drain + exit. Called
// from Server.Stop. Idempotent.
func (s *Server) stopMCPAuditWriter() {
if s.mcpAudit == nil {
return
}
s.mcpAudit.shutdown()
}
// Compile-time guard: pin the middleware's signature so an accidental
// refactor that breaks the http.Handler chain doesn't slip in.
var _ func(http.Handler) http.Handler = (*Server)(nil).MCPAuditLog
// emitMCPAuditDenied records an audit row for a request that
// MCPBearerAuth resolved (so we know the user + token identity) but
// then rejected — currently only the per-token rate-limit branch.
//
// Why a direct emit rather than letting the audit middleware catch
// it: MCPAuditLog is mounted INSIDE MCPBearerAuth, so when bearer
// auth denies before calling next.ServeHTTP, the audit middleware
// never runs. Codex review on PR #389 round 1 caught the resulting
// blind spot. Pre-auth rejections (missing / malformed / unknown
// bearer) intentionally stay un-audited because there's no user to
// attribute the row to and the audit_trail table covers those
// auth-event signals already.
//
// errorKind is one of the static strings classifyMCPResult uses
// (currently only "rate_limited" — extend if more denial paths
// emit through here). Keeping the vocabulary aligned makes the
// admin UI's status badges render consistently regardless of which
// emitter wrote the row.
//
// latencyStart is the moment the request entered MCPBearerAuth so
// the audit row carries real latency for denied requests too —
// useful for spotting "the rate limiter is the slowest thing on the
// hot path" patterns in the admin view.
func (s *Server) emitMCPAuditDenied(r *http.Request, user *models.User, kind, ref, errorKind string, latencyStart time.Time) {
if s.mcpAudit == nil || user == nil || kind == "" || ref == "" {
return
}
reqID := chimiddleware.GetReqID(r.Context())
if reqID == "" {
reqID = strconv.FormatInt(latencyStart.UnixNano(), 36)
}
// Body sniff: same path as the middleware uses, capped at the
// same limit. We can't read r.Body destructively here because
// the rate-limit denial returns immediately afterwards and
// nothing else reads it — but we CAN tee out the first chunk
// for tool_name extraction. If it fails or the body's already
// been read by a prior middleware, parseMCPRequestBody returns
// "(unknown)" which is the expected sentinel.
var snifferBytes []byte
if r.Body != nil && r.Method != http.MethodGet {
limited := io.LimitReader(r.Body, mcpAuditBodyMaxBytes)
snifferBytes, _ = io.ReadAll(limited)
}
toolName, argsHash := parseMCPRequestBody(snifferBytes)
s.mcpAudit.enqueue(models.MCPAuditEntryInput{
Timestamp: latencyStart,
UserID: user.ID,
TokenKind: models.TokenKind(kind),
TokenRef: ref,
ToolName: toolName,
ArgsHash: argsHash,
ResultStatus: models.MCPAuditResultDenied,
ErrorKind: errorKind,
LatencyMs: int(time.Since(latencyStart) / time.Millisecond),
RequestID: reqID,
})
// TASK-961: mirror the denial into the authz-denials counter so
// dashboards and alerts can react in real time. The errorKind
// vocabulary already matches the metric's reason label by design
// (see metrics.go's MCPAuthzDenialsTotal comment for the contract).
if s.metrics != nil && errorKind != "" {
s.metrics.MCPAuthzDenialsTotal.WithLabelValues(errorKind).Inc()
}
}