Skip to content
Open
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
55 changes: 30 additions & 25 deletions core/mcp/toolmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,39 +240,45 @@ func buildIntegrationDuplicateCheckMap(existingTools []schemas.ChatTool, integra
}
}

// Add integration-specific patterns from existing tools
// Add integration-specific patterns from existing tools.
// When a supported CLI sends a request it already includes its MCP tools
// prefixed with the server name (e.g. "mcp__filesystem__read_file" or
// "bifrost__read_file"). Without this mapping Bifrost would add the
// un-prefixed version too, so the model would see duplicate tools.
//
// All supported integrations use a double-underscore separator:
// claude-cli : mcp__{server}__{tool} e.g. mcp__bifrost__read_file
// gemini-cli : mcp__{server}__{tool}
// qwen-cli : mcp__{server}__{tool}
// cursor : mcp__{server}__{tool}
// codex : mcp__{server}__{tool}
// n8n : mcp__{server}__{tool}
switch integrationUserAgent {
case "claude-cli":
// Claude CLI uses pattern: mcp__{foreign_name}__{tool_name}
// The middle part is a foreign name we cannot check for, so we extract the last part
// Examples:
// mcp__bifrost__executeToolCode -> executeToolCode
// mcp__bifrost__listToolFiles -> listToolFiles
// mcp__bifrost__readToolFile -> readToolFile
// mcp__calculator__calculator_add -> calculator_add
case "claude-cli", "gemini-cli", "qwen-cli", "cursor", "codex", "n8n":
for _, tool := range existingTools {
if tool.Function != nil && tool.Function.Name != "" {
existingToolName := tool.Function.Name
// Check if existing tool matches Claude CLI pattern: mcp__*__{tool_name}

// If the tool name starts with "mcp__", it's from a CLI client
// e.g., "mcp__filesystem_stdio__read_file" needs to map to "filesystem_stdio-read_file"
// because Bifrost internally creates tools using the "{clientName}-{toolName}" pattern
if strings.HasPrefix(existingToolName, "mcp__") {
// Split on __ and take the last entry (the tool_name)
parts := strings.Split(existingToolName, "__")
if len(parts) >= 3 {
toolName := parts[len(parts)-1] // Last part is the tool name
// Map Claude CLI pattern back to our tool name format
// This handles both regular MCP tools and code mode tools
if toolName != "" {
duplicateCheckMap[toolName] = true
// Also keep the original pattern for direct matching
duplicateCheckMap[existingToolName] = true
}
cleanName := strings.TrimPrefix(existingToolName, "mcp__")
cleanName = strings.ReplaceAll(cleanName, "__", "-")

// Mark the reconstructed clean name (e.g., "filesystem_stdio-read_file")
duplicateCheckMap[cleanName] = true
// Also mark the original prefixed name for direct matching
duplicateCheckMap[existingToolName] = true
} else {
// Fallback for tools without standard MCP prefix
if existingToolName != "" {
duplicateCheckMap[existingToolName] = true
}
}
}
}
// Add more integration-specific patterns here as needed
// case "another-integration":
// // Add patterns for other integrations
default:
}

return duplicateCheckMap
Expand Down Expand Up @@ -321,7 +327,6 @@ func (m *ToolsManager) ParseAndAddToolsToRequest(ctx context.Context, req *schem
// Build integration-aware duplicate check map
duplicateCheckMap := buildIntegrationDuplicateCheckMap(tools, integrationUserAgentStr)

// Add MCP tools that are not already present
for _, mcpTool := range availableTools {
// Skip tools with nil Function or empty Name
if mcpTool.Function == nil || mcpTool.Function.Name == "" {
Expand Down
12 changes: 12 additions & 0 deletions transports/bifrost-http/handlers/inference.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
bifrost "github.com/maximhq/bifrost/core"

"github.com/maximhq/bifrost/core/schemas"
"github.com/maximhq/bifrost/transports/bifrost-http/integrations"
"github.com/maximhq/bifrost/transports/bifrost-http/lib"
"github.com/valyala/fasthttp"
)
Expand Down Expand Up @@ -792,6 +793,10 @@ func (h *CompletionHandler) textCompletion(ctx *fasthttp.RequestCtx) {
SendError(ctx, fasthttp.StatusBadRequest, "Failed to convert context")
return
}

// Detect CLI user agent for MCP tool deduplication
integrations.DetectCLIUserAgent(ctx, bifrostCtx)

if req.Stream != nil && *req.Stream {
h.handleStreamingTextCompletion(ctx, bifrostTextReq, bifrostCtx, cancel)
return
Expand Down Expand Up @@ -895,6 +900,10 @@ func (h *CompletionHandler) chatCompletion(ctx *fasthttp.RequestCtx) {
SendError(ctx, fasthttp.StatusBadRequest, "Failed to convert context")
return
}

// Detect CLI user agent for MCP tool deduplication
integrations.DetectCLIUserAgent(ctx, bifrostCtx)

if req.Stream != nil && *req.Stream {
h.handleStreamingChatCompletion(ctx, bifrostChatReq, bifrostCtx, cancel)
return
Expand Down Expand Up @@ -983,6 +992,9 @@ func (h *CompletionHandler) responses(ctx *fasthttp.RequestCtx) {
return
}

// Detect CLI user agent for MCP tool deduplication
integrations.DetectCLIUserAgent(ctx, bifrostCtx)

if req.Stream != nil && *req.Stream {
h.handleStreamingResponses(ctx, bifrostResponsesReq, bifrostCtx, cancel)
return
Expand Down
19 changes: 3 additions & 16 deletions transports/bifrost-http/integrations/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,22 +298,9 @@ func checkAnthropicPassthrough(ctx *fasthttp.RequestCtx, bifrostCtx *schemas.Bif
}

headers := extractHeadersFromRequest(ctx)
if len(headers) > 0 {
// Check for User-Agent header (case-insensitive)
var userAgent []string
for key, value := range headers {
if strings.EqualFold(key, "user-agent") {
userAgent = value
break
}
}
if len(userAgent) > 0 {
// Check if it's claude code
if strings.Contains(userAgent[0], "claude-cli") {
bifrostCtx.SetValue(schemas.BifrostContextKeyUserAgent, "claude-cli")
}
}
}

// Detect CLI user agent (claude-cli, gemini-cli, qwen-cli, cursor, codex, n8n)
DetectCLIUserAgent(ctx, bifrostCtx)

// Check if anthropic oauth headers are present
if shouldUsePassthrough(bifrostCtx, provider, model, "") {
Expand Down
3 changes: 3 additions & 0 deletions transports/bifrost-http/integrations/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,9 @@ func (g *GenericRouter) createHandler(config RouteConfig) fasthttp.RequestHandle
// Set integration type to context
bifrostCtx.SetValue(schemas.BifrostContextKeyIntegrationType, string(config.Type))

// Detect CLI user agent (claude-cli, gemini-cli, qwen-cli, cursor, codex, n8n)
DetectCLIUserAgent(ctx, bifrostCtx)

// Set available providers to context
availableProviders := g.handlerStore.GetAvailableProviders()
bifrostCtx.SetValue(schemas.BifrostContextKeyAvailableProviders, availableProviders)
Expand Down
76 changes: 76 additions & 0 deletions transports/bifrost-http/integrations/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,82 @@ func (g *GenericRouter) extractFallbacksFromRequest(req interface{}) ([]string,
return nil, nil
}

// DetectCLIUserAgent detects if the request is coming from a known CLI tool or integration.
// It checks the User-Agent header and sets the BifrostContextKeyUserAgent key in the BifrostContext.
// Returns the detected user agent string if found, otherwise empty string.
//
// Supported integrations:
// - claude-cli (User-Agent contains "claude-cli")
// - gemini-cli (User-Agent contains "gemini-cli")
// - qwen-cli (User-Agent contains "qwen-cli")
// - cursor (User-Agent token is exactly "cursor" or "cursor/<version>")
// - codex (User-Agent token is exactly "codex" or "codex/<version>")
// - n8n (User-Agent token is exactly "n8n" or "n8n/<version>")
//
// For "cursor", "codex" and "n8n" we require a word-boundary match to avoid
// false positives from User-Agents that merely contain those strings as substrings
// (e.g. "precursor/2.0", "vs-codex-plugin/1.0"). A token matches when it appears
// at the very beginning of the header value, or is immediately preceded by a
// non-alphanumeric character (space, '/', '-', etc.).
func DetectCLIUserAgent(ctx *fasthttp.RequestCtx, bifrostCtx *schemas.BifrostContext) string {
userAgent := string(ctx.Request.Header.Peek("User-Agent"))
userAgentLower := strings.ToLower(userAgent)
var detectedAgent string

switch {
case strings.Contains(userAgentLower, "claude-cli"):
detectedAgent = "claude-cli"
case strings.Contains(userAgentLower, "gemini-cli"):
detectedAgent = "gemini-cli"
case strings.Contains(userAgentLower, "qwen-cli"):
detectedAgent = "qwen-cli"
case isCLIToken(userAgentLower, "cursor"):
detectedAgent = "cursor"
case isCLIToken(userAgentLower, "codex"):
detectedAgent = "codex"
case isCLIToken(userAgentLower, "n8n"):
detectedAgent = "n8n"
}

if detectedAgent != "" {
bifrostCtx.SetValue(schemas.BifrostContextKeyUserAgent, detectedAgent)
}

return detectedAgent
}

// isCLIToken returns true when token appears in ua as a proper product token.
// Matching rules (all must hold):
// - The token must be preceded by start-of-string or a whitespace/slash
// (i.e. it must be a new product token, not a suffix of another word).
// - The token must be followed by end-of-string, '/', ' ', or '@'
// (i.e. it is not a prefix of a longer word like "cursor-editor").
//
// Examples that MATCH : "cursor/1.0", "codex/0.1", "n8n/2.0", "my-app cursor/1.0"
// Examples that DON'T : "precursor/2.0", "my-cursor-editor/1.0", "vs-codex-plugin/1.0", "plugin-n8n/1.0"
func isCLIToken(ua, token string) bool {
idx := strings.Index(ua, token)
if idx < 0 {
return false
}
// Check left boundary: must be start or preceded by space or '/'
if idx > 0 {
prev := ua[idx-1]
if prev != ' ' && prev != '/' {
return false
}
}
// Check right boundary: must be end, or followed by '/', ' ', or '@'
end := idx + len(token)
if end < len(ua) {
next := ua[end]
if next != '/' && next != ' ' && next != '@' {
return false
}
}
return true
}
Comment on lines +383 to +404
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

fd -t f "utils.go" transports/bifrost-http/integrations/

Repository: maximhq/bifrost

Length of output: 104


🏁 Script executed:

if [ -f "transports/bifrost-http/integrations/utils.go" ]; then
  sed -n '383,404p' transports/bifrost-http/integrations/utils.go
fi

Repository: maximhq/bifrost

Length of output: 543


🏁 Script executed:

python3 - <<'PY'
def is_cli_token_current(ua, token):
    idx = ua.find(token)
    if idx < 0:
        return False
    if idx > 0:
        prev = ua[idx - 1]
        if prev not in (' ', '/'):
            return False
    end = idx + len(token)
    if end < len(ua):
        nxt = ua[end]
        if nxt not in ('/', ' ', '@'):
            return False
    return True

tests = [
    ("precursor/2.0 cursor/1.0", "cursor", True),
    ("vs-codex-plugin/1.0 codex/0.1", "codex", True),
    ("plugin-n8n/1.0 n8n/2.0", "n8n", True),
    ("cursor/1.0", "cursor", True),
    ("curl/7.0", "curl", False),
]

print("Current implementation results:")
for ua, token, expected in tests:
    actual = is_cli_token_current(ua, token)
    status = "✓" if actual == expected else "✗ MISMATCH"
    print(f"{status} ua={ua!r}, token={token!r}, actual={actual}, expected={expected}")
PY

Repository: maximhq/bifrost

Length of output: 477


🏁 Script executed:

# Check the DetectCLIUserAgent function that uses isCLIToken
sed -n '330,382p' transports/bifrost-http/integrations/utils.go

Repository: maximhq/bifrost

Length of output: 2561


🏁 Script executed:

# Verify the suggested fix logic works
python3 - <<'PY'
def is_cli_token_fixed(ua, token):
    """Fixed version from suggested diff"""
    start = 0
    while start < len(ua):
        idx = ua.find(token, start)
        if idx < 0:
            return False
        
        left_ok = (idx == 0 or ua[idx - 1] in (' ', '/'))
        end = idx + len(token)
        right_ok = (end == len(ua) or ua[end] in ('/', ' ', '@'))
        
        if left_ok and right_ok:
            return True
        
        start = idx + 1
    return False

tests = [
    ("precursor/2.0 cursor/1.0", "cursor", True),
    ("vs-codex-plugin/1.0 codex/0.1", "codex", True),
    ("plugin-n8n/1.0 n8n/2.0", "n8n", True),
    ("cursor/1.0", "cursor", True),
    ("curl/7.0", "curl", True),  # Corrected expectation
    ("precursor/2.0", "cursor", False),
    ("curl/7.0", "curl", True),
]

print("Fixed implementation results:")
for ua, token, expected in tests:
    actual = is_cli_token_fixed(ua, token)
    status = "✓" if actual == expected else "✗"
    print(f"{status} ua={ua!r}, token={token!r}, actual={actual}, expected={expected}")
PY

Repository: maximhq/bifrost

Length of output: 560


🏁 Script executed:

# Check the usage context - are there real UA patterns that would trigger this?
grep -A 5 -B 5 "isCLIToken" transports/bifrost-http/integrations/utils.go | head -30

Repository: maximhq/bifrost

Length of output: 1237


isCLIToken fails to find valid tokens when they appear after an invalid substring match.

The function uses strings.Index() which finds only the first occurrence. When a token appears as part of a longer product name before appearing standalone (e.g., precursor/2.0 cursor/1.0), the function matches the invalid cursor substring inside precursor, fails the boundary check, and returns false without checking the valid cursor/1.0 token that follows.

This contradicts the documented behavior, which explicitly lists cases like precursor/2.0 and vs-codex-plugin/1.0 as non-matches, implying the function should correctly identify valid tokens elsewhere in the string. The fix loops through all occurrences until a match with valid boundaries is found:

Suggested fix
 func isCLIToken(ua, token string) bool {
-	idx := strings.Index(ua, token)
-	if idx < 0 {
-		return false
-	}
-	// Check left boundary: must be start or preceded by space or '/'
-	if idx > 0 {
-		prev := ua[idx-1]
-		if prev != ' ' && prev != '/' {
-			return false
-		}
-	}
-	// Check right boundary: must be end, or followed by '/', ' ', or '@'
-	end := idx + len(token)
-	if end < len(ua) {
-		next := ua[end]
-		if next != '/' && next != ' ' && next != '@' {
-			return false
-		}
-	}
-	return true
+	for start := 0; start < len(ua); {
+		idx := strings.Index(ua[start:], token)
+		if idx < 0 {
+			return false
+		}
+		idx += start
+
+		leftOK := idx == 0 || ua[idx-1] == ' ' || ua[idx-1] == '/'
+		end := idx + len(token)
+		rightOK := end == len(ua) || ua[end] == '/' || ua[end] == ' ' || ua[end] == '@'
+
+		if leftOK && rightOK {
+			return true
+		}
+
+		start = idx + 1
+	}
+	return false
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func isCLIToken(ua, token string) bool {
idx := strings.Index(ua, token)
if idx < 0 {
return false
}
// Check left boundary: must be start or preceded by space or '/'
if idx > 0 {
prev := ua[idx-1]
if prev != ' ' && prev != '/' {
return false
}
}
// Check right boundary: must be end, or followed by '/', ' ', or '@'
end := idx + len(token)
if end < len(ua) {
next := ua[end]
if next != '/' && next != ' ' && next != '@' {
return false
}
}
return true
}
func isCLIToken(ua, token string) bool {
for start := 0; start < len(ua); {
idx := strings.Index(ua[start:], token)
if idx < 0 {
return false
}
idx += start
leftOK := idx == 0 || ua[idx-1] == ' ' || ua[idx-1] == '/'
end := idx + len(token)
rightOK := end == len(ua) || ua[end] == '/' || ua[end] == ' ' || ua[end] == '@'
if leftOK && rightOK {
return true
}
start = idx + 1
}
return false
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@transports/bifrost-http/integrations/utils.go` around lines 383 - 404,
isCLIToken currently uses strings.Index once and returns false on the first
boundary-fail, which misses later valid occurrences; change isCLIToken to loop
over every occurrence of token in ua (repeatedly call strings.Index starting
from the byte after the previous match) and for each found idx perform the same
left-boundary (check ua[idx-1]) and right-boundary (compute end :=
idx+len(token) and check ua[end]) checks; return true on the first occurrence
that passes the boundary checks and return false only after no more occurrences
remain. Ensure you keep the existing boundary rules and use the same variables
(ua, token, idx, end) so behavior stays identical for the first valid match.


// getVirtualKeyFromBifrostContext extracts the virtual key value from bifrost context.
// Returns nil if no VK is present (e.g., direct key mode or no governance).
func getVirtualKeyFromBifrostContext(ctx *schemas.BifrostContext) *string {
Expand Down