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
2 changes: 1 addition & 1 deletion cmd/vmcp/app/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ func runServe(cmd *cobra.Command, _ []string) error {
if telemetryProvider != nil {
tracerProvider = telemetryProvider.TracerProvider()
}
agg := aggregator.NewDefaultAggregator(backendClient, conflictResolver, cfg.Aggregation.Tools, tracerProvider)
agg := aggregator.NewDefaultAggregator(backendClient, conflictResolver, cfg.Aggregation, tracerProvider)

// Use DynamicRegistry for version-based cache invalidation
// Works in both standalone (CLI with YAML config) and Kubernetes (operator-deployed) modes
Expand Down
66 changes: 55 additions & 11 deletions pkg/vmcp/aggregator/default_aggregator.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,30 @@ type defaultAggregator struct {
backendClient vmcp.BackendClient
conflictResolver ConflictResolver
toolConfigMap map[string]*config.WorkloadToolConfig // Maps backend ID to tool config
excludeAllTools bool // Global flag to exclude all tools
tracer trace.Tracer
}

// NewDefaultAggregator creates a new default aggregator implementation.
// conflictResolver handles tool name conflicts across backends.
// workloadConfigs specifies per-backend tool filtering and overrides.
// aggregationConfig specifies aggregation settings including tool filtering/overrides and excludeAllTools.
// tracerProvider is used to create a tracer for distributed tracing (pass nil for no tracing).
func NewDefaultAggregator(
backendClient vmcp.BackendClient,
conflictResolver ConflictResolver,
workloadConfigs []*config.WorkloadToolConfig,
aggregationConfig *config.AggregationConfig,
tracerProvider trace.TracerProvider,
) Aggregator {
// Build tool config map for quick lookup by backend ID
toolConfigMap := make(map[string]*config.WorkloadToolConfig)
for _, wlConfig := range workloadConfigs {
if wlConfig != nil {
toolConfigMap[wlConfig.Workload] = wlConfig
var excludeAllTools bool

if aggregationConfig != nil {
excludeAllTools = aggregationConfig.ExcludeAllTools
for _, wlConfig := range aggregationConfig.Tools {
if wlConfig != nil {
toolConfigMap[wlConfig.Workload] = wlConfig
}
}
}

Expand All @@ -58,6 +64,7 @@ func NewDefaultAggregator(
backendClient: backendClient,
conflictResolver: conflictResolver,
toolConfigMap: toolConfigMap,
excludeAllTools: excludeAllTools,
tracer: tracer,
}
}
Expand Down Expand Up @@ -91,6 +98,10 @@ func (a *defaultAggregator) QueryCapabilities(ctx context.Context, backend vmcp.
}

// Apply per-backend tool filtering and overrides (before conflict resolution)
// NOTE: ExcludeAll (both global and per-workload) is NOT applied here.
// This is intentional - we need all tools in the routing table so composite
// tools can call backend tools. ExcludeAll is applied in MergeCapabilities
// to control which tools are advertised to the LLM.
processedTools := processBackendTools(ctx, backend.ID, capabilities.Tools, a.toolConfigMap[backend.ID])

// Convert to BackendCapabilities
Expand Down Expand Up @@ -300,15 +311,24 @@ func (a *defaultAggregator) MergeCapabilities(
}

// Convert resolved tools to final vmcp.Tool format
// The routing table gets ALL tools (for composite tool routing)
// The advertised tools list only gets non-excluded tools (for LLM)
tools := make([]vmcp.Tool, 0, len(resolved.Tools))
for _, resolvedTool := range resolved.Tools {
tools = append(tools, vmcp.Tool{
Name: resolvedTool.ResolvedName,
Description: resolvedTool.Description,
InputSchema: resolvedTool.InputSchema,
BackendID: resolvedTool.BackendID,
})
// Check if this tool should be excluded from the advertised list
// ExcludeAll only affects advertising, not routing
shouldAdvertise := a.shouldAdvertiseTool(resolvedTool.BackendID)

if shouldAdvertise {
tools = append(tools, vmcp.Tool{
Name: resolvedTool.ResolvedName,
Description: resolvedTool.Description,
InputSchema: resolvedTool.InputSchema,
BackendID: resolvedTool.BackendID,
})
}

// ALWAYS add to routing table (for composite tools to call excluded backend tools)
// Look up full backend information from registry
backend := registry.Get(ctx, resolvedTool.BackendID)
if backend == nil {
Expand Down Expand Up @@ -466,3 +486,27 @@ func (a *defaultAggregator) AggregateCapabilities(

return aggregated, nil
}

// shouldAdvertiseTool returns true if a tool from the given backend should be
// advertised to the LLM (included in tools/list response).
//
// ExcludeAll settings control advertising, not routing:
// - Tools excluded via ExcludeAll are NOT advertised to the LLM
// - BUT they ARE available in the routing table for composite tools to use
//
// This enables the use case where you want to hide raw backend tools from
// direct LLM access while still allowing curated composite workflows to use them.
func (a *defaultAggregator) shouldAdvertiseTool(backendID string) bool {
// Global ExcludeAllTools takes precedence - excludes all tools from all backends
if a.excludeAllTools {
return false
}

// Check per-workload ExcludeAll setting
if wlConfig, exists := a.toolConfigMap[backendID]; exists && wlConfig.ExcludeAll {
return false
}

// Tool should be advertised
return true
}
200 changes: 200 additions & 0 deletions pkg/vmcp/aggregator/default_aggregator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"go.uber.org/mock/gomock"

"github.com/stacklok/toolhive/pkg/vmcp"
"github.com/stacklok/toolhive/pkg/vmcp/config"
"github.com/stacklok/toolhive/pkg/vmcp/mocks"
)

Expand Down Expand Up @@ -345,3 +346,202 @@ func TestDefaultAggregator_AggregateCapabilities(t *testing.T) {
assert.Equal(t, 1, result.Metadata.ResourceCount)
})
}

func TestDefaultAggregator_ExcludeAllTools(t *testing.T) {
t.Parallel()

// NOTE: ExcludeAll is applied in MergeCapabilities, NOT in QueryCapabilities.
// This allows the routing table to contain all tools (for composite tools)
// while only filtering the advertised tools list.

t.Run("QueryCapabilities returns all tools even with global excludeAllTools", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockClient := mocks.NewMockBackendClient(ctrl)
backend := newTestBackend("backend1", withBackendName("Backend 1"))

// Backend returns tools - they should still be returned by QueryCapabilities
// because ExcludeAll is applied later in MergeCapabilities
expectedCaps := newTestCapabilityList(
withTools(newTestTool("test_tool", "backend1")),
withResources(newTestResource("test://resource", "backend1")),
withPrompts(newTestPrompt("test_prompt", "backend1")),
withLogging(true))

mockClient.EXPECT().ListCapabilities(gomock.Any(), gomock.Any()).Return(expectedCaps, nil)

// Create aggregator with ExcludeAllTools: true
aggregationConfig := &config.AggregationConfig{
ExcludeAllTools: true,
}
agg := NewDefaultAggregator(mockClient, nil, aggregationConfig, nil)
result, err := agg.QueryCapabilities(context.Background(), backend)

require.NoError(t, err)
assert.Equal(t, "backend1", result.BackendID)
// Tools should still be present (ExcludeAll is applied in MergeCapabilities)
assert.Len(t, result.Tools, 1)
assert.Equal(t, "test_tool", result.Tools[0].Name)
// Resources and prompts should be preserved
assert.Len(t, result.Resources, 1)
assert.Len(t, result.Prompts, 1)
assert.True(t, result.SupportsLogging)
})

t.Run("global excludeAllTools false allows tools through", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockClient := mocks.NewMockBackendClient(ctrl)
backend := newTestBackend("backend1", withBackendName("Backend 1"))

expectedCaps := newTestCapabilityList(
withTools(newTestTool("test_tool", "backend1")))

mockClient.EXPECT().ListCapabilities(gomock.Any(), gomock.Any()).Return(expectedCaps, nil)

// Create aggregator with ExcludeAllTools: false (default)
aggregationConfig := &config.AggregationConfig{
ExcludeAllTools: false,
}
agg := NewDefaultAggregator(mockClient, nil, aggregationConfig, nil)
result, err := agg.QueryCapabilities(context.Background(), backend)

require.NoError(t, err)
// Tools should come through
assert.Len(t, result.Tools, 1)
assert.Equal(t, "test_tool", result.Tools[0].Name)
})

t.Run("nil aggregationConfig allows tools through", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockClient := mocks.NewMockBackendClient(ctrl)
backend := newTestBackend("backend1", withBackendName("Backend 1"))

expectedCaps := newTestCapabilityList(
withTools(newTestTool("test_tool", "backend1")))

mockClient.EXPECT().ListCapabilities(gomock.Any(), gomock.Any()).Return(expectedCaps, nil)

// Create aggregator with nil aggregationConfig (default behavior)
agg := NewDefaultAggregator(mockClient, nil, nil, nil)
result, err := agg.QueryCapabilities(context.Background(), backend)

require.NoError(t, err)
// Tools should come through
assert.Len(t, result.Tools, 1)
assert.Equal(t, "test_tool", result.Tools[0].Name)
})
}

func TestDefaultAggregator_ExcludeAllPreservesRoutingTableForCompositeTools(t *testing.T) {
t.Parallel()

// This test verifies that ExcludeAll only affects the advertised tools list,
// NOT the routing table. This is important because composite tools need to
// route to backend tools that may be excluded from direct LLM access.
//
// Use case: A vMCP server may want to hide raw backend tools from the LLM
// (using ExcludeAll) while still allowing curated composite tool workflows
// to use those backend tools internally.

t.Run("per-workload excludeAll preserves routing table for composite tools", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockClient := mocks.NewMockBackendClient(ctrl)
backends := []vmcp.Backend{
newTestBackend("github", withBackendName("GitHub")),
}

// Backend has tools that should be available for composite tools
caps := newTestCapabilityList(
withTools(
newTestTool("create_issue", "github"),
newTestTool("list_issues", "github"),
),
)

mockClient.EXPECT().ListCapabilities(gomock.Any(), gomock.Any()).Return(caps, nil)

// Configure ExcludeAll for the github backend
aggregationConfig := &config.AggregationConfig{
Tools: []*config.WorkloadToolConfig{
{
Workload: "github",
ExcludeAll: true,
},
},
}

agg := NewDefaultAggregator(mockClient, nil, aggregationConfig, nil)
result, err := agg.AggregateCapabilities(context.Background(), backends)

require.NoError(t, err)
assert.NotNil(t, result)

// Advertised tools should be empty (excluded from LLM)
assert.Empty(t, result.Tools, "ExcludeAll should hide tools from LLM")

// BUT the routing table should still contain the tools (for composite tools)
assert.NotNil(t, result.RoutingTable)
assert.Contains(t, result.RoutingTable.Tools, "create_issue",
"Routing table should contain excluded tools for composite tool use")
assert.Contains(t, result.RoutingTable.Tools, "list_issues",
"Routing table should contain excluded tools for composite tool use")

// Verify the routing targets are properly configured
createIssueTarget := result.RoutingTable.Tools["create_issue"]
assert.NotNil(t, createIssueTarget)
assert.Equal(t, "github", createIssueTarget.WorkloadID)
})

t.Run("global excludeAllTools preserves routing table for composite tools", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockClient := mocks.NewMockBackendClient(ctrl)
backends := []vmcp.Backend{
newTestBackend("slack", withBackendName("Slack")),
}

// Backend has tools
caps := newTestCapabilityList(
withTools(
newTestTool("send_message", "slack"),
newTestTool("list_channels", "slack"),
),
)

mockClient.EXPECT().ListCapabilities(gomock.Any(), gomock.Any()).Return(caps, nil)

// Configure global ExcludeAllTools
aggregationConfig := &config.AggregationConfig{
ExcludeAllTools: true,
}

agg := NewDefaultAggregator(mockClient, nil, aggregationConfig, nil)
result, err := agg.AggregateCapabilities(context.Background(), backends)

require.NoError(t, err)
assert.NotNil(t, result)

// Advertised tools should be empty
assert.Empty(t, result.Tools, "Global ExcludeAllTools should hide all tools from LLM")

// BUT routing table should still contain tools for composite tools
assert.NotNil(t, result.RoutingTable)
assert.Contains(t, result.RoutingTable.Tools, "send_message",
"Routing table should contain globally excluded tools for composite tool use")
assert.Contains(t, result.RoutingTable.Tools, "list_channels",
"Routing table should contain globally excluded tools for composite tool use")
})
}
5 changes: 5 additions & 0 deletions pkg/vmcp/aggregator/tool_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ func processBackendTools(
return tools // No configuration for this backend
}

// NOTE: ExcludeAll is NOT applied here. ExcludeAll only affects which tools
// are advertised to the LLM, not which tools are available for routing.
// This allows composite tools to call backend tools that are excluded from
// direct LLM access. ExcludeAll is applied later in MergeCapabilities.

// If no filter or overrides configured, return tools as-is
if len(workloadConfig.Filter) == 0 && len(workloadConfig.Overrides) == 0 {
return tools
Expand Down
Loading
Loading