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
18 changes: 14 additions & 4 deletions cmd/vmcp/app/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
"github.com/stacklok/toolhive/pkg/groups"
"github.com/stacklok/toolhive/pkg/logger"
"github.com/stacklok/toolhive/pkg/vmcp/aggregator"
vmcpauth "github.com/stacklok/toolhive/pkg/vmcp/auth"
"github.com/stacklok/toolhive/pkg/vmcp/auth/factory"
vmcpclient "github.com/stacklok/toolhive/pkg/vmcp/client"
"github.com/stacklok/toolhive/pkg/vmcp/config"
vmcprouter "github.com/stacklok/toolhive/pkg/vmcp/router"
Expand Down Expand Up @@ -213,8 +213,15 @@ func runServe(cmd *cobra.Command, _ []string) error {
return fmt.Errorf("failed to create groups manager: %w", err)
}

// Create outgoing authentication registry from configuration
logger.Info("Initializing outgoing authentication")
outgoingRegistry, err := factory.NewOutgoingAuthRegistry(ctx, cfg.OutgoingAuth)
if err != nil {
return fmt.Errorf("failed to create outgoing authentication registry: %w", err)
}

// Create backend discoverer
discoverer := aggregator.NewCLIBackendDiscoverer(workloadsManager, groupsManager)
discoverer := aggregator.NewCLIBackendDiscoverer(workloadsManager, groupsManager, cfg.OutgoingAuth)

// Discover backends from the configured group
logger.Infof("Discovering backends in group: %s", cfg.GroupRef)
Expand All @@ -230,7 +237,10 @@ func runServe(cmd *cobra.Command, _ []string) error {
logger.Infof("Discovered %d backends", len(backends))

// Create backend client
backendClient := vmcpclient.NewHTTPBackendClient()
backendClient, err := vmcpclient.NewHTTPBackendClient(outgoingRegistry)
if err != nil {
return fmt.Errorf("failed to create backend client: %w", err)
}

// Create conflict resolver based on configuration
// Use the factory method that handles all strategies
Expand Down Expand Up @@ -264,7 +274,7 @@ func runServe(cmd *cobra.Command, _ []string) error {
// Setup authentication middleware
logger.Infof("Setting up incoming authentication (type: %s)", cfg.IncomingAuth.Type)

authMiddleware, authInfoHandler, err := vmcpauth.NewIncomingAuthMiddleware(ctx, cfg.IncomingAuth)
authMiddleware, authInfoHandler, err := factory.NewIncomingAuthMiddleware(ctx, cfg.IncomingAuth)
if err != nil {
return fmt.Errorf("failed to create authentication middleware: %w", err)
}
Expand Down
31 changes: 20 additions & 11 deletions cmd/vmcp/example-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,25 +34,34 @@ incoming_auth:
# scopes: ["openid", "profile", "email"]

# ===== OUTGOING AUTHENTICATION (Virtual MCP → Backends) =====
# Currently not implemented - this configuration is a placeholder for
# future implementation (Issue #160)
# Implemented strategies: unauthenticated, header_injection
outgoing_auth:
source: inline # Options: inline | discovered

# Default behavior for backends without explicit config
default:
type: pass_through # Options: pass_through | token_exchange | service_account
type: unauthenticated # Options: unauthenticated | header_injection
# TODO: Uncomment when pass_through is implemented
# type: pass_through

# Per-backend authentication (not yet implemented)
# Per-backend authentication examples
# backends:
# # Example: API key authentication
# github:
# type: token_exchange
# token_exchange:
# token_url: "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token"
# client_id: "vmcp-github-exchange"
# client_secret_env: "GITHUB_EXCHANGE_SECRET"
# audience: "github-api"
# scopes: ["repo", "read:org"]
# type: header_injection
# header_injection:
# header_name: "Authorization"
# header_value: "${GITHUB_API_TOKEN}"
#
# # TODO: Uncomment when token_exchange is implemented
# # jira:
# # type: token_exchange
# # metadata:
# # token_url: "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token"
# # client_id: "vmcp-github-exchange"
# # client_secret_env: "GITHUB_EXCHANGE_SECRET"
# # audience: "github-api"
# # scopes: ["repo", "read:org"]

# ===== TOOL AGGREGATION =====
aggregation:
Expand Down
70 changes: 42 additions & 28 deletions examples/vmcp-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ incoming_auth:
client_id: "vmcp-client"
client_secret_env: "VMCP_CLIENT_SECRET" # Read from environment variable
audience: "vmcp" # Token must have aud=vmcp
resource: "http://localhost:4483/mcp"
scopes: ["openid", "profile", "email"]

# Optional: Authorization policies (Cedar)
Expand All @@ -33,42 +34,55 @@ outgoing_auth:

# Default behavior for backends without explicit config
default:
type: pass_through # pass_through | error
type: unauthenticated # unauthenticated | header_injection
# TODO: Uncomment when pass_through is implemented
# type: pass_through # Forward client token unchanged

# Per-backend authentication configurations
# IMPORTANT: These tokens are for backend APIs (e.g., github-api, jira-api),
# NOT for authenticating Virtual MCP to backend MCP servers.
# Backend MCP servers receive properly scoped tokens and use them to call upstream APIs.
backends:
# Example: API key authentication using header_injection
github:
type: token_exchange
token_exchange:
# RFC 8693 token exchange for GitHub API access
token_url: "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token"
client_id: "vmcp-github-exchange"
client_secret_env: "GITHUB_EXCHANGE_SECRET"
audience: "github-api" # Token audience for GitHub API
scopes: ["repo", "read:org"] # GitHub API scopes
subject_token_type: "access_token" # access_token | id_token

jira:
type: token_exchange
token_exchange:
token_url: "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token"
client_id: "vmcp-jira-exchange"
client_secret_env: "JIRA_EXCHANGE_SECRET"
audience: "jira-api" # Token audience for Jira API
scopes: ["read:jira-work", "write:jira-work"]

slack:
type: service_account
service_account:
credentials_env: "SLACK_BOT_TOKEN"
type: header_injection
header_injection:
header_name: "Authorization"
header_format: "Bearer {token}"

internal-db:
type: pass_through # Forward client token unchanged
header_value: "${GITHUB_API_TOKEN}" # Read from environment variable

# TODO: Uncomment when token_exchange strategy is implemented
# github:
# type: token_exchange
# metadata:
# # RFC 8693 token exchange for GitHub API access
# token_url: "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token"
# client_id: "vmcp-github-exchange"
# client_secret_env: "GITHUB_EXCHANGE_SECRET"
# audience: "github-api" # Token audience for GitHub API
# scopes: ["repo", "read:org"] # GitHub API scopes
# subject_token_type: "access_token" # access_token | id_token

# TODO: Uncomment when token_exchange strategy is implemented
# jira:
# type: token_exchange
# metadata:
# token_url: "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token"
# client_id: "vmcp-jira-exchange"
# client_secret_env: "JIRA_EXCHANGE_SECRET"
# audience: "jira-api" # Token audience for Jira API
# scopes: ["read:jira-work", "write:jira-work"]

# TODO: Uncomment when service_account strategy is implemented
# slack:
# type: service_account
# metadata:
# credentials_env: "SLACK_BOT_TOKEN"
# header_name: "Authorization"
# header_format: "Bearer {token}"

# TODO: Uncomment when pass_through strategy is implemented
# internal-db:
# type: pass_through # Forward client token unchanged

# ===== TOKEN CACHING =====
token_cache:
Expand Down
45 changes: 44 additions & 1 deletion pkg/vmcp/aggregator/cli_discoverer.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/stacklok/toolhive/pkg/groups"
"github.com/stacklok/toolhive/pkg/logger"
"github.com/stacklok/toolhive/pkg/vmcp"
"github.com/stacklok/toolhive/pkg/vmcp/config"
"github.com/stacklok/toolhive/pkg/workloads"
)

Expand All @@ -16,14 +17,23 @@ import (
type cliBackendDiscoverer struct {
workloadsManager workloads.Manager
groupsManager groups.Manager
authConfig *config.OutgoingAuthConfig
}

// NewCLIBackendDiscoverer creates a new CLI-based backend discoverer.
// It discovers workloads from Docker/Podman containers managed by ToolHive.
func NewCLIBackendDiscoverer(workloadsManager workloads.Manager, groupsManager groups.Manager) BackendDiscoverer {
//
// The authConfig parameter configures authentication for discovered backends.
// If nil, backends will have no authentication configured.
func NewCLIBackendDiscoverer(
workloadsManager workloads.Manager,
groupsManager groups.Manager,
authConfig *config.OutgoingAuthConfig,
) BackendDiscoverer {
return &cliBackendDiscoverer{
workloadsManager: workloadsManager,
groupsManager: groupsManager,
authConfig: authConfig,
}
}

Expand Down Expand Up @@ -92,6 +102,16 @@ func (d *cliBackendDiscoverer) Discover(ctx context.Context, groupRef string) ([
Metadata: make(map[string]string),
}

// Apply authentication configuration if provided
if d.authConfig != nil {
authStrategy, authMetadata := d.resolveAuthConfig(name)
backend.AuthStrategy = authStrategy
backend.AuthMetadata = authMetadata
if authStrategy != "" {
logger.Debugf("Backend %s configured with auth strategy: %s", name, authStrategy)
}
}

// Copy user labels to metadata first
for k, v := range workload.Labels {
backend.Metadata[k] = v
Expand All @@ -116,6 +136,29 @@ func (d *cliBackendDiscoverer) Discover(ctx context.Context, groupRef string) ([
return backends, nil
}

// resolveAuthConfig determines the authentication strategy and metadata for a backend.
// It checks for backend-specific configuration first, then falls back to default.
func (d *cliBackendDiscoverer) resolveAuthConfig(backendID string) (string, map[string]any) {
if d.authConfig == nil {
return "", nil
}

// Check for backend-specific configuration
if strategy, exists := d.authConfig.Backends[backendID]; exists && strategy != nil {
logger.Debugf("Using backend-specific auth strategy for %s: %s", backendID, strategy.Type)
return strategy.Type, strategy.Metadata
}

// Fall back to default configuration
if d.authConfig.Default != nil {
logger.Debugf("Using default auth strategy for %s: %s", backendID, d.authConfig.Default.Type)
return d.authConfig.Default.Type, d.authConfig.Default.Metadata
}

// No authentication configured
return "", nil
}

// mapWorkloadStatusToHealth converts a workload status to a backend health status.
func mapWorkloadStatusToHealth(status rt.WorkloadStatus) vmcp.BackendHealthStatus {
switch status {
Expand Down
18 changes: 9 additions & 9 deletions pkg/vmcp/aggregator/cli_discoverer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func TestCLIBackendDiscoverer_Discover(t *testing.T) {
mockWorkloads.EXPECT().GetWorkload(gomock.Any(), "workload1").Return(workload1, nil)
mockWorkloads.EXPECT().GetWorkload(gomock.Any(), "workload2").Return(workload2, nil)

discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups, nil)
backends, err := discoverer.Discover(context.Background(), testGroupName)

require.NoError(t, err)
Expand Down Expand Up @@ -79,7 +79,7 @@ func TestCLIBackendDiscoverer_Discover(t *testing.T) {
mockWorkloads.EXPECT().GetWorkload(gomock.Any(), "running-workload").Return(runningWorkload, nil)
mockWorkloads.EXPECT().GetWorkload(gomock.Any(), "stopped-workload").Return(stoppedWorkload, nil)

discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups, nil)
backends, err := discoverer.Discover(context.Background(), testGroupName)

require.NoError(t, err)
Expand Down Expand Up @@ -108,7 +108,7 @@ func TestCLIBackendDiscoverer_Discover(t *testing.T) {
mockWorkloads.EXPECT().GetWorkload(gomock.Any(), "workload1").Return(workloadWithURL, nil)
mockWorkloads.EXPECT().GetWorkload(gomock.Any(), "workload2").Return(workloadWithoutURL, nil)

discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups, nil)
backends, err := discoverer.Discover(context.Background(), testGroupName)

require.NoError(t, err)
Expand All @@ -133,7 +133,7 @@ func TestCLIBackendDiscoverer_Discover(t *testing.T) {
mockWorkloads.EXPECT().GetWorkload(gomock.Any(), "workload1").Return(workload1, nil)
mockWorkloads.EXPECT().GetWorkload(gomock.Any(), "workload2").Return(workload2, nil)

discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups, nil)
backends, err := discoverer.Discover(context.Background(), testGroupName)

require.NoError(t, err)
Expand All @@ -150,7 +150,7 @@ func TestCLIBackendDiscoverer_Discover(t *testing.T) {

mockGroups.EXPECT().Exists(gomock.Any(), "nonexistent-group").Return(false, nil)

discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups, nil)
backends, err := discoverer.Discover(context.Background(), "nonexistent-group")

require.Error(t, err)
Expand All @@ -168,7 +168,7 @@ func TestCLIBackendDiscoverer_Discover(t *testing.T) {

mockGroups.EXPECT().Exists(gomock.Any(), testGroupName).Return(false, errors.New("database error"))

discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups, nil)
backends, err := discoverer.Discover(context.Background(), testGroupName)

require.Error(t, err)
Expand All @@ -187,7 +187,7 @@ func TestCLIBackendDiscoverer_Discover(t *testing.T) {
mockGroups.EXPECT().Exists(gomock.Any(), "empty-group").Return(true, nil)
mockWorkloads.EXPECT().ListWorkloadsInGroup(gomock.Any(), "empty-group").Return([]string{}, nil)

discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups, nil)
backends, err := discoverer.Discover(context.Background(), "empty-group")

require.NoError(t, err)
Expand All @@ -214,7 +214,7 @@ func TestCLIBackendDiscoverer_Discover(t *testing.T) {
mockWorkloads.EXPECT().GetWorkload(gomock.Any(), "stopped1").Return(stoppedWorkload, nil)
mockWorkloads.EXPECT().GetWorkload(gomock.Any(), "error1").Return(errorWorkload, nil)

discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups, nil)
backends, err := discoverer.Discover(context.Background(), testGroupName)

require.NoError(t, err)
Expand All @@ -240,7 +240,7 @@ func TestCLIBackendDiscoverer_Discover(t *testing.T) {
mockWorkloads.EXPECT().GetWorkload(gomock.Any(), "failing-workload").
Return(core.Workload{}, errors.New("workload query failed"))

discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups, nil)
backends, err := discoverer.Discover(context.Background(), testGroupName)

require.NoError(t, err)
Expand Down
10 changes: 2 additions & 8 deletions pkg/vmcp/aggregator/default_aggregator.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,8 @@ func (a *defaultAggregator) QueryCapabilities(ctx context.Context, backend vmcp.
logger.Debugf("Querying capabilities from backend %s", backend.ID)

// Create a BackendTarget from the Backend
target := &vmcp.BackendTarget{
WorkloadID: backend.ID,
WorkloadName: backend.Name,
BaseURL: backend.BaseURL,
TransportType: backend.TransportType,
HealthStatus: backend.HealthStatus,
Metadata: backend.Metadata,
}
// Use BackendToTarget helper to ensure all fields (including auth) are copied
target := vmcp.BackendToTarget(&backend)

// Query capabilities using the backend client
capabilities, err := a.backendClient.ListCapabilities(ctx, target)
Expand Down
Loading
Loading