package mcp import ( "context" "testing" "github.com/maximhq/bifrost/core/schemas" ) // ============================================================================= // HELPERS // ============================================================================= // mockToolClientManager is a ClientManager that returns a pre-defined set of MCP tools. // It is used to drive ParseAndAddToolsToRequest without a real MCP server. type mockToolClientManager struct { tools []schemas.ChatTool } func (m *mockToolClientManager) GetClientByName(clientName string) *schemas.MCPClientState { if clientName == "test-client" { return &schemas.MCPClientState{ Name: "test-client", ExecutionConfig: &schemas.MCPClientConfig{ ID: "test-client", Name: "test-client", IsCodeModeClient: false, ToolsToExecute: []string{"*"}, }, } } return nil } func (m *mockToolClientManager) GetClientForTool(toolName string) *schemas.MCPClientState { return nil } func (m *mockToolClientManager) GetToolPerClient(ctx context.Context) map[string][]schemas.ChatTool { return map[string][]schemas.ChatTool{ "test-client": m.tools, } } // makeTool is a convenience constructor for test tool fixtures. func makeTool(name string) schemas.ChatTool { return schemas.ChatTool{ Type: schemas.ChatToolTypeFunction, Function: &schemas.ChatToolFunction{ Name: name, }, } } // toolNamesFromChatRequest collects the names of every tool in the request's // ChatRequest.Params.Tools slice. func toolNamesFromChatRequest(req *schemas.BifrostRequest) []string { if req.ChatRequest == nil || req.ChatRequest.Params == nil { return nil } names := make([]string, 0, len(req.ChatRequest.Params.Tools)) for _, t := range req.ChatRequest.Params.Tools { if t.Function != nil { names = append(names, t.Function.Name) } } return names } // toolNamesFromResponsesRequest collects the names of every tool in the request's // ResponsesRequest.Params.Tools slice (ResponsesParameters uses *string Name). func toolNamesFromResponsesRequest(req *schemas.BifrostRequest) []string { if req.ResponsesRequest == nil || req.ResponsesRequest.Params == nil { return nil } names := make([]string, 0, len(req.ResponsesRequest.Params.Tools)) for _, t := range req.ResponsesRequest.Params.Tools { if t.Name != nil { names = append(names, *t.Name) } } return names } // countOccurrences returns how many times name appears in slice. func countOccurrences(slice []string, name string) int { n := 0 for _, s := range slice { if s == name { n++ } } return n } // newToolsManagerForTest creates a minimal ToolsManager backed by the provided // ClientManager, with no code mode, no plugin pipeline, and a no-op logger. func newToolsManagerForTest(cm ClientManager) *ToolsManager { return NewToolsManager( &schemas.MCPToolManagerConfig{ MaxAgentDepth: 5, }, cm, nil, // fetchNewRequestIDFunc nil, // pluginPipelineProvider nil, // releasePluginPipeline nil, // oauth2Provider &MockLogger{}, ) } // contextWithUserAgent creates a BifrostContext with BifrostContextKeyUserAgent set. func contextWithUserAgent(ua string) *schemas.BifrostContext { ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) ctx.SetValue(schemas.BifrostContextKeyUserAgent, ua) return ctx } // ============================================================================= // buildIntegrationDuplicateCheckMap – unit tests // ============================================================================= func TestBuildIntegrationDuplicateCheckMap_EmptyInputs(t *testing.T) { t.Parallel() m := buildIntegrationDuplicateCheckMap(nil, "", defaultLogger) if len(m) != 0 { t.Errorf("expected empty map for nil tools and no agent, got %d entries", len(m)) } } func TestBuildIntegrationDuplicateCheckMap_NoUserAgent_DirectMatch(t *testing.T) { t.Parallel() tools := []schemas.ChatTool{ makeTool("echo"), makeTool("calculator"), } m := buildIntegrationDuplicateCheckMap(tools, "", defaultLogger) for _, want := range []string{"echo", "calculator"} { if !m[want] { t.Errorf("expected %q to be in map", want) } } if len(m) != 2 { t.Errorf("expected exactly 2 entries, got %d", len(m)) } } func TestBuildIntegrationDuplicateCheckMap_NilFunction_IsSkipped(t *testing.T) { t.Parallel() tools := []schemas.ChatTool{ {Type: schemas.ChatToolTypeFunction, Function: nil}, // nil Function makeTool(""), // empty name makeTool("valid_tool"), // valid } m := buildIntegrationDuplicateCheckMap(tools, "", defaultLogger) if !m["valid_tool"] { t.Error("expected valid_tool to be in map") } // "" is technically inserted because the loop guard is `Name != ""` — nothing else if m[""] { t.Error("empty tool name should not be inserted") } // nil Function is skipped entirely; no panic } func TestBuildIntegrationDuplicateCheckMap_UnknownAgent_FallsBackToDirectMatch(t *testing.T) { t.Parallel() tools := []schemas.ChatTool{ makeTool("search"), makeTool("weather"), } m := buildIntegrationDuplicateCheckMap(tools, "unknown-agent-xyz", defaultLogger) for _, want := range []string{"search", "weather"} { if !m[want] { t.Errorf("expected %q in map for unknown agent", want) } } if len(m) != 2 { t.Errorf("expected 2 entries for unknown agent, got %d", len(m)) } } // --------------------------------------------------------------------------- // ClaudeCLI — pattern: mcp__{server}__{tool_name} // --------------------------------------------------------------------------- func TestBuildIntegrationDuplicateCheckMap_ClaudeCLI_StripsPrefix(t *testing.T) { t.Parallel() // These are the tool names Claude CLI sends when it has already connected to a // Bifrost MCP server. The prefix is mcp__{server}__{tool_name}. tools := []schemas.ChatTool{ makeTool("mcp__bifrost__echo"), makeTool("mcp__bifrost__calculator"), } m := buildIntegrationDuplicateCheckMap(tools, schemas.ClaudeCLI.String(), defaultLogger) // Bare tool names must be recognised so Bifrost does not inject them again. for _, want := range []string{"echo", "calculator"} { if !m[want] { t.Errorf("ClaudeCLI: expected bare name %q to be in duplicate map", want) } } // Original prefixed names must also be present. for _, want := range []string{"mcp__bifrost__echo", "mcp__bifrost__calculator"} { if !m[want] { t.Errorf("ClaudeCLI: expected prefixed name %q to be in duplicate map", want) } } } func TestBuildIntegrationDuplicateCheckMap_ClaudeCLI_MultipleServers(t *testing.T) { t.Parallel() tools := []schemas.ChatTool{ makeTool("mcp__bifrost__executeToolCode"), makeTool("mcp__bifrost__listToolFiles"), makeTool("mcp__calculator__calculator_add"), } m := buildIntegrationDuplicateCheckMap(tools, schemas.ClaudeCLI.String(), defaultLogger) // Last segment is always the bare tool name. for _, want := range []string{"executeToolCode", "listToolFiles", "calculator_add"} { if !m[want] { t.Errorf("ClaudeCLI multi-server: expected bare name %q in map", want) } } } func TestBuildIntegrationDuplicateCheckMap_ClaudeCLI_TwoPartName_NotExtracted(t *testing.T) { t.Parallel() // A two-segment name like "mcp__only_two" doesn't satisfy the len(parts) >= 3 guard, // so only the raw name ends up in the map — the "only_two" portion is NOT extracted. tools := []schemas.ChatTool{ makeTool("mcp__only_two"), } m := buildIntegrationDuplicateCheckMap(tools, schemas.ClaudeCLI.String(), defaultLogger) if !m["mcp__only_two"] { t.Error("ClaudeCLI: original two-part name should still appear as direct match") } if m["only_two"] { t.Error("ClaudeCLI: bare segment from two-part name should NOT be extracted") } } func TestBuildIntegrationDuplicateCheckMap_ClaudeCLI_ToolWithUnderscores(t *testing.T) { t.Parallel() // Tool names that themselves contain underscores must not be split — only the // double-underscore __ delimiter is meaningful. tools := []schemas.ChatTool{ makeTool("mcp__my_server__get_weather_data"), } m := buildIntegrationDuplicateCheckMap(tools, schemas.ClaudeCLI.String(), defaultLogger) // The entire last segment, including its internal underscores, is the tool name. if !m["get_weather_data"] { t.Error("ClaudeCLI: tool name with underscores should be extracted as-is") } if !m["mcp__my_server__get_weather_data"] { t.Error("ClaudeCLI: original prefixed name should be retained") } // Partial segments must not appear. for _, unexpected := range []string{"get", "weather", "data"} { if m[unexpected] { t.Errorf("ClaudeCLI: unexpected partial segment %q in map", unexpected) } } } func TestBuildIntegrationDuplicateCheckMap_ClaudeCLI_MixedPrefixedAndBare(t *testing.T) { t.Parallel() // A request might contain some already-prefixed tools from one MCP server and some // bare tools injected directly (e.g., by a plugin). Both should be in the map. tools := []schemas.ChatTool{ makeTool("mcp__bifrost__echo"), makeTool("search"), // already bare } m := buildIntegrationDuplicateCheckMap(tools, schemas.ClaudeCLI.String(), defaultLogger) for _, want := range []string{"echo", "mcp__bifrost__echo", "search"} { if !m[want] { t.Errorf("ClaudeCLI mixed: expected %q in map", want) } } } // --------------------------------------------------------------------------- // GeminiCLI — pattern: mcp_{server}_{tool_name} (single underscore) // The current implementation treats GeminiCLI tools as direct matches only. // These tests document the current (direct-match) behaviour and also define the // expected behaviour once prefix-stripping for the mcp_{server}_{tool} pattern // is added in a follow-up. // --------------------------------------------------------------------------- func TestBuildIntegrationDuplicateCheckMap_GeminiCLI_DirectMatch(t *testing.T) { t.Parallel() tools := []schemas.ChatTool{ makeTool("echo"), makeTool("calculator"), } m := buildIntegrationDuplicateCheckMap(tools, schemas.GeminiCLI.String(), defaultLogger) for _, want := range []string{"echo", "calculator"} { if !m[want] { t.Errorf("GeminiCLI direct match: expected %q in map", want) } } } // TestBuildIntegrationDuplicateCheckMap_GeminiCLI_PrefixedTools_StripsPrefix verifies that // the GeminiCLI case correctly extracts the tool name from the mcp_{server}_{tool} pattern // by stripping "mcp_" and skipping past the first "_" (server name boundary). func TestBuildIntegrationDuplicateCheckMap_GeminiCLI_PrefixedTools_StripsPrefix(t *testing.T) { t.Parallel() tools := []schemas.ChatTool{ makeTool("mcp_bifrost_echo"), makeTool("mcp_bifrost_calculator"), } m := buildIntegrationDuplicateCheckMap(tools, schemas.GeminiCLI.String(), defaultLogger) for _, want := range []string{"echo", "calculator"} { if !m[want] { t.Errorf("GeminiCLI: expected bare name %q after prefix stripping", want) } } } // TestBuildIntegrationDuplicateCheckMap_GeminiCLI_ClientPrefixedTools verifies the // dual-gateway scenario: Bifrost stores tools as "{client}-{tool}" and Gemini CLI // wraps them as "mcp_{server}_{client}-{tool}". The dedup must extract "{client}-{tool}". func TestBuildIntegrationDuplicateCheckMap_GeminiCLI_ClientPrefixedTools(t *testing.T) { t.Parallel() tools := []schemas.ChatTool{ makeTool("mcp_bifrost_testing_exa-web_fetch_exa"), makeTool("mcp_bifrost_testing_exa-web_search_exa"), makeTool("mcp_bifrost_testing_websets-cancel_enrichment"), makeTool("mcp_bifrost_ctx7-resolve-library-id"), } m := buildIntegrationDuplicateCheckMap(tools, schemas.GeminiCLI.String(), defaultLogger) for _, want := range []string{ "testing_exa-web_fetch_exa", "testing_exa-web_search_exa", "testing_websets-cancel_enrichment", "ctx7-resolve-library-id", } { if !m[want] { t.Errorf("GeminiCLI client-prefixed: expected %q in duplicate map", want) } } } // --------------------------------------------------------------------------- // New integrations — Cursor, Codex CLI, n8n, Qwen CLI // // These agents are expected to use tool names without provider-specific prefixes // (i.e. direct matching). Once the corresponding constants are added to // core/schemas/useragents.go and the switch cases are wired in, these tests // verify that the deduplication map is correctly populated. // // String literals are used instead of schema constants because the constants do // not exist yet. Replace with schemas.CursorEditor.String() etc. once the // constants land. // --------------------------------------------------------------------------- func TestBuildIntegrationDuplicateCheckMap_CursorEditor_DirectMatch(t *testing.T) { t.Parallel() const cursorUA = "cursor" tools := []schemas.ChatTool{ makeTool("echo"), makeTool("read_file"), } m := buildIntegrationDuplicateCheckMap(tools, cursorUA, defaultLogger) for _, want := range []string{"echo", "read_file"} { if !m[want] { t.Errorf("Cursor: expected %q in duplicate map", want) } } if len(m) != 2 { t.Errorf("Cursor: expected 2 entries, got %d", len(m)) } } func TestBuildIntegrationDuplicateCheckMap_CodexCLI_DirectMatch(t *testing.T) { t.Parallel() const codexUA = "codex" tools := []schemas.ChatTool{ makeTool("bash"), makeTool("list_files"), } m := buildIntegrationDuplicateCheckMap(tools, codexUA, defaultLogger) for _, want := range []string{"bash", "list_files"} { if !m[want] { t.Errorf("Codex: expected %q in duplicate map", want) } } } func TestBuildIntegrationDuplicateCheckMap_N8N_DirectMatch(t *testing.T) { t.Parallel() const n8nUA = "n8n" tools := []schemas.ChatTool{ makeTool("http_request"), makeTool("send_email"), } m := buildIntegrationDuplicateCheckMap(tools, n8nUA, defaultLogger) for _, want := range []string{"http_request", "send_email"} { if !m[want] { t.Errorf("n8n: expected %q in duplicate map", want) } } } func TestBuildIntegrationDuplicateCheckMap_QwenCLI_DirectMatch(t *testing.T) { t.Parallel() const qwenUA = "qwen-code" tools := []schemas.ChatTool{ makeTool("code_interpreter"), makeTool("web_search"), } m := buildIntegrationDuplicateCheckMap(tools, qwenUA, defaultLogger) for _, want := range []string{"code_interpreter", "web_search"} { if !m[want] { t.Errorf("Qwen: expected %q in duplicate map", want) } } } // TestBuildIntegrationDuplicateCheckMap_CodexCLI_ClientPrefixedTools verifies the // dual-gateway scenario for Codex CLI: format is mcp__{server}__{tool_name} but ALL // hyphens in the original Bifrost tool name are converted to underscores. The dedup // map stores the all-underscore form; callers must normalize before lookup. func TestBuildIntegrationDuplicateCheckMap_CodexCLI_ClientPrefixedTools(t *testing.T) { t.Parallel() tools := []schemas.ChatTool{ makeTool("mcp__bifrost__testing_exa_web_fetch_exa"), makeTool("mcp__bifrost__testing_exa_web_search_exa"), makeTool("mcp__bifrost__testing_websets_cancel_enrichment"), makeTool("mcp__bifrost__ctx7_query_docs"), } m := buildIntegrationDuplicateCheckMap(tools, schemas.CodexCLI.String(), defaultLogger) // All-underscore forms must be in the map (callers normalize Bifrost names before lookup). for _, want := range []string{ "testing_exa_web_fetch_exa", "testing_exa_web_search_exa", "testing_websets_cancel_enrichment", "ctx7_query_docs", } { if !m[want] { t.Errorf("CodexCLI: expected %q in duplicate map", want) } } } // TestBuildIntegrationDuplicateCheckMap_QwenCLI_ClientPrefixedTools verifies the // dual-gateway scenario for Qwen CLI: format is mcp__{server}__{tool_name} (double // underscores). Strip "mcp__" then skip past first "__" to get the Bifrost tool name. func TestBuildIntegrationDuplicateCheckMap_QwenCLI_ClientPrefixedTools(t *testing.T) { t.Parallel() tools := []schemas.ChatTool{ makeTool("mcp__bifrost__testing_exa-web_fetch_exa"), makeTool("mcp__bifrost__testing_exa-web_search_exa"), makeTool("mcp__bifrost__testing_websets-cancel_enrichment"), makeTool("mcp__bifrost__ctx7-resolve-library-id"), } m := buildIntegrationDuplicateCheckMap(tools, schemas.QwenCodeCLI.String(), defaultLogger) for _, want := range []string{ "testing_exa-web_fetch_exa", "testing_exa-web_search_exa", "testing_websets-cancel_enrichment", "ctx7-resolve-library-id", } { if !m[want] { t.Errorf("QwenCLI client-prefixed: expected %q in duplicate map", want) } } } // ============================================================================= // ParseAndAddToolsToRequest – end-to-end deduplication tests // // These tests wire a ToolsManager backed by a mockToolClientManager and verify // that ParseAndAddToolsToRequest does not inject duplicate tools when the // request already carries tools that match MCP-registered ones. // ============================================================================= // buildChatRequest builds a minimal BifrostChatRequest with the given pre-existing // tool names already populated in Params.Tools. func buildChatRequest(existingToolNames ...string) *schemas.BifrostRequest { tools := make([]schemas.ChatTool, 0, len(existingToolNames)) for _, name := range existingToolNames { tools = append(tools, makeTool(name)) } return &schemas.BifrostRequest{ ChatRequest: &schemas.BifrostChatRequest{ Provider: schemas.OpenAI, Model: "gpt-4", Params: &schemas.ChatParameters{ Tools: tools, }, }, RequestType: schemas.ChatCompletionRequest, } } func TestParseAndAddToolsToRequest_NoUserAgent_NoDuplicate(t *testing.T) { t.Parallel() cm := &mockToolClientManager{ tools: []schemas.ChatTool{ makeTool("echo"), makeTool("calculator"), }, } tm := newToolsManagerForTest(cm) // Request already has "echo" — Bifrost should not add it again. req := buildChatRequest("echo") ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) result := tm.ParseAndAddToolsToRequest(ctx, req) names := toolNamesFromChatRequest(result) if countOccurrences(names, "echo") != 1 { t.Errorf("expected exactly 1 'echo', got %d (names: %v)", countOccurrences(names, "echo"), names) } // "calculator" is not in the existing tools so it should be added exactly once. if countOccurrences(names, "calculator") != 1 { t.Errorf("expected exactly 1 'calculator', got %d", countOccurrences(names, "calculator")) } } func TestParseAndAddToolsToRequest_NoUserAgent_HyphenUnderscoreDistinct(t *testing.T) { t.Parallel() cm := &mockToolClientManager{ tools: []schemas.ChatTool{ makeTool("foo-bar"), makeTool("foo_bar"), }, } tm := newToolsManagerForTest(cm) req := buildChatRequest() ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) result := tm.ParseAndAddToolsToRequest(ctx, req) names := toolNamesFromChatRequest(result) if countOccurrences(names, "foo-bar") != 1 || countOccurrences(names, "foo_bar") != 1 { t.Errorf("without Codex UA, foo-bar and foo_bar are distinct tools; got names %v", names) } } func TestParseAndAddToolsToRequest_ClaudeCLI_NoDuplicate(t *testing.T) { t.Parallel() // Available MCP tools registered on the Bifrost server. cm := &mockToolClientManager{ tools: []schemas.ChatTool{ makeTool("echo"), makeTool("calculator"), }, } tm := newToolsManagerForTest(cm) // Claude CLI already carries the tools under the mcp__{server}__{name} pattern. req := buildChatRequest("mcp__bifrost__echo", "mcp__bifrost__calculator") ctx := contextWithUserAgent(schemas.ClaudeCLI.String()) result := tm.ParseAndAddToolsToRequest(ctx, req) names := toolNamesFromChatRequest(result) // The bare names must NOT be injected as new entries — they were already present // under the prefixed names. if countOccurrences(names, "echo") != 0 { t.Errorf("ClaudeCLI: bare 'echo' should not be added again, got %d occurrences (names: %v)", countOccurrences(names, "echo"), names) } if countOccurrences(names, "calculator") != 0 { t.Errorf("ClaudeCLI: bare 'calculator' should not be added again, got %d occurrences", countOccurrences(names, "calculator")) } // Prefixed originals must still be present exactly once. for _, want := range []string{"mcp__bifrost__echo", "mcp__bifrost__calculator"} { if countOccurrences(names, want) != 1 { t.Errorf("ClaudeCLI: expected exactly 1 %q, got %d (names: %v)", want, countOccurrences(names, want), names) } } } func TestParseAndAddToolsToRequest_ClaudeCLI_NewToolsInjected(t *testing.T) { t.Parallel() // MCP server exposes echo and search. Claude CLI has only echo (prefixed). // Bifrost should inject search (not already present in any form). cm := &mockToolClientManager{ tools: []schemas.ChatTool{ makeTool("echo"), makeTool("search"), }, } tm := newToolsManagerForTest(cm) req := buildChatRequest("mcp__bifrost__echo") ctx := contextWithUserAgent(schemas.ClaudeCLI.String()) result := tm.ParseAndAddToolsToRequest(ctx, req) names := toolNamesFromChatRequest(result) // echo is covered by the prefixed entry — must not be injected again. if countOccurrences(names, "echo") != 0 { t.Errorf("ClaudeCLI new tool: bare 'echo' injected unexpectedly (names: %v)", names) } // search is new — must be injected exactly once. if countOccurrences(names, "search") != 1 { t.Errorf("ClaudeCLI new tool: 'search' should be injected once, got %d (names: %v)", countOccurrences(names, "search"), names) } } func TestParseAndAddToolsToRequest_GeminiCLI_NoDuplicate(t *testing.T) { t.Parallel() cm := &mockToolClientManager{ tools: []schemas.ChatTool{ makeTool("echo"), }, } tm := newToolsManagerForTest(cm) // Gemini CLI sends tools with their bare names (no prefix in current implementation). req := buildChatRequest("echo") ctx := contextWithUserAgent(schemas.GeminiCLI.String()) result := tm.ParseAndAddToolsToRequest(ctx, req) names := toolNamesFromChatRequest(result) if countOccurrences(names, "echo") != 1 { t.Errorf("GeminiCLI: expected exactly 1 'echo', got %d (names: %v)", countOccurrences(names, "echo"), names) } } func TestParseAndAddToolsToRequest_CursorEditor_NoDuplicate(t *testing.T) { t.Parallel() const cursorUA = "cursor" cm := &mockToolClientManager{ tools: []schemas.ChatTool{ makeTool("echo"), }, } tm := newToolsManagerForTest(cm) req := buildChatRequest("echo") ctx := contextWithUserAgent(cursorUA) result := tm.ParseAndAddToolsToRequest(ctx, req) names := toolNamesFromChatRequest(result) if countOccurrences(names, "echo") != 1 { t.Errorf("Cursor: expected exactly 1 'echo', got %d (names: %v)", countOccurrences(names, "echo"), names) } } func TestParseAndAddToolsToRequest_CodexCLI_NoDuplicate(t *testing.T) { t.Parallel() const codexUA = "codex" cm := &mockToolClientManager{ tools: []schemas.ChatTool{ makeTool("bash"), }, } tm := newToolsManagerForTest(cm) req := buildChatRequest("bash") ctx := contextWithUserAgent(codexUA) result := tm.ParseAndAddToolsToRequest(ctx, req) names := toolNamesFromChatRequest(result) if countOccurrences(names, "bash") != 1 { t.Errorf("Codex: expected exactly 1 'bash', got %d (names: %v)", countOccurrences(names, "bash"), names) } } func TestParseAndAddToolsToRequest_CodexCLI_MCPHyphenUnderscoreVariantsDeduped(t *testing.T) { t.Parallel() cm := &mockToolClientManager{ tools: []schemas.ChatTool{ makeTool("foo-bar"), makeTool("foo_bar"), }, } tm := newToolsManagerForTest(cm) req := buildChatRequest() ctx := contextWithUserAgent(schemas.CodexCLI.String()) result := tm.ParseAndAddToolsToRequest(ctx, req) names := toolNamesFromChatRequest(result) if len(names) != 1 { t.Fatalf("Codex: hyphen/underscore variants should yield one injected tool, got %d: %v", len(names), names) } if countOccurrences(names, "foo-bar")+countOccurrences(names, "foo_bar") != 1 { t.Errorf("Codex: expected exactly one of foo-bar or foo_bar, got %v", names) } } func TestParseAndAddToolsToRequest_N8N_NoDuplicate(t *testing.T) { t.Parallel() const n8nUA = "n8n" cm := &mockToolClientManager{ tools: []schemas.ChatTool{ makeTool("http_request"), }, } tm := newToolsManagerForTest(cm) req := buildChatRequest("http_request") ctx := contextWithUserAgent(n8nUA) result := tm.ParseAndAddToolsToRequest(ctx, req) names := toolNamesFromChatRequest(result) if countOccurrences(names, "http_request") != 1 { t.Errorf("n8n: expected exactly 1 'http_request', got %d (names: %v)", countOccurrences(names, "http_request"), names) } } func TestParseAndAddToolsToRequest_QwenCLI_NoDuplicate(t *testing.T) { t.Parallel() const qwenUA = "qwen-code" cm := &mockToolClientManager{ tools: []schemas.ChatTool{ makeTool("code_interpreter"), }, } tm := newToolsManagerForTest(cm) req := buildChatRequest("code_interpreter") ctx := contextWithUserAgent(qwenUA) result := tm.ParseAndAddToolsToRequest(ctx, req) names := toolNamesFromChatRequest(result) if countOccurrences(names, "code_interpreter") != 1 { t.Errorf("Qwen: expected exactly 1 'code_interpreter', got %d (names: %v)", countOccurrences(names, "code_interpreter"), names) } } // ============================================================================= // Responses API deduplication – mirrors the ChatRequest tests above but for // the /responses endpoint path in ParseAndAddToolsToRequest. // ============================================================================= // buildResponsesRequest creates a minimal BifrostResponsesRequest with the given // pre-existing tool names already populated in Params.Tools. func buildResponsesRequest(existingToolNames ...string) *schemas.BifrostRequest { tools := make([]schemas.ResponsesTool, 0, len(existingToolNames)) for _, name := range existingToolNames { n := name // capture tools = append(tools, schemas.ResponsesTool{Name: &n}) } return &schemas.BifrostRequest{ ResponsesRequest: &schemas.BifrostResponsesRequest{ Provider: schemas.OpenAI, Model: "gpt-4", Params: &schemas.ResponsesParameters{ Tools: tools, }, }, RequestType: schemas.ResponsesRequest, } } func TestParseAndAddToolsToRequest_ResponsesAPI_ClaudeCLI_NoDuplicate(t *testing.T) { t.Parallel() cm := &mockToolClientManager{ tools: []schemas.ChatTool{ makeTool("echo"), }, } tm := newToolsManagerForTest(cm) // Responses API: tool is carried under the Claude CLI prefix. req := buildResponsesRequest("mcp__bifrost__echo") ctx := contextWithUserAgent(schemas.ClaudeCLI.String()) result := tm.ParseAndAddToolsToRequest(ctx, req) names := toolNamesFromResponsesRequest(result) if countOccurrences(names, "echo") != 0 { t.Errorf("Responses/ClaudeCLI: bare 'echo' should not be injected, got %d (names: %v)", countOccurrences(names, "echo"), names) } if countOccurrences(names, "mcp__bifrost__echo") != 1 { t.Errorf("Responses/ClaudeCLI: prefixed name should remain exactly once, got %d", countOccurrences(names, "mcp__bifrost__echo")) } } func TestParseAndAddToolsToRequest_ResponsesAPI_NoUserAgent_NoDuplicate(t *testing.T) { t.Parallel() cm := &mockToolClientManager{ tools: []schemas.ChatTool{ makeTool("echo"), makeTool("search"), }, } tm := newToolsManagerForTest(cm) req := buildResponsesRequest("echo") ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) result := tm.ParseAndAddToolsToRequest(ctx, req) names := toolNamesFromResponsesRequest(result) if countOccurrences(names, "echo") != 1 { t.Errorf("Responses/no-agent: expected exactly 1 'echo', got %d (names: %v)", countOccurrences(names, "echo"), names) } // search is new — must be injected. if countOccurrences(names, "search") != 1 { t.Errorf("Responses/no-agent: 'search' should be injected once, got %d", countOccurrences(names, "search")) } } func TestParseAndAddToolsToRequest_ResponsesAPI_CodexCLI_MCPHyphenUnderscoreVariantsDeduped(t *testing.T) { t.Parallel() cm := &mockToolClientManager{ tools: []schemas.ChatTool{ makeTool("foo-bar"), makeTool("foo_bar"), }, } tm := newToolsManagerForTest(cm) req := buildResponsesRequest() ctx := contextWithUserAgent(schemas.CodexCLI.String()) result := tm.ParseAndAddToolsToRequest(ctx, req) names := toolNamesFromResponsesRequest(result) if len(names) != 1 { t.Fatalf("Responses/Codex: hyphen/underscore variants should yield one injected tool, got %d: %v", len(names), names) } if countOccurrences(names, "foo-bar")+countOccurrences(names, "foo_bar") != 1 { t.Errorf("Responses/Codex: expected exactly one of foo-bar or foo_bar, got %v", names) } } // --------------------------------------------------------------------------- // OpenCode — pattern: {server_name}_{tool_name} (no mcp_ prefix, hyphens preserved) // --------------------------------------------------------------------------- // TestBuildIntegrationDuplicateCheckMap_OpenCode_ClientPrefixedTools verifies the // dual-gateway scenario for OpenCode: format is {server}_{tool_name} (no mcp_ prefix, // single underscore separator, hyphens in the Bifrost tool name are preserved). // Strip up to and including the first "_" to recover the Bifrost tool name. func TestBuildIntegrationDuplicateCheckMap_OpenCode_ClientPrefixedTools(t *testing.T) { t.Parallel() tools := []schemas.ChatTool{ makeTool("bifrost_testing_exa-web_fetch_exa"), makeTool("bifrost_testing_exa-web_search_exa"), makeTool("bifrost_testing_websets-cancel_enrichment"), makeTool("bifrost_ctx7-query-docs"), makeTool("bifrost_filesystem-create_directory"), } m := buildIntegrationDuplicateCheckMap(tools, schemas.OpenCode.String(), defaultLogger) for _, want := range []string{ "testing_exa-web_fetch_exa", "testing_exa-web_search_exa", "testing_websets-cancel_enrichment", "ctx7-query-docs", "filesystem-create_directory", } { if !m[want] { t.Errorf("OpenCode: expected %q in duplicate map", want) } } // Original prefixed names must also be retained. for _, want := range []string{ "bifrost_testing_exa-web_fetch_exa", "bifrost_ctx7-query-docs", } { if !m[want] { t.Errorf("OpenCode: expected original prefixed name %q in duplicate map", want) } } } // TestParseAndAddToolsToRequest_OpenCode_NoDuplicate verifies end-to-end deduplication // for OpenCode in the dual-gateway scenario. func TestParseAndAddToolsToRequest_OpenCode_NoDuplicate(t *testing.T) { t.Parallel() cm := &mockToolClientManager{ tools: []schemas.ChatTool{ makeTool("testing_exa-web_fetch_exa"), makeTool("ctx7-query-docs"), makeTool("filesystem-create_directory"), }, } tm := newToolsManagerForTest(cm) // OpenCode sends tools prefixed as {server}_{tool_name}. req := buildChatRequest( "bifrost_testing_exa-web_fetch_exa", "bifrost_ctx7-query-docs", "bifrost_filesystem-create_directory", ) ctx := contextWithUserAgent(schemas.OpenCode.String()) result := tm.ParseAndAddToolsToRequest(ctx, req) names := toolNamesFromChatRequest(result) // Bare Bifrost names must NOT be injected again — they're covered by prefixed entries. for _, bare := range []string{"testing_exa-web_fetch_exa", "ctx7-query-docs", "filesystem-create_directory"} { if countOccurrences(names, bare) != 0 { t.Errorf("OpenCode: bare %q should not be injected, got %d (names: %v)", bare, countOccurrences(names, bare), names) } } // Prefixed originals must remain exactly once. for _, prefixed := range []string{ "bifrost_testing_exa-web_fetch_exa", "bifrost_ctx7-query-docs", "bifrost_filesystem-create_directory", } { if countOccurrences(names, prefixed) != 1 { t.Errorf("OpenCode: expected exactly 1 %q, got %d (names: %v)", prefixed, countOccurrences(names, prefixed), names) } } }