Skip to content
32 changes: 31 additions & 1 deletion cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type HeaderFromSecret struct {
// MCPRemoteProxySpec defines the desired state of MCPRemoteProxy
//
// +kubebuilder:validation:XValidation:rule="!(has(self.oidcConfig) && has(self.oidcConfigRef))",message="oidcConfig and oidcConfigRef are mutually exclusive; use oidcConfigRef to reference a shared MCPOIDCConfig"
// +kubebuilder:validation:XValidation:rule="!(has(self.telemetry) && has(self.telemetryConfigRef))",message="telemetry and telemetryConfigRef are mutually exclusive; migrate to telemetryConfigRef"
//
//nolint:lll // CEL validation rules exceed line length limit
type MCPRemoteProxySpec struct {
Expand Down Expand Up @@ -102,7 +103,17 @@ type MCPRemoteProxySpec struct {
// +optional
ToolConfigRef *ToolConfigRef `json:"toolConfigRef,omitempty"`

// Telemetry defines observability configuration for the proxy
// TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.
// The referenced MCPTelemetryConfig must exist in the same namespace as this MCPRemoteProxy.
// Cross-namespace references are not supported for security and isolation reasons.
// Mutually exclusive with the deprecated inline Telemetry field.
// +optional
TelemetryConfigRef *MCPTelemetryConfigReference `json:"telemetryConfigRef,omitempty"`

// Telemetry defines inline observability configuration for the proxy.
// Deprecated: Use TelemetryConfigRef to reference a shared MCPTelemetryConfig resource instead.
// This field will be removed in a future release. Setting both telemetry and telemetryConfigRef
// is rejected by CEL validation.
// +optional
Telemetry *TelemetryConfig `json:"telemetry,omitempty"`

Expand Down Expand Up @@ -174,6 +185,10 @@ type MCPRemoteProxyStatus struct {
// +optional
ToolConfigHash string `json:"toolConfigHash,omitempty"`

// TelemetryConfigHash stores the hash of the referenced MCPTelemetryConfig for change detection
// +optional
TelemetryConfigHash string `json:"telemetryConfigHash,omitempty"`

// ExternalAuthConfigHash is the hash of the referenced MCPExternalAuthConfig spec
// +optional
ExternalAuthConfigHash string `json:"externalAuthConfigHash,omitempty"`
Expand Down Expand Up @@ -227,6 +242,9 @@ const (
// ConditionTypeMCPRemoteProxyToolConfigValidated indicates whether the ToolConfigRef is valid
ConditionTypeMCPRemoteProxyToolConfigValidated = "ToolConfigValidated"

// ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated indicates whether the TelemetryConfigRef is valid
ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated = "TelemetryConfigRefValidated"

// ConditionTypeMCPRemoteProxyExternalAuthConfigValidated indicates whether the ExternalAuthConfigRef is valid
ConditionTypeMCPRemoteProxyExternalAuthConfigValidated = "ExternalAuthConfigValidated"

Expand Down Expand Up @@ -278,6 +296,18 @@ const (
// ConditionReasonMCPRemoteProxyToolConfigFetchError indicates an error occurred fetching the MCPToolConfig
ConditionReasonMCPRemoteProxyToolConfigFetchError = "ToolConfigFetchError"

// ConditionReasonMCPRemoteProxyTelemetryConfigRefValid indicates the TelemetryConfigRef is valid
ConditionReasonMCPRemoteProxyTelemetryConfigRefValid = "TelemetryConfigRefValid"

// ConditionReasonMCPRemoteProxyTelemetryConfigRefNotFound indicates the referenced MCPTelemetryConfig was not found
ConditionReasonMCPRemoteProxyTelemetryConfigRefNotFound = "TelemetryConfigRefNotFound"

// ConditionReasonMCPRemoteProxyTelemetryConfigRefInvalid indicates the referenced MCPTelemetryConfig is invalid
ConditionReasonMCPRemoteProxyTelemetryConfigRefInvalid = "TelemetryConfigRefInvalid"

// ConditionReasonMCPRemoteProxyTelemetryConfigRefFetchError indicates an error occurred fetching the MCPTelemetryConfig
ConditionReasonMCPRemoteProxyTelemetryConfigRefFetchError = "TelemetryConfigRefFetchError"

// ConditionReasonMCPRemoteProxyExternalAuthConfigValid indicates the ExternalAuthConfigRef is valid
ConditionReasonMCPRemoteProxyExternalAuthConfigValid = "ExternalAuthConfigValid"

Expand Down
5 changes: 5 additions & 0 deletions cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go

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

137 changes: 137 additions & 0 deletions cmd/thv-operator/controllers/mcpremoteproxy_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type MCPRemoteProxyReconciler struct {
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcptoolconfigs,verbs=get;list;watch
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpexternalauthconfigs,verbs=get;list;watch
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpoidcconfigs,verbs=get;list;watch
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcptelemetryconfigs,verbs=get;list;watch
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpoidcconfigs/status,verbs=get;update;patch
// +kubebuilder:rbac:groups="",resources=configmaps,verbs=create;delete;get;list;patch;update;watch
// +kubebuilder:rbac:groups="",resources=services,verbs=create;delete;get;list;patch;update;watch
Expand Down Expand Up @@ -128,6 +129,16 @@ func (r *MCPRemoteProxyReconciler) validateAndHandleConfigs(ctx context.Context,
return err
}

// Handle MCPTelemetryConfig
if err := r.handleTelemetryConfig(ctx, proxy); err != nil {
ctxLogger.Error(err, "Failed to handle MCPTelemetryConfig")
proxy.Status.Phase = mcpv1alpha1.MCPRemoteProxyPhaseFailed
if statusErr := r.Status().Update(ctx, proxy); statusErr != nil {
ctxLogger.Error(statusErr, "Failed to update MCPRemoteProxy status after MCPTelemetryConfig error")
}
return err
}

// Handle MCPExternalAuthConfig
if err := r.handleExternalAuthConfig(ctx, proxy); err != nil {
ctxLogger.Error(err, "Failed to handle MCPExternalAuthConfig")
Expand Down Expand Up @@ -641,6 +652,97 @@ func (r *MCPRemoteProxyReconciler) handleToolConfig(ctx context.Context, proxy *
return nil
}

// handleTelemetryConfig validates and tracks the hash of the referenced MCPTelemetryConfig.
// It updates the MCPRemoteProxy status when the telemetry configuration changes.
func (r *MCPRemoteProxyReconciler) handleTelemetryConfig(ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy) error {
ctxLogger := log.FromContext(ctx)

if proxy.Spec.TelemetryConfigRef == nil {
// No MCPTelemetryConfig referenced, clear any stored hash and condition.
condType := mcpv1alpha1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated
condRemoved := meta.FindStatusCondition(proxy.Status.Conditions, condType) != nil
meta.RemoveStatusCondition(&proxy.Status.Conditions, condType)
if condRemoved || proxy.Status.TelemetryConfigHash != "" {
proxy.Status.TelemetryConfigHash = ""
if err := r.Status().Update(ctx, proxy); err != nil {
return fmt.Errorf("failed to clear MCPTelemetryConfig hash from status: %w", err)
}
}
return nil
}

// Get the referenced MCPTelemetryConfig
telemetryConfig, err := ctrlutil.GetTelemetryConfigForMCPRemoteProxy(ctx, r.Client, proxy)
if err != nil {
// Transient API error (not a NotFound)
meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{
Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated,
Status: metav1.ConditionFalse,
Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyTelemetryConfigRefFetchError,
Message: err.Error(),
ObservedGeneration: proxy.Generation,
})
return err
}

if telemetryConfig == nil {
// Resource genuinely does not exist
meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{
Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated,
Status: metav1.ConditionFalse,
Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyTelemetryConfigRefNotFound,
Message: fmt.Sprintf("MCPTelemetryConfig %s not found", proxy.Spec.TelemetryConfigRef.Name),
ObservedGeneration: proxy.Generation,
})
return fmt.Errorf("MCPTelemetryConfig %s not found", proxy.Spec.TelemetryConfigRef.Name)
}

// Validate that the MCPTelemetryConfig is valid (has Valid=True condition)
if err := telemetryConfig.Validate(); err != nil {
meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{
Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated,
Status: metav1.ConditionFalse,
Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyTelemetryConfigRefInvalid,
Message: fmt.Sprintf("MCPTelemetryConfig %s is invalid: %v", proxy.Spec.TelemetryConfigRef.Name, err),
ObservedGeneration: proxy.Generation,
})
return fmt.Errorf("MCPTelemetryConfig %s is invalid: %w", proxy.Spec.TelemetryConfigRef.Name, err)
}

// Detect whether the condition is transitioning to True (e.g. recovering from
// a transient error). Without this check the status update is skipped when the
// hash is unchanged, leaving a stale False condition.
condType := mcpv1alpha1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated
prevCondition := meta.FindStatusCondition(proxy.Status.Conditions, condType)
needsUpdate := prevCondition == nil || prevCondition.Status != metav1.ConditionTrue

meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{
Type: mcpv1alpha1.ConditionTypeMCPRemoteProxyTelemetryConfigRefValidated,
Status: metav1.ConditionTrue,
Reason: mcpv1alpha1.ConditionReasonMCPRemoteProxyTelemetryConfigRefValid,
Message: fmt.Sprintf("MCPTelemetryConfig %s is valid", proxy.Spec.TelemetryConfigRef.Name),
ObservedGeneration: proxy.Generation,
})

if proxy.Status.TelemetryConfigHash != telemetryConfig.Status.ConfigHash {
ctxLogger.Info("MCPTelemetryConfig has changed, updating MCPRemoteProxy",
"proxy", proxy.Name,
"telemetryConfig", telemetryConfig.Name,
"oldHash", proxy.Status.TelemetryConfigHash,
"newHash", telemetryConfig.Status.ConfigHash)
proxy.Status.TelemetryConfigHash = telemetryConfig.Status.ConfigHash
needsUpdate = true
}

if needsUpdate {
if err := r.Status().Update(ctx, proxy); err != nil {
return fmt.Errorf("failed to update MCPTelemetryConfig status: %w", err)
}
}

return nil
}

// handleExternalAuthConfig validates and tracks the hash of the referenced MCPExternalAuthConfig
func (r *MCPRemoteProxyReconciler) handleExternalAuthConfig(ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy) error {
ctxLogger := log.FromContext(ctx)
Expand Down Expand Up @@ -1390,6 +1492,37 @@ func (r *MCPRemoteProxyReconciler) mapOIDCConfigToMCPRemoteProxy(
return requests
}

// mapTelemetryConfigToMCPRemoteProxy maps MCPTelemetryConfig changes to MCPRemoteProxy reconciliation requests.
func (r *MCPRemoteProxyReconciler) mapTelemetryConfigToMCPRemoteProxy(
ctx context.Context, obj client.Object,
) []reconcile.Request {
telemetryConfig, ok := obj.(*mcpv1alpha1.MCPTelemetryConfig)
if !ok {
return nil
}

proxyList := &mcpv1alpha1.MCPRemoteProxyList{}
if err := r.List(ctx, proxyList, client.InNamespace(telemetryConfig.Namespace)); err != nil {
log.FromContext(ctx).Error(err, "Failed to list MCPRemoteProxies for MCPTelemetryConfig watch")
return nil
}

var requests []reconcile.Request
for _, proxy := range proxyList.Items {
if proxy.Spec.TelemetryConfigRef != nil &&
proxy.Spec.TelemetryConfigRef.Name == telemetryConfig.Name {
requests = append(requests, reconcile.Request{
NamespacedName: types.NamespacedName{
Name: proxy.Name,
Namespace: proxy.Namespace,
},
})
}
}

return requests
}

// SetupWithManager sets up the controller with the Manager
func (r *MCPRemoteProxyReconciler) SetupWithManager(mgr ctrl.Manager) error {
// Create a handler that maps MCPExternalAuthConfig changes to MCPRemoteProxy reconciliation requests
Expand Down Expand Up @@ -1470,5 +1603,9 @@ func (r *MCPRemoteProxyReconciler) SetupWithManager(mgr ctrl.Manager) error {
&mcpv1alpha1.MCPOIDCConfig{},
handler.EnqueueRequestsFromMapFunc(r.mapOIDCConfigToMCPRemoteProxy),
).
Watches(
&mcpv1alpha1.MCPTelemetryConfig{},
handler.EnqueueRequestsFromMapFunc(r.mapTelemetryConfigToMCPRemoteProxy),
).
Complete(r)
}
24 changes: 24 additions & 0 deletions cmd/thv-operator/controllers/mcpremoteproxy_deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func (r *MCPRemoteProxyReconciler) deploymentForMCPRemoteProxy(
// Build deployment components using helper functions
args := r.buildContainerArgs()
volumeMounts, volumes := r.buildVolumesForProxy(proxy)
r.addTelemetryCABundleVolumes(ctx, proxy, &volumes, &volumeMounts)
env := r.buildEnvVarsForProxy(ctx, proxy)

// Add embedded auth server volumes and env vars. AuthServerRef takes precedence;
Expand Down Expand Up @@ -143,6 +144,29 @@ func (*MCPRemoteProxyReconciler) buildVolumesForProxy(
return volumeMounts, volumes
}

// addTelemetryCABundleVolumes appends CA bundle volumes for the referenced MCPTelemetryConfig.
// Must be called from deploymentForMCPRemoteProxy where the client is available.
func (r *MCPRemoteProxyReconciler) addTelemetryCABundleVolumes(
ctx context.Context,
proxy *mcpv1alpha1.MCPRemoteProxy,
volumes *[]corev1.Volume,
volumeMounts *[]corev1.VolumeMount,
) {
if proxy.Spec.TelemetryConfigRef == nil {
return
}
telCfg, err := ctrlutil.GetTelemetryConfigForMCPRemoteProxy(ctx, r.Client, proxy)
if err != nil {
log.FromContext(ctx).Error(err, "Failed to fetch MCPTelemetryConfig for CA bundle volume")
return
}
if telCfg != nil {
caVolumes, caMounts := ctrlutil.AddTelemetryCABundleVolumes(telCfg)
*volumes = append(*volumes, caVolumes...)
*volumeMounts = append(*volumeMounts, caMounts...)
}
}

// buildEnvVarsForProxy builds environment variables for the proxy container
func (r *MCPRemoteProxyReconciler) buildEnvVarsForProxy(
ctx context.Context, proxy *mcpv1alpha1.MCPRemoteProxy,
Expand Down
Loading
Loading