diff --git a/pkg/vmcp/aggregator/aggregator.go b/pkg/vmcp/aggregator/aggregator.go index 6d2b482359..50f6aa28d5 100644 --- a/pkg/vmcp/aggregator/aggregator.go +++ b/pkg/vmcp/aggregator/aggregator.go @@ -134,7 +134,8 @@ type ResolvedTool struct { // AggregatedCapabilities is the final unified view of all backend capabilities. // This is what gets exposed to MCP clients via tools/list, resources/list, prompts/list. type AggregatedCapabilities struct { - // Tools are the aggregated backend tools (ready to expose to clients). + // Tools are the aggregated backend tools (ready to expose to clients), + // sorted by name for deterministic ordering. Tools []vmcp.Tool // CompositeTools are the composite workflow tools defined in vMCP configuration. diff --git a/pkg/vmcp/aggregator/default_aggregator.go b/pkg/vmcp/aggregator/default_aggregator.go index 42b80bae3c..9fe1b35ad0 100644 --- a/pkg/vmcp/aggregator/default_aggregator.go +++ b/pkg/vmcp/aggregator/default_aggregator.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "log/slog" + "sort" "sync" "go.opentelemetry.io/otel/attribute" @@ -353,6 +354,10 @@ func (a *defaultAggregator) MergeCapabilities( } } + sort.Slice(tools, func(i, j int) bool { + return tools[i].Name < tools[j].Name + }) + // Add resources to routing table for _, resource := range resolved.Resources { backend := registry.Get(ctx, resource.BackendID) diff --git a/pkg/vmcp/aggregator/default_aggregator_test.go b/pkg/vmcp/aggregator/default_aggregator_test.go index 1507327713..7634dd1110 100644 --- a/pkg/vmcp/aggregator/default_aggregator_test.go +++ b/pkg/vmcp/aggregator/default_aggregator_test.go @@ -306,6 +306,47 @@ func TestDefaultAggregator_MergeCapabilities(t *testing.T) { }) } +func TestDefaultAggregator_MergeCapabilities_DeterministicToolOrder(t *testing.T) { + t.Parallel() + + names := []string{"zebra_tool", "middle_tool", "alpha_tool", "omega_tool", "delta_tool", "beta_tool", "gamma_tool"} + resolvedTools := make(map[string]*ResolvedTool, len(names)) + for _, name := range names { + resolvedTools[name] = &ResolvedTool{ + ResolvedName: name, + OriginalName: name, + BackendID: "backend1", + } + } + + registry := vmcp.NewImmutableRegistry([]vmcp.Backend{ + { + ID: "backend1", + Name: "Backend 1", + BaseURL: "http://backend1:8080", + TransportType: "streamable-http", + HealthStatus: vmcp.BackendHealthy, + }, + }) + agg := NewDefaultAggregator(nil, nil, nil, nil) + + want := []string{"alpha_tool", "beta_tool", "delta_tool", "gamma_tool", "middle_tool", "omega_tool", "zebra_tool"} + + // Repeated because map iteration order is re-randomized on every merge. + for range 10 { + aggregated, err := agg.MergeCapabilities( + context.Background(), &ResolvedCapabilities{Tools: resolvedTools}, registry, + ) + require.NoError(t, err) + + got := make([]string, len(aggregated.Tools)) + for i, tool := range aggregated.Tools { + got[i] = tool.Name + } + require.Equal(t, want, got, "tools should always be sorted by name") + } +} + func TestDefaultAggregator_AggregateCapabilities(t *testing.T) { t.Parallel()