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
3 changes: 2 additions & 1 deletion pkg/vmcp/aggregator/aggregator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions pkg/vmcp/aggregator/default_aggregator.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"fmt"
"log/slog"
"sort"
"sync"

"go.opentelemetry.io/otel/attribute"
Expand Down Expand Up @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions pkg/vmcp/aggregator/default_aggregator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading