package mcp import ( "context" "encoding/json" "log/slog" "time" ) // logToolCall emits one structured record per tools/call (hyperguild#144, // OWASP MCP08): who called which tool, against which wing/hall, and how it // ended. Brain content is never copied into the record — only its size — so // the audit trail does not become a second, unscrubbed copy of the brain. func logToolCall(ctx context.Context, name string, args json.RawMessage, start time.Time, err error) { origin, principal := provenanceFrom(ctx) attrs := []any{ "tool", name, "principal", principal, "origin", origin, "duration_ms", time.Since(start).Milliseconds(), } var a struct { Wing string `json:"wing"` Hall string `json:"hall"` Content string `json:"content"` } if json.Unmarshal(args, &a) == nil { if a.Wing != "" { attrs = append(attrs, "wing", a.Wing) } if a.Hall != "" { attrs = append(attrs, "hall", a.Hall) } if a.Content != "" { attrs = append(attrs, "content_bytes", len(a.Content)) } } // The outcome is a class, never the error text: handler errors echo // argument values (a rejected half_life, a requested path), which would // make the audit line a copy of what the caller sent (PR #147 review E6). // The caller already received the full error in the tool response. outcome := "ok" if err != nil { outcome = "error" } attrs = append(attrs, "outcome", outcome) slog.InfoContext(ctx, "mcp tool call", attrs...) }