Skip to content

Commit d75c36e

Browse files
authored
Forward MCPServerEntry headerForward to vMCP outbound requests (#5239)
* Add wirefmt package and HeaderForwardConfig types The wirefmt package centralizes the env-var encoding shared between the operator (which emits TOOLHIVE_HEADER_FORWARD_<entry> manifests) and the vMCP runtime (which parses them). HeaderForwardConfig and the Backend / BackendTarget fields carry per-backend header forwarding state through the vMCP domain types. * Adopt wirefmt in MCPRemoteProxy controller Replace the local SecretEnvVarName helpers with the shared wirefmt encoder so the operator and vMCP runtime stay in lockstep on env-var naming. * Refactor externalauth helpers in operator controllerutil Surfaced while wiring headerForward through the MCPRemoteProxy and MCPServerEntry controllers. Tightens the helper contracts so callers in the new code paths share the same lookup signature. * Validate headerForward Secret refs on MCPServerEntry The headerForward field already exists on the MCPServerEntry CRD; this commit adds the reconciler validation that walks spec.headerForward.addHeadersFromSecret, confirms each referenced Secret exists in the namespace, and surfaces the result as a HeaderSecretRefsValidated status condition. Mirrors the validation MCPRemoteProxy already performs for its header Secret refs. * Emit headerForward env vars from VirtualMCPServer deployment The VirtualMCPServer reconciler now renders the entry-side headerForward manifest into the vMCP pod env via the wirefmt encoding. Plaintext values land directly; Secret-backed values become valueFrom.secretKeyRef so the runtime never sees raw secret material in CRD or pod spec. * Apply headerForward in vMCP client The HTTP client decorator injects per-backend headers (plaintext and Secret-resolved) on every outbound request: list, call, and health checks. Secret identifiers are resolved through the standard EnvironmentProvider, so the client never holds raw secret values. * Thread per-backend headerForward through aggregator, workloads, and CLI The static-mode discoverer now keys headerForward by normalized backend name and stamps each Backend with its config at discovery time. The Kubernetes workload discoverer surfaces the same field on managed entries, and the health monitor forwards it through to client calls. vMCP startup ingests the operator-emitted TOOLHIVE_HEADER_FORWARD_* env vars and routes the resulting per-backend map through serve into the discoverer.
1 parent ed8b00a commit d75c36e

25 files changed

Lines changed: 2082 additions & 102 deletions

cmd/thv-operator/api/v1beta1/mcpserverentry_types.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ const (
101101
// ConditionTypeMCPServerEntryRemoteURLValidated indicates whether the RemoteURL passes
102102
// format and SSRF safety checks.
103103
ConditionTypeMCPServerEntryRemoteURLValidated = "RemoteURLValidated"
104+
105+
// ConditionTypeMCPServerEntryHeaderSecretRefsValidated indicates whether every Kubernetes
106+
// Secret referenced by spec.headerForward.addHeadersFromSecret exists. Absent when the
107+
// entry declares no header Secret refs.
108+
ConditionTypeMCPServerEntryHeaderSecretRefsValidated = "HeaderSecretRefsValidated"
104109
)
105110

106111
// Condition reasons for MCPServerEntry.
@@ -146,6 +151,14 @@ const (
146151
// ConditionReasonMCPServerEntryRemoteURLInvalid indicates the RemoteURL is malformed or
147152
// targets a blocked internal/metadata endpoint.
148153
ConditionReasonMCPServerEntryRemoteURLInvalid = ConditionReasonRemoteURLInvalid
154+
155+
// ConditionReasonMCPServerEntryHeaderSecretsValid indicates every referenced header Secret exists.
156+
ConditionReasonMCPServerEntryHeaderSecretsValid = "HeaderSecretsValid"
157+
158+
// ConditionReasonMCPServerEntryHeaderSecretNotFound indicates a Secret referenced by
159+
// spec.headerForward.addHeadersFromSecret was not found in the entry's namespace.
160+
// Reuses the string value used by MCPRemoteProxy for parity.
161+
ConditionReasonMCPServerEntryHeaderSecretNotFound = ConditionReasonHeaderSecretNotFound
149162
)
150163

151164
//+kubebuilder:object:root=true

cmd/thv-operator/controllers/mcpremoteproxy_deployment.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil"
1919
"github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum"
2020
"github.com/stacklok/toolhive/pkg/container/kubernetes"
21+
"github.com/stacklok/toolhive/pkg/vmcp/headerforward/wirefmt"
2122
)
2223

2324
// deploymentForMCPRemoteProxy returns a MCPRemoteProxy Deployment object
@@ -278,7 +279,7 @@ func buildHeaderForwardSecretEnvVars(proxy *mcpv1beta1.MCPRemoteProxy) []corev1.
278279
}
279280

280281
// Generate env var name following the TOOLHIVE_SECRET_ pattern
281-
envVarName, _ := ctrlutil.GenerateHeaderForwardSecretEnvVarName(proxy.Name, headerSecret.HeaderName)
282+
envVarName, _ := wirefmt.SecretEnvVarName(proxy.Name, headerSecret.HeaderName)
282283

283284
envVars = append(envVars, corev1.EnvVar{
284285
Name: envVarName,

cmd/thv-operator/controllers/mcpremoteproxy_runconfig.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum"
2222
"github.com/stacklok/toolhive/pkg/runner"
2323
transporttypes "github.com/stacklok/toolhive/pkg/transport/types"
24+
"github.com/stacklok/toolhive/pkg/vmcp/headerforward/wirefmt"
2425
)
2526

2627
// ensureRunConfigConfigMap ensures the RunConfig ConfigMap exists and is up to date for MCPRemoteProxy
@@ -291,7 +292,7 @@ func addHeaderForwardConfigOptions(proxy *mcpv1beta1.MCPRemoteProxy, options *[]
291292
continue
292293
}
293294
// Get the secret identifier (not the full env var name)
294-
_, secretIdentifier := ctrlutil.GenerateHeaderForwardSecretEnvVarName(proxy.Name, headerSecret.HeaderName)
295+
_, secretIdentifier := wirefmt.SecretEnvVarName(proxy.Name, headerSecret.HeaderName)
295296
headerSecrets[headerSecret.HeaderName] = secretIdentifier
296297
}
297298
*options = append(*options, runner.WithHeaderForwardSecrets(headerSecrets))

cmd/thv-operator/controllers/mcpserverentry_controller.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package controllers
66
import (
77
"context"
88
"fmt"
9+
"strings"
910
"time"
1011

1112
corev1 "k8s.io/api/core/v1"
@@ -32,6 +33,10 @@ const (
3233

3334
// mcpServerEntryCABundleRefField is the field index key for CABundleRef ConfigMap lookups.
3435
mcpServerEntryCABundleRefField = "spec.caBundleRef.configMapRef.name"
36+
37+
// mcpServerEntryHeaderSecretRefField is the field index key for
38+
// spec.headerForward.addHeadersFromSecret[*].valueSecretRef.name lookups.
39+
mcpServerEntryHeaderSecretRefField = "spec.headerForward.addHeadersFromSecret.valueSecretRef.name"
3540
)
3641

3742
// MCPServerEntryReconciler reconciles a MCPServerEntry object.
@@ -46,6 +51,7 @@ type MCPServerEntryReconciler struct {
4651
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpgroups,verbs=get;list;watch
4752
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpexternalauthconfigs,verbs=get;list;watch
4853
// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch
54+
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch
4955

5056
// Reconcile validates referenced resources and updates status conditions.
5157
func (r *MCPServerEntryReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
@@ -85,6 +91,12 @@ func (r *MCPServerEntryReconciler) Reconcile(ctx context.Context, req ctrl.Reque
8591
}
8692
allValid = valid && allValid
8793

94+
valid, err = r.validateHeaderForwardSecretRefs(ctx, entry)
95+
if err != nil {
96+
return ctrl.Result{}, err
97+
}
98+
allValid = valid && allValid
99+
88100
// Compute overall phase and Valid condition
89101
r.updateOverallStatus(entry, allValid)
90102

@@ -137,6 +149,38 @@ func (r *MCPServerEntryReconciler) SetupWithManager(mgr ctrl.Manager) error {
137149
mcpServerEntryCABundleRefField, err)
138150
}
139151

152+
// Set up field index for headerForward Secret refs. Used by the Secret
153+
// watch below so a Secret create/update/delete reconciles every entry that
154+
// references it — without this index we would have to list all entries on
155+
// every Secret event.
156+
if err := mgr.GetFieldIndexer().IndexField(
157+
context.Background(),
158+
&mcpv1beta1.MCPServerEntry{},
159+
mcpServerEntryHeaderSecretRefField,
160+
func(obj client.Object) []string {
161+
entry := obj.(*mcpv1beta1.MCPServerEntry)
162+
if entry.Spec.HeaderForward == nil {
163+
return nil
164+
}
165+
seen := make(map[string]struct{}, len(entry.Spec.HeaderForward.AddHeadersFromSecret))
166+
names := make([]string, 0, len(entry.Spec.HeaderForward.AddHeadersFromSecret))
167+
for _, ref := range entry.Spec.HeaderForward.AddHeadersFromSecret {
168+
if ref.ValueSecretRef == nil {
169+
continue
170+
}
171+
if _, ok := seen[ref.ValueSecretRef.Name]; ok {
172+
continue
173+
}
174+
seen[ref.ValueSecretRef.Name] = struct{}{}
175+
names = append(names, ref.ValueSecretRef.Name)
176+
}
177+
return names
178+
},
179+
); err != nil {
180+
return fmt.Errorf("unable to create field index for MCPServerEntry %s: %w",
181+
mcpServerEntryHeaderSecretRefField, err)
182+
}
183+
140184
return ctrl.NewControllerManagedBy(mgr).
141185
For(&mcpv1beta1.MCPServerEntry{}).
142186
Watches(
@@ -151,6 +195,10 @@ func (r *MCPServerEntryReconciler) SetupWithManager(mgr ctrl.Manager) error {
151195
&corev1.ConfigMap{},
152196
handler.EnqueueRequestsFromMapFunc(r.findEntriesForConfigMap),
153197
).
198+
Watches(
199+
&corev1.Secret{},
200+
handler.EnqueueRequestsFromMapFunc(r.findEntriesForHeaderSecret),
201+
).
154202
Complete(r)
155203
}
156204

@@ -300,6 +348,81 @@ func (r *MCPServerEntryReconciler) validateCABundleRef(
300348
return true, nil
301349
}
302350

351+
// validateHeaderForwardSecretRefs checks that every Secret referenced by
352+
// spec.headerForward.addHeadersFromSecret exists AND that the named key
353+
// inside it is present in the entry's namespace. Skipped (condition removed)
354+
// when no Secret-backed headers are declared.
355+
//
356+
// All ref-level failures are aggregated into one condition message — a user
357+
// fixing two missing secrets at once should see both surface together rather
358+
// than one-per-reconcile.
359+
//
360+
// Returns (valid, error) where a non-nil error means a transient API
361+
// failure that should be requeued. Key-not-found and Secret-not-found are
362+
// surfaced via the condition (valid=false, err=nil).
363+
func (r *MCPServerEntryReconciler) validateHeaderForwardSecretRefs(
364+
ctx context.Context,
365+
entry *mcpv1beta1.MCPServerEntry,
366+
) (bool, error) {
367+
ctxLogger := log.FromContext(ctx)
368+
369+
if entry.Spec.HeaderForward == nil || len(entry.Spec.HeaderForward.AddHeadersFromSecret) == 0 {
370+
meta.RemoveStatusCondition(&entry.Status.Conditions,
371+
mcpv1beta1.ConditionTypeMCPServerEntryHeaderSecretRefsValidated)
372+
return true, nil
373+
}
374+
375+
var failures []string
376+
for _, ref := range entry.Spec.HeaderForward.AddHeadersFromSecret {
377+
if ref.ValueSecretRef == nil {
378+
continue
379+
}
380+
secret := &corev1.Secret{}
381+
secretKey := types.NamespacedName{
382+
Namespace: entry.Namespace,
383+
Name: ref.ValueSecretRef.Name,
384+
}
385+
if err := r.Get(ctx, secretKey, secret); err != nil {
386+
if errors.IsNotFound(err) {
387+
failures = append(failures, fmt.Sprintf(
388+
"Secret %q referenced for header %q not found",
389+
ref.ValueSecretRef.Name, ref.HeaderName))
390+
continue
391+
}
392+
// Transient API failure (rate-limit, network, etc.) — requeue
393+
// rather than condition-flip. The next reconcile will re-validate.
394+
ctxLogger.Error(err, "Failed to get referenced header Secret",
395+
"secret", ref.ValueSecretRef.Name, "header", ref.HeaderName)
396+
return false, err
397+
}
398+
if _, ok := secret.Data[ref.ValueSecretRef.Key]; !ok {
399+
failures = append(failures, fmt.Sprintf(
400+
"Secret %q has no key %q referenced for header %q",
401+
ref.ValueSecretRef.Name, ref.ValueSecretRef.Key, ref.HeaderName))
402+
}
403+
}
404+
405+
if len(failures) > 0 {
406+
meta.SetStatusCondition(&entry.Status.Conditions, metav1.Condition{
407+
Type: mcpv1beta1.ConditionTypeMCPServerEntryHeaderSecretRefsValidated,
408+
Status: metav1.ConditionFalse,
409+
Reason: mcpv1beta1.ConditionReasonMCPServerEntryHeaderSecretNotFound,
410+
Message: strings.Join(failures, "; "),
411+
ObservedGeneration: entry.Generation,
412+
})
413+
return false, nil
414+
}
415+
416+
meta.SetStatusCondition(&entry.Status.Conditions, metav1.Condition{
417+
Type: mcpv1beta1.ConditionTypeMCPServerEntryHeaderSecretRefsValidated,
418+
Status: metav1.ConditionTrue,
419+
Reason: mcpv1beta1.ConditionReasonMCPServerEntryHeaderSecretsValid,
420+
Message: "All referenced header Secrets exist with required keys",
421+
ObservedGeneration: entry.Generation,
422+
})
423+
return true, nil
424+
}
425+
303426
// validateRemoteURL checks that the RemoteURL is well-formed and does not target
304427
// a blocked internal or metadata endpoint (SSRF protection).
305428
func (*MCPServerEntryReconciler) validateRemoteURL(
@@ -421,6 +544,44 @@ func (r *MCPServerEntryReconciler) findEntriesForGroup(
421544
return requests
422545
}
423546

547+
// findEntriesForHeaderSecret maps Secret changes to MCPServerEntry reconcile
548+
// requests for entries that reference the Secret in
549+
// spec.headerForward.addHeadersFromSecret. This ensures that creating the
550+
// referenced Secret (or rotating its contents) flips the validation condition
551+
// from false → true within one reconcile, instead of waiting for the resync.
552+
func (r *MCPServerEntryReconciler) findEntriesForHeaderSecret(
553+
ctx context.Context,
554+
obj client.Object,
555+
) []reconcile.Request {
556+
ctxLogger := log.FromContext(ctx)
557+
558+
secret, ok := obj.(*corev1.Secret)
559+
if !ok {
560+
ctxLogger.Error(nil, "Object is not a Secret", "object", obj.GetName())
561+
return nil
562+
}
563+
564+
entryList := &mcpv1beta1.MCPServerEntryList{}
565+
if err := r.List(ctx, entryList,
566+
client.InNamespace(secret.Namespace),
567+
client.MatchingFields{mcpServerEntryHeaderSecretRefField: secret.Name},
568+
); err != nil {
569+
ctxLogger.Error(err, "Failed to list MCPServerEntries for header Secret change")
570+
return nil
571+
}
572+
573+
requests := make([]reconcile.Request, len(entryList.Items))
574+
for i, entry := range entryList.Items {
575+
requests[i] = reconcile.Request{
576+
NamespacedName: types.NamespacedName{
577+
Namespace: entry.Namespace,
578+
Name: entry.Name,
579+
},
580+
}
581+
}
582+
return requests
583+
}
584+
424585
// findEntriesForConfigMap maps ConfigMap changes to MCPServerEntry reconcile requests
425586
// for entries that reference the ConfigMap as a CA bundle.
426587
func (r *MCPServerEntryReconciler) findEntriesForConfigMap(

0 commit comments

Comments
 (0)