Skip to content

Commit 32725c6

Browse files
JAORMXclaude
andcommitted
Implement tool conflict resolution for Virtual MCP Server
Add three conflict resolution strategies (prefix, priority, manual) to handle duplicate tool names across multiple backend MCP servers in vMCP aggregation. This implements the aggregation conflict resolution portion of the Virtual MCP Server proposal (THV-2106), enabling vMCP to merge capabilities from multiple backends while resolving naming conflicts. Key features: - Prefix strategy: automatically prefixes tools with workload identifier (supports {workload}_, {workload}., custom formats) - Priority strategy: explicit ordering with first-wins semantics (drops lower-priority conflicting tools with warnings) - Manual strategy: requires explicit overrides with startup validation (fails if any conflicts lack overrides, safest for production) - Reuses existing mcp.WithToolsFilter/Override middleware logic - Per-backend tool filtering and overrides applied before conflict resolution - Tracks conflict metadata (count resolved, strategy used) Implementation: - Extracted shared filtering/override logic from pkg/mcp/tool_filter.go - Created applyFilteringAndOverrides() as single source of truth - Both HTTP middleware and aggregator use the same battle-tested code - Updated defaultAggregator to integrate conflict resolver - Comprehensive table-driven unit tests for all strategies This follows DDD principles with clear bounded contexts and maintains backward compatibility with existing middleware behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent c864276 commit 32725c6

File tree

7 files changed

+1310
-64
lines changed

7 files changed

+1310
-64
lines changed

pkg/mcp/tool_filter.go

Lines changed: 102 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,36 @@ func (c *toolMiddlewareConfig) getToolListOverride(toolName string) (*toolOverri
7272
// middleware.
7373
type ToolMiddlewareOption func(*toolMiddlewareConfig) error
7474

75+
// SimpleTool represents a minimal tool with name and description.
76+
// This is used by ApplyToolFiltering to work with tools in a generic way.
77+
type SimpleTool struct {
78+
Name string
79+
Description string
80+
}
81+
82+
// ApplyToolFiltering applies filtering and overriding to a list of tools.
83+
// This is the core logic used by both the HTTP middleware and other components
84+
// that need to apply the same filtering/overriding behavior.
85+
//
86+
// Returns the filtered and overridden tools.
87+
func ApplyToolFiltering(opts []ToolMiddlewareOption, tools []SimpleTool) ([]SimpleTool, error) {
88+
config := &toolMiddlewareConfig{
89+
filterTools: make(map[string]struct{}),
90+
actualToUserOverride: make(map[string]toolOverrideEntry),
91+
userToActualOverride: make(map[string]toolOverrideEntry),
92+
}
93+
94+
// Apply options to build config
95+
for _, opt := range opts {
96+
if err := opt(config); err != nil {
97+
return nil, err
98+
}
99+
}
100+
101+
// Use the shared core logic
102+
return applyFilteringAndOverrides(config, tools), nil
103+
}
104+
75105
// WithToolsFilter is a function that can be used to configure the tool
76106
// middleware to use a filter list of tools.
77107
func WithToolsFilter(toolsFilter ...string) ToolMiddlewareOption {
@@ -448,7 +478,10 @@ func processToolsListResponse(
448478
toolsListResponse toolsListResponse,
449479
w io.Writer,
450480
) error {
451-
filteredTools := []map[string]any{}
481+
// Convert to SimpleTool format for shared processing
482+
simpleTools := make([]SimpleTool, 0, len(*toolsListResponse.Result.Tools))
483+
toolMaps := make([]map[string]any, 0, len(*toolsListResponse.Result.Tools))
484+
452485
for _, tool := range *toolsListResponse.Result.Tools {
453486
// NOTE: the spec does not allow for name to be missing.
454487
toolName, ok := tool["name"].(string)
@@ -461,31 +494,86 @@ func processToolsListResponse(
461494
return errToolNameNotFound
462495
}
463496

497+
// Get description if present (optional in MCP spec)
498+
description, _ := tool["description"].(string)
499+
500+
simpleTools = append(simpleTools, SimpleTool{
501+
Name: toolName,
502+
Description: description,
503+
})
504+
toolMaps = append(toolMaps, tool)
505+
}
506+
507+
// Apply the shared filtering/override logic
508+
processedTools := applyFilteringAndOverrides(config, simpleTools)
509+
510+
// Build the filtered response by matching processed tools with their original maps
511+
filteredTools := make([]map[string]any, 0, len(processedTools))
512+
for _, processed := range processedTools {
513+
// Find the original tool map by matching names
514+
for i, simple := range simpleTools {
515+
if simple.Name == processed.Name || simple.Name == findOriginalName(config, processed.Name) {
516+
// Clone the original map and update name/description
517+
toolCopy := make(map[string]any, len(toolMaps[i]))
518+
for k, v := range toolMaps[i] {
519+
toolCopy[k] = v
520+
}
521+
toolCopy["name"] = processed.Name
522+
if processed.Description != "" {
523+
toolCopy["description"] = processed.Description
524+
}
525+
filteredTools = append(filteredTools, toolCopy)
526+
break
527+
}
528+
}
529+
}
530+
531+
toolsListResponse.Result.Tools = &filteredTools
532+
if err := json.NewEncoder(w).Encode(toolsListResponse); err != nil {
533+
return fmt.Errorf("%w: %v", errBug, err)
534+
}
535+
536+
return nil
537+
}
538+
539+
// applyFilteringAndOverrides is the core logic for filtering and overriding tools.
540+
// This implements the exact same logic as before but is now extracted for reuse.
541+
func applyFilteringAndOverrides(config *toolMiddlewareConfig, tools []SimpleTool) []SimpleTool {
542+
result := make([]SimpleTool, 0, len(tools))
543+
for _, tool := range tools {
544+
description := tool.Description
545+
464546
// If the tool is overridden, we need to use the override name and description.
465-
if entry, ok := config.getToolListOverride(toolName); ok {
547+
if entry, ok := config.getToolListOverride(tool.Name); ok {
466548
if entry.OverrideName != "" {
467-
tool["name"] = entry.OverrideName
549+
tool.Name = entry.OverrideName
468550
}
469551
if entry.OverrideDescription != "" {
470-
tool["description"] = entry.OverrideDescription
552+
description = entry.OverrideDescription
471553
}
472-
toolName = entry.OverrideName
473554
}
474555

475556
// If the tool is in the filter, we add it to the filtered tools list.
476-
// Note that lookup is done using the user-known name, which might be
477-
// different from the actual tool name.
478-
if config.isToolInFilter(toolName) {
479-
filteredTools = append(filteredTools, tool)
557+
// Note that lookup is done using the user-known name (tool.Name after override).
558+
if config.isToolInFilter(tool.Name) {
559+
result = append(result, SimpleTool{
560+
Name: tool.Name,
561+
Description: description,
562+
})
480563
}
481564
}
565+
return result
566+
}
482567

483-
toolsListResponse.Result.Tools = &filteredTools
484-
if err := json.NewEncoder(w).Encode(toolsListResponse); err != nil {
485-
return fmt.Errorf("%w: %v", errBug, err)
568+
// findOriginalName attempts to find the original tool name before override.
569+
func findOriginalName(config *toolMiddlewareConfig, overriddenName string) string {
570+
// Iterate through overrides to find reverse mapping
571+
for actualName, entry := range config.actualToUserOverride {
572+
if entry.OverrideName == overriddenName {
573+
return actualName
574+
}
486575
}
487-
488-
return nil
576+
return overriddenName
489577
}
490578

491579
// toolCallFix mimics a sum type in Go. The actual types represent the

0 commit comments

Comments
 (0)