Skip to content
Closed
18 changes: 13 additions & 5 deletions cmd/thv-operator/pkg/vmcpconfig/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,23 @@ func NewConverter(oidcResolver oidc.Resolver, k8sClient client.Client) (*Convert
}, nil
}

// Convert converts VirtualMCPServer CRD spec to vmcp Config
// Convert converts VirtualMCPServer CRD spec to vmcp Config.
//
// The conversion starts with a DeepCopy of the embedded config.Config from the CRD spec.
// This ensures that simple fields (like Optimizer, Metadata, etc.) are automatically
// passed through without explicit mapping. Only fields that require special handling
// (auth, aggregation, composite tools, telemetry) are explicitly converted below.
func (c *Converter) Convert(
ctx context.Context,
vmcp *mcpv1alpha1.VirtualMCPServer,
) (*vmcpconfig.Config, error) {
config := &vmcpconfig.Config{
Name: vmcp.Name,
Group: vmcp.Spec.Config.Group,
}
// Start with a deep copy of the embedded config for automatic field passthrough.
// This ensures new fields added to config.Config are automatically included
// without requiring explicit mapping in this converter.
config := vmcp.Spec.Config.DeepCopy()

// Override name with the CR name (authoritative source)
config.Name = vmcp.Name

// Convert IncomingAuth - required field, no defaults
if vmcp.Spec.IncomingAuth != nil {
Expand Down
4 changes: 3 additions & 1 deletion cmd/vmcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ The Virtual MCP Server (vmcp) is a standalone binary that aggregates multiple MC

## Features

### Implemented (Phase 1)
### Implemented
- ✅ **Group-Based Backend Management**: Automatic workload discovery from ToolHive groups
- ✅ **Tool Aggregation**: Combines tools from multiple MCP servers with conflict resolution (prefix, priority, manual)
- ✅ **Resource & Prompt Aggregation**: Unified access to resources and prompts from all backends
Expand All @@ -15,12 +15,14 @@ The Virtual MCP Server (vmcp) is a standalone binary that aggregates multiple MC
- ✅ **Health Endpoints**: `/health` and `/ping` for service monitoring
- ✅ **Configuration Validation**: `vmcp validate` command for config verification
- ✅ **Observability**: OpenTelemetry metrics and traces for backend operations and workflow executions
- ✅ **Composite Tools**: Multi-step workflows with elicitation support

### In Progress
- 🚧 **Incoming Authentication** (Issue #165): OIDC, local, anonymous authentication
- 🚧 **Outgoing Authentication** (Issue #160): RFC 8693 token exchange for backend API access
- 🚧 **Token Caching**: Memory and Redis cache providers
- 🚧 **Health Monitoring** (Issue #166): Circuit breakers, backend health checks
- 🚧 **Optimizer** Support the MCP optimizer in vMCP for context optimization on large toolsets.

### Future (Phase 2+)
- 📋 **Authorization**: Cedar policy-based access control
Expand Down
6 changes: 6 additions & 0 deletions cmd/vmcp/app/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/stacklok/toolhive/pkg/vmcp/discovery"
"github.com/stacklok/toolhive/pkg/vmcp/health"
"github.com/stacklok/toolhive/pkg/vmcp/k8s"
"github.com/stacklok/toolhive/pkg/vmcp/optimizer"
vmcprouter "github.com/stacklok/toolhive/pkg/vmcp/router"
vmcpserver "github.com/stacklok/toolhive/pkg/vmcp/server"
)
Expand Down Expand Up @@ -416,6 +417,11 @@ func runServe(cmd *cobra.Command, _ []string) error {
Watcher: backendWatcher,
}

if cfg.Optimizer != nil {
// TODO: update this with the real optimizer.
serverCfg.OptimizerFactory = optimizer.NewDummyOptimizer
}

// Convert composite tool configurations to workflow definitions
workflowDefs, err := vmcpserver.ConvertConfigToWorkflowDefinitions(cfg.CompositeTools)
if err != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,21 @@ spec:
- default
type: object
type: object
optimizer:
description: |-
Optimizer configures the MCP optimizer for context optimization on large toolsets.
When enabled, vMCP exposes only find_tool and call_tool operations to clients
instead of all backend tools directly. This reduces token usage by allowing
LLMs to discover relevant tools on demand rather than receiving all tool definitions.
properties:
embeddingService:
description: |-
EmbeddingService is the name of a Kubernetes Service that provides the embedding service
for semantic tool discovery. The service must implement the optimizer embedding API.
type: string
required:
- embeddingService
type: object
outgoingAuth:
description: OutgoingAuth configures how the virtual MCP server
authenticates to backends.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,21 @@ spec:
- default
type: object
type: object
optimizer:
description: |-
Optimizer configures the MCP optimizer for context optimization on large toolsets.
When enabled, vMCP exposes only find_tool and call_tool operations to clients
instead of all backend tools directly. This reduces token usage by allowing
LLMs to discover relevant tools on demand rather than receiving all tool definitions.
properties:
embeddingService:
description: |-
EmbeddingService is the name of a Kubernetes Service that provides the embedding service
for semantic tool discovery. The service must implement the optimizer embedding API.
type: string
required:
- embeddingService
type: object
outgoingAuth:
description: OutgoingAuth configures how the virtual MCP server
authenticates to backends.
Expand Down
19 changes: 19 additions & 0 deletions docs/operator/crd-api.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions pkg/vmcp/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ type Config struct {
// See audit.Config for available configuration options.
// +optional
Audit *audit.Config `json:"audit,omitempty" yaml:"audit,omitempty"`

// Optimizer configures the MCP optimizer for context optimization on large toolsets.
// When enabled, vMCP exposes only find_tool and call_tool operations to clients
// instead of all backend tools directly. This reduces token usage by allowing
// LLMs to discover relevant tools on demand rather than receiving all tool definitions.
// +optional
Optimizer *OptimizerConfig `json:"optimizer,omitempty" yaml:"optimizer,omitempty"`
}

// IncomingAuthConfig configures client authentication to the virtual MCP server.
Expand Down Expand Up @@ -474,6 +481,18 @@ type OutputProperty struct {
Default thvjson.Any `json:"default,omitempty" yaml:"default,omitempty"`
}

// OptimizerConfig configures the MCP optimizer.
// When enabled, vMCP exposes only find_tool and call_tool operations to clients
// instead of all backend tools directly.
// +kubebuilder:object:generate=true
// +gendoc
type OptimizerConfig struct {
// EmbeddingService is the name of a Kubernetes Service that provides the embedding service
// for semantic tool discovery. The service must implement the optimizer embedding API.
// +kubebuilder:validation:Required
EmbeddingService string `json:"embeddingService" yaml:"embeddingService"`
}

// Validator validates configuration.
type Validator interface {
// Validate checks if the configuration is valid.
Expand Down
20 changes: 20 additions & 0 deletions pkg/vmcp/config/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

122 changes: 122 additions & 0 deletions pkg/vmcp/optimizer/dummy_optimizer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package optimizer

import (
"context"
"encoding/json"
"fmt"
"strings"

"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)

// DummyOptimizer implements the Optimizer interface using exact string matching.
//
// This implementation is intended for testing and development. It performs
// case-insensitive substring matching on tool names and descriptions.
//
// For production use, see the EmbeddingOptimizer which uses semantic similarity.
type DummyOptimizer struct {
// tools contains all available tools indexed by name.
tools map[string]server.ServerTool
}

// NewDummyOptimizer creates a new DummyOptimizer with the given tools.
//
// The tools slice should contain all backend tools (as ServerTool with handlers).
func NewDummyOptimizer(tools []server.ServerTool) Optimizer {
toolMap := make(map[string]server.ServerTool, len(tools))
for _, tool := range tools {
toolMap[tool.Tool.Name] = tool
}

return DummyOptimizer{
tools: toolMap,
}
}

// FindTool searches for tools using exact substring matching.
//
// The search is case-insensitive and matches against:
// - Tool name (substring match)
// - Tool description (substring match)
//
// Returns all matching tools with a score of 1.0 (exact match semantics).
// TokenMetrics are returned as zero values (not implemented in dummy).
func (d DummyOptimizer) FindTool(_ context.Context, input FindToolInput) (*FindToolOutput, error) {
if input.ToolDescription == "" {
return nil, fmt.Errorf("tool_description is required")
}

// Log all tools in the optimizer for debugging
fmt.Printf("[DummyOptimizer.FindTool] Searching for %q in %d tools:\n", input.ToolDescription, len(d.tools))
for name, tool := range d.tools {
fmt.Printf(" - %q: %q\n", name, tool.Tool.Description)
}

searchTerm := strings.ToLower(input.ToolDescription)

var matches []ToolMatch
for _, tool := range d.tools {
nameLower := strings.ToLower(tool.Tool.Name)
descLower := strings.ToLower(tool.Tool.Description)

// Check if search term matches name or description
if strings.Contains(nameLower, searchTerm) || strings.Contains(descLower, searchTerm) {
schema, err := getToolSchema(tool.Tool)
if err != nil {
return nil, err
}
matches = append(matches, ToolMatch{
Name: tool.Tool.Name,
Description: tool.Tool.Description,
Parameters: schema,
Score: 1.0, // Exact match semantics
})
}
}

return &FindToolOutput{
Tools: matches,
TokenMetrics: TokenMetrics{}, // Zero values for dummy
}, nil
}

// CallTool invokes a tool by name using its registered handler.
//
// The tool is looked up by exact name match. If found, the handler
// is invoked directly with the given parameters.
func (d DummyOptimizer) CallTool(ctx context.Context, input CallToolInput) (*mcp.CallToolResult, error) {
if input.ToolName == "" {
return nil, fmt.Errorf("tool_name is required")
}

// Verify the tool exists
tool, exists := d.tools[input.ToolName]
if !exists {
return mcp.NewToolResultError(fmt.Sprintf("tool not found: %s", input.ToolName)), nil
}

// Build the MCP request
request := mcp.CallToolRequest{}
request.Params.Name = input.ToolName
request.Params.Arguments = input.Parameters

// Call the tool handler directly
return tool.Handler(ctx, request)
}

// getToolSchema returns the input schema for a tool.
// Prefers RawInputSchema if set, otherwise marshals InputSchema.
func getToolSchema(tool mcp.Tool) (json.RawMessage, error) {
if len(tool.RawInputSchema) > 0 {
return tool.RawInputSchema, nil
}

// Fall back to InputSchema
data, err := json.Marshal(tool.InputSchema)
if err != nil {
return nil, fmt.Errorf("failed to marshal input schema for tool %s: %w", tool.Name, err)
}
return data, nil
}
Loading
Loading