Skip to content

Commit 03302b2

Browse files
authored
Implement outgoing authentication strategies for vMCP (#2451)
* Reorganize incoming auth factory into subfolder to prevent an import cycle Move incoming authentication factory from pkg/vmcp/auth/ to pkg/vmcp/auth/factory/ subfolder to improve code organization. This separates factory code from core authentication types and middleware. * Refactor outgoing auth to separate registry from strategy Rename OutgoingAuthenticator to OutgoingAuthRegistry to better reflect its responsibility as a strategy registry rather than an authenticator. The interface now focuses solely on strategy management (registration and retrieval), while authentication is performed directly by Strategy implementations. This separation improves performance by eliminating indirection in the hot path (per-request authentication) and clarifies the single responsibility of each component: the registry manages strategies, strategies perform authentication. * Add factory package to resolve auth import cycle Introduces pkg/vmcp/auth/factory to break the circular dependency between pkg/vmcp/auth and pkg/vmcp/auth/strategies. The import cycle occurred because: - auth package needed to import strategies to instantiate them - strategies package imported auth for Identity and context helpers The factory package sits at the composition layer and can import both auth (for interfaces) and strategies (for implementations) without creating cycles. * Integrate authentication registry into HTTP backend client Refactors HTTPBackendClient to accept an OutgoingAuthRegistry and apply authentication strategies to all backend requests via a new authRoundTripper middleware. Authentication is now resolved and validated once at client creation time rather than per-request, improving performance and enabling early error detection for misconfigurations. The authRoundTripper clones requests to preserve immutability before applying authentication, ensuring thread-safety and preventing unintended side effects. * Apply auth configuration in backend discoverer The CLI backend discoverer now accepts authentication configuration and applies it to discovered backends during the discovery process. This change enables per-backend authentication by: - Adding authConfig parameter to NewCLIBackendDiscoverer constructor - Implementing resolveAuthConfig() to select backend-specific or default authentication settings with proper precedence - Populating Backend.AuthStrategy and Backend.AuthMetadata fields during backend creation Authentication configuration follows this precedence: 1. Backend-specific configuration (cfg.Backends[backendID]) 2. Default configuration (cfg.Default) 3. No authentication (if neither is configured) The populated authentication fields are later consumed when converting Backend instances to BackendTarget for use by the HTTP client's authRoundTripper. * Complete outgoing authentication integration in serve command Finalizes the end-to-end authentication flow by connecting the authentication factory, backend discoverer, and HTTP client in the serve command. This enables vMCP proxy to authenticate requests to downstream MCP servers using configured authentication strategies. The serve command now: - Creates outgoing authenticator from configuration using the factory - Provides authentication config to backend discoverer for setup - Supplies authenticator to HTTP client for request signing - Uses factory for incoming authentication middleware (consistency) This completes the authentication architecture where configuration flows through the factory to create strategies that are applied by the client's round tripper to outgoing requests. Also simplifies redundant type annotation in client variable declaration for consistency with Go style conventions. * Add explicit unauthenticated strategy for vMCP Replace the pattern of passing nil authenticators with an explicit UnauthenticatedStrategy that implements the Strategy interface as a no-op. This makes the intent clear in configuration and improves type safety by eliminating nil checks. The strategy is appropriate for backends on trusted networks or where authentication is handled at the network layer. Configuration now explicitly declares "strategy: unauthenticated" instead of relying on implicit nil behavior. * Implement HeaderInjection authentication strategy Add HeaderInjectionStrategy for injecting static header values into backend requests. This general-purpose strategy supports any HTTP header with any static value, enabling flexible authentication schemes like API keys, bearer tokens, and custom auth headers. The strategy extracts header_name and api_key from metadata configuration and validates them to prevent CRLF injection attacks using pkg/validation functions. Validation occurs at configuration time for fail-fast behavior. * Update validator to only accept implemented strategies Limit validTypes to strategies actually implemented in this PR: - unauthenticated - header_injection Comment out unimplemented strategies with TODO to add them as they are implemented in future PRs. This prevents accepting configuration for strategies that don't exist yet. * Update example configs to use implemented strategies Update example configuration files to use only implemented authentication strategies (unauthenticated and header_injection). Changes: - Replace pass_through with unauthenticated in defaults - Show header_injection example for backends - Comment out unimplemented strategies (pass_through, token_exchange, service_account) with TODOs - Add clear notes about which strategies are currently implemented This ensures example configs are valid and can be used immediately without validation errors. * Rename api_key to header_value in HeaderInjectionStrategy Rename the misleading 'api_key' field to 'header_value' to better reflect that this strategy can inject any HTTP header value, not just API keys. This improves semantic clarity and matches the general- purpose nature of the strategy. * Fix header_injection metadata parsing in YAML loader Add dedicated rawHeaderInjectionAuth struct and update transformBackendAuthStrategy to properly parse header_injection configuration from YAML files. Previously, the header_injection strategy used a generic metadata field in YAML, but the transform function had no case to handle it, resulting in empty metadata maps. This follows the established pattern used by token_exchange and service_account strategies. The YAML format is now consistent across all strategies: backends: github: type: header_injection header_injection: header_name: Authorization header_value: Bearer xxx * Fix auth loss when querying backend capabilities QueryCapabilities was manually creating BackendTarget but omitted AuthStrategy and AuthMetadata fields, causing all backends to fall back to unauthenticated strategy during capability queries. Replace manual struct creation with BackendToTarget() helper to ensure all fields (including auth) are properly copied from Backend to BackendTarget. This bug prevented per-backend authentication from working during the initial capability discovery phase, even though auth was correctly configured by the discoverer.
1 parent 9415772 commit 03302b2

28 files changed

+2928
-728
lines changed

cmd/vmcp/app/commands.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
"github.com/stacklok/toolhive/pkg/groups"
1313
"github.com/stacklok/toolhive/pkg/logger"
1414
"github.com/stacklok/toolhive/pkg/vmcp/aggregator"
15-
vmcpauth "github.com/stacklok/toolhive/pkg/vmcp/auth"
15+
"github.com/stacklok/toolhive/pkg/vmcp/auth/factory"
1616
vmcpclient "github.com/stacklok/toolhive/pkg/vmcp/client"
1717
"github.com/stacklok/toolhive/pkg/vmcp/config"
1818
vmcprouter "github.com/stacklok/toolhive/pkg/vmcp/router"
@@ -213,8 +213,15 @@ func runServe(cmd *cobra.Command, _ []string) error {
213213
return fmt.Errorf("failed to create groups manager: %w", err)
214214
}
215215

216+
// Create outgoing authentication registry from configuration
217+
logger.Info("Initializing outgoing authentication")
218+
outgoingRegistry, err := factory.NewOutgoingAuthRegistry(ctx, cfg.OutgoingAuth)
219+
if err != nil {
220+
return fmt.Errorf("failed to create outgoing authentication registry: %w", err)
221+
}
222+
216223
// Create backend discoverer
217-
discoverer := aggregator.NewCLIBackendDiscoverer(workloadsManager, groupsManager)
224+
discoverer := aggregator.NewCLIBackendDiscoverer(workloadsManager, groupsManager, cfg.OutgoingAuth)
218225

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

232239
// Create backend client
233-
backendClient := vmcpclient.NewHTTPBackendClient()
240+
backendClient, err := vmcpclient.NewHTTPBackendClient(outgoingRegistry)
241+
if err != nil {
242+
return fmt.Errorf("failed to create backend client: %w", err)
243+
}
234244

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

267-
authMiddleware, authInfoHandler, err := vmcpauth.NewIncomingAuthMiddleware(ctx, cfg.IncomingAuth)
277+
authMiddleware, authInfoHandler, err := factory.NewIncomingAuthMiddleware(ctx, cfg.IncomingAuth)
268278
if err != nil {
269279
return fmt.Errorf("failed to create authentication middleware: %w", err)
270280
}

cmd/vmcp/example-config.yaml

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,25 +34,34 @@ incoming_auth:
3434
# scopes: ["openid", "profile", "email"]
3535

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

4241
# Default behavior for backends without explicit config
4342
default:
44-
type: pass_through # Options: pass_through | token_exchange | service_account
43+
type: unauthenticated # Options: unauthenticated | header_injection
44+
# TODO: Uncomment when pass_through is implemented
45+
# type: pass_through
4546

46-
# Per-backend authentication (not yet implemented)
47+
# Per-backend authentication examples
4748
# backends:
49+
# # Example: API key authentication
4850
# github:
49-
# type: token_exchange
50-
# token_exchange:
51-
# token_url: "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token"
52-
# client_id: "vmcp-github-exchange"
53-
# client_secret_env: "GITHUB_EXCHANGE_SECRET"
54-
# audience: "github-api"
55-
# scopes: ["repo", "read:org"]
51+
# type: header_injection
52+
# header_injection:
53+
# header_name: "Authorization"
54+
# header_value: "${GITHUB_API_TOKEN}"
55+
#
56+
# # TODO: Uncomment when token_exchange is implemented
57+
# # jira:
58+
# # type: token_exchange
59+
# # metadata:
60+
# # token_url: "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token"
61+
# # client_id: "vmcp-github-exchange"
62+
# # client_secret_env: "GITHUB_EXCHANGE_SECRET"
63+
# # audience: "github-api"
64+
# # scopes: ["repo", "read:org"]
5665

5766
# ===== TOOL AGGREGATION =====
5867
aggregation:

examples/vmcp-config.yaml

Lines changed: 42 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ incoming_auth:
1313
client_id: "vmcp-client"
1414
client_secret_env: "VMCP_CLIENT_SECRET" # Read from environment variable
1515
audience: "vmcp" # Token must have aud=vmcp
16+
resource: "http://localhost:4483/mcp"
1617
scopes: ["openid", "profile", "email"]
1718

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

3435
# Default behavior for backends without explicit config
3536
default:
36-
type: pass_through # pass_through | error
37+
type: unauthenticated # unauthenticated | header_injection
38+
# TODO: Uncomment when pass_through is implemented
39+
# type: pass_through # Forward client token unchanged
3740

3841
# Per-backend authentication configurations
3942
# IMPORTANT: These tokens are for backend APIs (e.g., github-api, jira-api),
4043
# NOT for authenticating Virtual MCP to backend MCP servers.
4144
# Backend MCP servers receive properly scoped tokens and use them to call upstream APIs.
4245
backends:
46+
# Example: API key authentication using header_injection
4347
github:
44-
type: token_exchange
45-
token_exchange:
46-
# RFC 8693 token exchange for GitHub API access
47-
token_url: "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token"
48-
client_id: "vmcp-github-exchange"
49-
client_secret_env: "GITHUB_EXCHANGE_SECRET"
50-
audience: "github-api" # Token audience for GitHub API
51-
scopes: ["repo", "read:org"] # GitHub API scopes
52-
subject_token_type: "access_token" # access_token | id_token
53-
54-
jira:
55-
type: token_exchange
56-
token_exchange:
57-
token_url: "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token"
58-
client_id: "vmcp-jira-exchange"
59-
client_secret_env: "JIRA_EXCHANGE_SECRET"
60-
audience: "jira-api" # Token audience for Jira API
61-
scopes: ["read:jira-work", "write:jira-work"]
62-
63-
slack:
64-
type: service_account
65-
service_account:
66-
credentials_env: "SLACK_BOT_TOKEN"
48+
type: header_injection
49+
header_injection:
6750
header_name: "Authorization"
68-
header_format: "Bearer {token}"
69-
70-
internal-db:
71-
type: pass_through # Forward client token unchanged
51+
header_value: "${GITHUB_API_TOKEN}" # Read from environment variable
52+
53+
# TODO: Uncomment when token_exchange strategy is implemented
54+
# github:
55+
# type: token_exchange
56+
# metadata:
57+
# # RFC 8693 token exchange for GitHub API access
58+
# token_url: "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token"
59+
# client_id: "vmcp-github-exchange"
60+
# client_secret_env: "GITHUB_EXCHANGE_SECRET"
61+
# audience: "github-api" # Token audience for GitHub API
62+
# scopes: ["repo", "read:org"] # GitHub API scopes
63+
# subject_token_type: "access_token" # access_token | id_token
64+
65+
# TODO: Uncomment when token_exchange strategy is implemented
66+
# jira:
67+
# type: token_exchange
68+
# metadata:
69+
# token_url: "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token"
70+
# client_id: "vmcp-jira-exchange"
71+
# client_secret_env: "JIRA_EXCHANGE_SECRET"
72+
# audience: "jira-api" # Token audience for Jira API
73+
# scopes: ["read:jira-work", "write:jira-work"]
74+
75+
# TODO: Uncomment when service_account strategy is implemented
76+
# slack:
77+
# type: service_account
78+
# metadata:
79+
# credentials_env: "SLACK_BOT_TOKEN"
80+
# header_name: "Authorization"
81+
# header_format: "Bearer {token}"
82+
83+
# TODO: Uncomment when pass_through strategy is implemented
84+
# internal-db:
85+
# type: pass_through # Forward client token unchanged
7286

7387
# ===== TOKEN CACHING =====
7488
token_cache:

pkg/vmcp/aggregator/cli_discoverer.go

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/stacklok/toolhive/pkg/groups"
99
"github.com/stacklok/toolhive/pkg/logger"
1010
"github.com/stacklok/toolhive/pkg/vmcp"
11+
"github.com/stacklok/toolhive/pkg/vmcp/config"
1112
"github.com/stacklok/toolhive/pkg/workloads"
1213
)
1314

@@ -16,14 +17,23 @@ import (
1617
type cliBackendDiscoverer struct {
1718
workloadsManager workloads.Manager
1819
groupsManager groups.Manager
20+
authConfig *config.OutgoingAuthConfig
1921
}
2022

2123
// NewCLIBackendDiscoverer creates a new CLI-based backend discoverer.
2224
// It discovers workloads from Docker/Podman containers managed by ToolHive.
23-
func NewCLIBackendDiscoverer(workloadsManager workloads.Manager, groupsManager groups.Manager) BackendDiscoverer {
25+
//
26+
// The authConfig parameter configures authentication for discovered backends.
27+
// If nil, backends will have no authentication configured.
28+
func NewCLIBackendDiscoverer(
29+
workloadsManager workloads.Manager,
30+
groupsManager groups.Manager,
31+
authConfig *config.OutgoingAuthConfig,
32+
) BackendDiscoverer {
2433
return &cliBackendDiscoverer{
2534
workloadsManager: workloadsManager,
2635
groupsManager: groupsManager,
36+
authConfig: authConfig,
2737
}
2838
}
2939

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

105+
// Apply authentication configuration if provided
106+
if d.authConfig != nil {
107+
authStrategy, authMetadata := d.resolveAuthConfig(name)
108+
backend.AuthStrategy = authStrategy
109+
backend.AuthMetadata = authMetadata
110+
if authStrategy != "" {
111+
logger.Debugf("Backend %s configured with auth strategy: %s", name, authStrategy)
112+
}
113+
}
114+
95115
// Copy user labels to metadata first
96116
for k, v := range workload.Labels {
97117
backend.Metadata[k] = v
@@ -116,6 +136,29 @@ func (d *cliBackendDiscoverer) Discover(ctx context.Context, groupRef string) ([
116136
return backends, nil
117137
}
118138

139+
// resolveAuthConfig determines the authentication strategy and metadata for a backend.
140+
// It checks for backend-specific configuration first, then falls back to default.
141+
func (d *cliBackendDiscoverer) resolveAuthConfig(backendID string) (string, map[string]any) {
142+
if d.authConfig == nil {
143+
return "", nil
144+
}
145+
146+
// Check for backend-specific configuration
147+
if strategy, exists := d.authConfig.Backends[backendID]; exists && strategy != nil {
148+
logger.Debugf("Using backend-specific auth strategy for %s: %s", backendID, strategy.Type)
149+
return strategy.Type, strategy.Metadata
150+
}
151+
152+
// Fall back to default configuration
153+
if d.authConfig.Default != nil {
154+
logger.Debugf("Using default auth strategy for %s: %s", backendID, d.authConfig.Default.Type)
155+
return d.authConfig.Default.Type, d.authConfig.Default.Metadata
156+
}
157+
158+
// No authentication configured
159+
return "", nil
160+
}
161+
119162
// mapWorkloadStatusToHealth converts a workload status to a backend health status.
120163
func mapWorkloadStatusToHealth(status rt.WorkloadStatus) vmcp.BackendHealthStatus {
121164
switch status {

pkg/vmcp/aggregator/cli_discoverer_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func TestCLIBackendDiscoverer_Discover(t *testing.T) {
4545
mockWorkloads.EXPECT().GetWorkload(gomock.Any(), "workload1").Return(workload1, nil)
4646
mockWorkloads.EXPECT().GetWorkload(gomock.Any(), "workload2").Return(workload2, nil)
4747

48-
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
48+
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups, nil)
4949
backends, err := discoverer.Discover(context.Background(), testGroupName)
5050

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

82-
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
82+
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups, nil)
8383
backends, err := discoverer.Discover(context.Background(), testGroupName)
8484

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

111-
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
111+
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups, nil)
112112
backends, err := discoverer.Discover(context.Background(), testGroupName)
113113

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

136-
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
136+
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups, nil)
137137
backends, err := discoverer.Discover(context.Background(), testGroupName)
138138

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

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

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

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

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

171-
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
171+
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups, nil)
172172
backends, err := discoverer.Discover(context.Background(), testGroupName)
173173

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

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

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

217-
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
217+
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups, nil)
218218
backends, err := discoverer.Discover(context.Background(), testGroupName)
219219

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

243-
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
243+
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups, nil)
244244
backends, err := discoverer.Discover(context.Background(), testGroupName)
245245

246246
require.NoError(t, err)

pkg/vmcp/aggregator/default_aggregator.go

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,8 @@ func (a *defaultAggregator) QueryCapabilities(ctx context.Context, backend vmcp.
4949
logger.Debugf("Querying capabilities from backend %s", backend.ID)
5050

5151
// Create a BackendTarget from the Backend
52-
target := &vmcp.BackendTarget{
53-
WorkloadID: backend.ID,
54-
WorkloadName: backend.Name,
55-
BaseURL: backend.BaseURL,
56-
TransportType: backend.TransportType,
57-
HealthStatus: backend.HealthStatus,
58-
Metadata: backend.Metadata,
59-
}
52+
// Use BackendToTarget helper to ensure all fields (including auth) are copied
53+
target := vmcp.BackendToTarget(&backend)
6054

6155
// Query capabilities using the backend client
6256
capabilities, err := a.backendClient.ListCapabilities(ctx, target)

0 commit comments

Comments
 (0)