Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions framework/logstore/rdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,7 @@ func stripNonBillingPayloadBytes(l *Log) {
l.ImageGenerationOutput = ""
}

// searchLogs runs scoped log searches with the requested projection.
func (s *RDBLogStore) searchLogs(ctx context.Context, filters SearchFilters, pagination PaginationOptions, selectColumns string) (*SearchResult, error) {
// Build order clause up front (needed by the data goroutine).
direction := "DESC"
Expand Down Expand Up @@ -1184,6 +1185,7 @@ func (s *RDBLogStore) GetSessionSummary(ctx context.Context, sessionID string) (
}, nil
}

// normalizeAggregateTimestamp normalizes driver-specific timestamp values for aggregate responses.
func normalizeAggregateTimestamp(value any) string {
switch v := value.(type) {
case nil:
Expand Down Expand Up @@ -2460,6 +2462,7 @@ type latencyHistogramBucketData struct {
overheads []float64
}

// toBucket converts accumulated latency values into the response bucket.
func (bd *latencyHistogramBucketData) toBucket(ts int64) LatencyHistogramBucket {
b := LatencyHistogramBucket{
Timestamp: time.Unix(ts, 0).UTC(),
Expand Down Expand Up @@ -4591,6 +4594,25 @@ func (s *RDBLogStore) DeleteLogs(ctx context.Context, ids []string) error {

// applyMCPFilters applies search filters to a GORM query for MCP tool logs
func (s *RDBLogStore) applyMCPFilters(baseQuery *gorm.DB, filters MCPToolLogSearchFilters) *gorm.DB {
if len(filters.UserIDs) > 0 {
baseQuery = baseQuery.Where("user_id IN ?", filters.UserIDs)
}
if len(filters.TeamIDs) > 0 {
baseQuery = baseQuery.Where("team_id IN ?", filters.TeamIDs)
}
if len(filters.CustomerIDs) > 0 {
baseQuery = baseQuery.Where("customer_id IN ?", filters.CustomerIDs)
}
if len(filters.BusinessUnitIDs) > 0 {
baseQuery = baseQuery.Where("business_unit_id IN ?", filters.BusinessUnitIDs)
}
if len(filters.ProjectIDs) > 0 {
baseQuery = baseQuery.Where("project_id IN ?", filters.ProjectIDs)
}
if len(filters.DeviceIDs) > 0 {
baseQuery = baseQuery.Where("device_id IN ?", filters.DeviceIDs)
}

if len(filters.ToolNames) > 0 {
baseQuery = baseQuery.Where("tool_name IN ?", filters.ToolNames)
}
Expand Down Expand Up @@ -4895,6 +4917,7 @@ func (s *RDBLogStore) GetAvailableToolNames(ctx context.Context, limit int, quer
return toolNames, nil
}

// GetAvailableServerLabels lists MCP server labels matching the filter search.
func (s *RDBLogStore) GetAvailableServerLabels(ctx context.Context, limit int, query string) ([]string, error) {
cutoff := time.Now().UTC().AddDate(0, 0, -defaultFilterDataCutoffDays)
var serverLabels []string
Expand Down Expand Up @@ -4943,6 +4966,7 @@ func (s *RDBLogStore) GetAvailableMCPApps(ctx context.Context, limit int, query
return apps, nil
}

// GetAvailableMCPVirtualKeys lists virtual keys represented in visible MCP logs.
func (s *RDBLogStore) GetAvailableMCPVirtualKeys(ctx context.Context, limit int, query string) ([]MCPToolLog, error) {
cutoff := time.Now().UTC().AddDate(0, 0, -defaultFilterDataCutoffDays)
var logs []MCPToolLog
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,26 @@ func TestGetStatsTokenSplit(t *testing.T) {
require.Equal(t, int64(70), stats.CompletionTokens, "completion = 10+20+40")
require.Equal(t, stats.TotalTokens, stats.PromptTokens+stats.CompletionTokens, "split sums to total")
}

// TestMCPAttributionFiltersApplyToRowsAndStats checks each stored scope filters both records and aggregates.
func TestMCPAttributionFiltersApplyToRowsAndStats(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&MCPToolLog{}))
store := &RDBLogStore{db: db, logger: bifrost.NewDefaultLogger(schemas.LogLevelInfo)}
ctx := context.Background()
a, b := "a", "b"
now := time.Now()
require.NoError(t, db.Create(&MCPToolLog{ID: a, ToolName: "Read", Timestamp: now, Status: "success", UserID: &a, TeamID: &a, CustomerID: &a, BusinessUnitID: &a, ProjectID: &a, DeviceID: &a}).Error)
require.NoError(t, db.Create(&MCPToolLog{ID: b, ToolName: "Read", Timestamp: now, Status: "error", UserID: &b, TeamID: &b, CustomerID: &b, BusinessUnitID: &b, ProjectID: &b, DeviceID: &b}).Error)
for _, filters := range []MCPToolLogSearchFilters{{UserIDs: []string{a}}, {TeamIDs: []string{a}}, {CustomerIDs: []string{a}}, {BusinessUnitIDs: []string{a}}, {ProjectIDs: []string{a}}, {DeviceIDs: []string{a}}} {
result, err := store.SearchMCPToolLogs(ctx, filters, PaginationOptions{Limit: 10, SortBy: "timestamp", Order: "desc"})
require.NoError(t, err)
require.Len(t, result.Logs, 1)
require.Equal(t, a, result.Logs[0].ID)
stats, err := store.GetMCPToolLogStats(ctx, filters)
require.NoError(t, err)
require.EqualValues(t, 1, stats.TotalExecutions)
require.EqualValues(t, 100, stats.SuccessRate)
}
}
16 changes: 15 additions & 1 deletion framework/logstore/tables.go
Original file line number Diff line number Diff line change
Expand Up @@ -1354,6 +1354,12 @@ func costsReconcile(a, b float64) bool {
// MCPToolLog represents a log entry for MCP tool executions
// This is separate from the main Log table since MCP tool calls have different fields
type MCPToolLog struct {
// Display names are resolved after scoped reads; IDs remain the recorded attribution.
UserName *string `gorm:"-" json:"user_name,omitempty"`
TeamName *string `gorm:"-" json:"team_name,omitempty"`
CustomerName *string `gorm:"-" json:"customer_name,omitempty"`
BusinessUnitName *string `gorm:"-" json:"business_unit_name,omitempty"`
Comment thread
coderabbitai[bot] marked this conversation as resolved.

ID string `gorm:"primaryKey;type:varchar(255)" json:"id"`
RequestID string `gorm:"type:varchar(255);column:request_id;index:idx_mcp_logs_request_id" json:"request_id,omitempty"` // The original request ID from context
LLMRequestID *string `gorm:"type:varchar(255);column:llm_request_id;index:idx_mcp_logs_llm_request_id" json:"llm_request_id,omitempty"` // Links to the LLM request that triggered this tool call
Expand Down Expand Up @@ -1388,7 +1394,7 @@ type MCPToolLog struct {
// Endpoint-agent context. These are populated for tool calls observed on a
// developer machine by the Bifrost Edge agent (rather than proxied by the
// gateway). Source distinguishes the origin: empty/null for gateway-proxied
// calls, "endpoint" for agent-observed calls.
// calls, "endpoint" for agent-observed MCP calls, "native" for harness tools.
DeviceID *string `gorm:"type:varchar(255);index:idx_mcp_logs_device_id" json:"device_id,omitempty"`
AppKey *string `gorm:"type:varchar(64)" json:"app_key,omitempty"` // Canonical policy key of the detected client app (schemas.AppKeyFromName), e.g. "claude-code"; a slug like App, not a secret or credential
Decision *string `gorm:"type:varchar(16)" json:"decision,omitempty"`
Expand Down Expand Up @@ -1694,6 +1700,13 @@ type WebhookDeliverySearchFilters struct {

// MCPToolLogSearchFilters represents the available filters for MCP tool log searches
type MCPToolLogSearchFilters struct {
UserIDs []string `json:"user_ids,omitempty"`
TeamIDs []string `json:"team_ids,omitempty"`
CustomerIDs []string `json:"customer_ids,omitempty"`
BusinessUnitIDs []string `json:"business_unit_ids,omitempty"`
ProjectIDs []string `json:"project_ids,omitempty"`
DeviceIDs []string `json:"device_ids,omitempty"`

ToolNames []string `json:"tool_names,omitempty"`
ServerLabels []string `json:"server_labels,omitempty"`
Status []string `json:"status,omitempty"`
Expand Down Expand Up @@ -2315,6 +2328,7 @@ var dimensionColumns = map[RankingDimension]dimensionColumnDef{
RankingDimensionUserAgent: {IDCol: "user_agent", NameCol: "user_agent"},
}

// DimensionColumnDef returns the column pair for a supported ranking dimension.
func DimensionColumnDef(d RankingDimension) (idCol, nameCol string, ok bool) {
def, exists := dimensionColumns[d]
return def.IDCol, def.NameCol, exists
Expand Down
2 changes: 1 addition & 1 deletion plugins/governance/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1322,7 +1322,7 @@ func (p *GovernancePlugin) PreMCPHook(ctx *schemas.BifrostContext, req *schemas.
// disagree. What is left is a question about the tool, which the access answers whatever granted it.
// A request carrying no access is unrestricted and may execute any tool, as it always could.
access := ctx.Grant().Access()
if access != nil && !access.IsMCPToolAllowed(toolName) {
if access != nil && !access.IsMCPToolAllowed(toolName) && !hasMCPExecutionAuthorization(ctx, req) {
ctx.SetValue(governanceRejectedContextKey, true)
return req, &schemas.MCPPluginShortCircuit{Error: &schemas.BifrostError{
Type: bifrost.Ptr(string(DecisionMCPToolBlocked)),
Expand Down
21 changes: 21 additions & 0 deletions plugins/governance/mcpauthorization.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package governance

import "github.com/maximhq/bifrost/core/schemas"

const mcpAuthorizationContextKey schemas.BifrostContextKey = "bf-governance-mcp-authorization"

// mcpAuthorization binds trusted transport approval to one exact execution target.
type mcpAuthorization struct{ clientName, toolName string }

// SetMCPExecutionAuthorization records a transport-verified approval for one MCP call.
// Only trusted handlers may call this after checking their own authorization policy;
// it replaces the MCP tool permit check, never identity, headers or usage limits.
func SetMCPExecutionAuthorization(ctx *schemas.BifrostContext, clientName, toolName string) {
ctx.SetValue(mcpAuthorizationContextKey, mcpAuthorization{clientName, toolName})
}

// hasMCPExecutionAuthorization prevents approval from following a retargeted request.
func hasMCPExecutionAuthorization(ctx *schemas.BifrostContext, req *schemas.BifrostMCPRequest) bool {
approval, ok := ctx.Value(mcpAuthorizationContextKey).(mcpAuthorization)
return ok && approval.clientName != "" && approval.toolName != "" && req.ClientName == approval.clientName && req.GetToolName() == approval.toolName
}
53 changes: 53 additions & 0 deletions plugins/governance/mcpauthorization_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package governance

import (
"testing"

"github.com/maximhq/bifrost/core/schemas"
"github.com/stretchr/testify/require"
)

// TestMCPExecutionAuthorization preserves governance while replacing only the exact tool permit.
func TestMCPExecutionAuthorization(t *testing.T) {
p := newPluginForMCPStamping(t, buildVKForMCPStamping(nil), false)
for _, tc := range []struct {
name, client, tool, key string
approved, allowed bool
}{
{"gateway", "local", "local-read", mcpTestVKValue, false, false},
{"approved", "local", "local-read", mcpTestVKValue, true, true},
{"other tool", "local", "local-write", mcpTestVKValue, true, false},
{"other client", "other", "local-read", mcpTestVKValue, true, false},
{"invalid identity", "local", "local-read", "invalid", true, false},
} {
t.Run(tc.name, func(t *testing.T) {
ctx := presentCtx(tc.key)
if tc.approved {
SetMCPExecutionAuthorization(ctx, "local", "local-read")
}
req := &schemas.BifrostMCPRequest{RequestType: schemas.MCPRequestTypeChatToolCall, ClientName: tc.client, ChatAssistantMessageToolCall: &schemas.ChatAssistantMessageToolCall{Function: schemas.ChatAssistantMessageToolCallFunction{Name: &tc.tool, Arguments: "{}"}}}
_, short, err := p.PreMCPHook(ctx, req)
require.NoError(t, err)
if tc.allowed {
require.Nil(t, short)
} else {
require.NotNil(t, short)
require.NotNil(t, short.Error)
}
})
}
}

// TestMCPExecutionAuthorizationRequiresHeaders keeps transport requirements ahead of tool approval.
func TestMCPExecutionAuthorizationRequiresHeaders(t *testing.T) {
p := newPluginForMCPStamping(t, buildVKForMCPStamping(nil), false)
p.requiredHeaders = &[]string{"x-required"}
ctx := presentCtx(mcpTestVKValue)
SetMCPExecutionAuthorization(ctx, "local", "local-read")
name := "local-read"
req := &schemas.BifrostMCPRequest{RequestType: schemas.MCPRequestTypeChatToolCall, ClientName: "local", ChatAssistantMessageToolCall: &schemas.ChatAssistantMessageToolCall{Function: schemas.ChatAssistantMessageToolCallFunction{Name: &name, Arguments: "{}"}}}
_, short, err := p.PreMCPHook(ctx, req)
require.NoError(t, err)
require.NotNil(t, short)
require.Equal(t, "missing_required_headers", *short.Error.Type)
}
8 changes: 8 additions & 0 deletions plugins/logging/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,14 @@ func (p *LoggerPlugin) EnqueueLogEntry(entry *logstore.Log) {
p.enqueueLogEntry(entry, p.makePostWriteCallback(nil))
}

// EnqueueMCPToolLogEntry pushes a completed MCP log through the normal async write queue.
func (p *LoggerPlugin) EnqueueMCPToolLogEntry(entry *logstore.MCPToolLog) {
p.mu.Lock()
callback := p.mcpToolLogCallback
p.mu.Unlock()
p.enqueueMCPToolLogEntry(entry, callback)
}

// enqueueMCPToolLogEntry pushes a complete MCP tool log entry to the write queue.
// If the queue is full, the entry is dropped to prevent store slowness from
// cascading into request handling goroutines.
Expand Down
25 changes: 25 additions & 0 deletions transports/bifrost-http/handlers/logging.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,16 +301,19 @@ func (c *filterDataCache) load(key string) (*filterDataCacheEntry, map[string]in
return entry, nil, false
}

// store publishes a completed filter-data cache result.
func (c *filterDataCache) store(entry *filterDataCacheEntry, payload map[string]interface{}) {
entry.payload = payload
entry.expiresAt = time.Now().Add(filterDataCacheTTL)
entry.mu.Unlock()
}

// release releases a filter-data cache entry after a fetch attempt.
func (c *filterDataCache) release(entry *filterDataCacheEntry) {
entry.mu.Unlock()
}

// parseParentRequestIDFilter reads the parent request ID filter from the request.
func parseParentRequestIDFilter(ctx *fasthttp.RequestCtx) string {
if parentRequestID := string(ctx.QueryArgs().Peek("parent_request_id")); strings.TrimSpace(parentRequestID) != "" {
return parentRequestID
Expand Down Expand Up @@ -359,6 +362,7 @@ func (h *LoggingHandler) SetMCPLogRedactionMappingResolver(resolver MCPLogRedact
h.mcpLogRedactionMappingResolver = resolver
}

// shouldHideDeletedVirtualKeysInFilters reads the configured deleted-key visibility policy.
func (h *LoggingHandler) shouldHideDeletedVirtualKeysInFilters() bool {
if h == nil || h.config == nil {
return false
Expand Down Expand Up @@ -413,6 +417,7 @@ func (h *LoggingHandler) RegisterRoutes(r *router.Router, middlewares ...schemas
r.DELETE("/api/mcp-logs", lib.ChainMiddlewares(h.deleteMCPLogs, middlewares...))
}

// listUserAgentMappings returns configured client identification mappings.
func (h *LoggingHandler) listUserAgentMappings(ctx *fasthttp.RequestCtx) {
mappings, err := h.logManager.ListUserAgentMappings(ctx)
if err != nil {
Expand All @@ -422,6 +427,7 @@ func (h *LoggingHandler) listUserAgentMappings(ctx *fasthttp.RequestCtx) {
SendJSON(ctx, map[string]any{"mappings": mappings})
}

// createUserAgentMapping validates and creates a client identification mapping.
func (h *LoggingHandler) createUserAgentMapping(ctx *fasthttp.RequestCtx) {
var mapping logstore.UserAgentMapping
if err := sonic.Unmarshal(ctx.PostBody(), &mapping); err != nil {
Expand All @@ -440,6 +446,7 @@ func (h *LoggingHandler) createUserAgentMapping(ctx *fasthttp.RequestCtx) {
SendJSON(ctx, created)
}

// updateUserAgentMapping updates an identified client mapping after request validation.
func (h *LoggingHandler) updateUserAgentMapping(ctx *fasthttp.RequestCtx) {
id, ok := ctx.UserValue("id").(string)
if !ok || strings.TrimSpace(id) == "" {
Expand Down Expand Up @@ -467,6 +474,7 @@ func (h *LoggingHandler) updateUserAgentMapping(ctx *fasthttp.RequestCtx) {
SendJSON(ctx, updated)
}

// deleteUserAgentMapping deletes an identified client mapping and reports missing records.
func (h *LoggingHandler) deleteUserAgentMapping(ctx *fasthttp.RequestCtx) {
id, ok := ctx.UserValue("id").(string)
if !ok || strings.TrimSpace(id) == "" {
Expand Down Expand Up @@ -1538,6 +1546,7 @@ func (h *LoggingHandler) getModelRankings(ctx *fasthttp.RequestCtx) {
SendJSON(ctx, result)
}

// getDimensionRankings validates and serves rankings for the requested attribution dimension.
func (h *LoggingHandler) getDimensionRankings(ctx *fasthttp.RequestCtx) {
dim := logstore.RankingDimension(string(ctx.QueryArgs().Peek("dimension")))
if dim == "" {
Expand Down Expand Up @@ -2420,6 +2429,7 @@ func recalcJobStatusFromRow(job *tables.TableSidekiqJob) recalcJobStatus {

// Helper functions

// findRedactedKey matches a redacted provider key or returns a deleted-key placeholder.
func findRedactedKey(redactedKeys []schemas.Key, id string, name string) *schemas.Key {
if len(redactedKeys) == 0 {
return &schemas.Key{
Expand Down Expand Up @@ -2450,6 +2460,7 @@ func findRedactedKey(redactedKeys []schemas.Key, id string, name string) *schema
}
}

// findRedactedVirtualKey matches a redacted virtual key or returns a deleted-key placeholder.
func findRedactedVirtualKey(redactedVirtualKeys []tables.TableVirtualKey, id string, name string) *tables.TableVirtualKey {
if len(redactedVirtualKeys) == 0 {
return &tables.TableVirtualKey{
Expand Down Expand Up @@ -2480,6 +2491,7 @@ func findRedactedVirtualKey(redactedVirtualKeys []tables.TableVirtualKey, id str
}
}

// findRedactedRoutingRule matches a redacted routing rule or returns a deleted-rule placeholder.
func findRedactedRoutingRule(redactedRoutingRules []tables.TableRoutingRule, id string, name string) *tables.TableRoutingRule {
if len(redactedRoutingRules) == 0 {
return &tables.TableRoutingRule{
Expand Down Expand Up @@ -2585,6 +2597,13 @@ type recalculateCostFilters struct {
// Returns an error if any required parsing fails (e.g., invalid time format, invalid number format).
func parseMCPFiltersAndPagination(ctx *fasthttp.RequestCtx) (*logstore.MCPToolLogSearchFilters, *logstore.PaginationOptions, error) {
filters := &logstore.MCPToolLogSearchFilters{}
filters.UserIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("user_ids")))
filters.TeamIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("team_ids")))
filters.CustomerIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("customer_ids")))
filters.BusinessUnitIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("business_unit_ids")))
filters.ProjectIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("project_ids")))
filters.DeviceIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("device_ids")))

pagination := &logstore.PaginationOptions{}

// Extract filters from query parameters
Expand Down Expand Up @@ -2712,6 +2731,12 @@ func parseMCPFiltersAndPagination(ctx *fasthttp.RequestCtx) (*logstore.MCPToolLo
// Returns an error if any required parsing fails.
func parseMCPFilters(ctx *fasthttp.RequestCtx) (*logstore.MCPToolLogSearchFilters, error) {
filters := &logstore.MCPToolLogSearchFilters{}
filters.UserIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("user_ids")))
filters.TeamIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("team_ids")))
filters.CustomerIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("customer_ids")))
filters.BusinessUnitIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("business_unit_ids")))
filters.ProjectIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("project_ids")))
filters.DeviceIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("device_ids")))

// Extract filters from query parameters
if toolNames := string(ctx.QueryArgs().Peek("tool_names")); toolNames != "" {
Expand Down
Loading
Loading