Skip to content
Closed
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
7 changes: 7 additions & 0 deletions go/api/v1alpha2/modelconfig_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,13 @@ type TLSConfig struct {
// +optional
CACertSecretKey string `json:"caCertSecretKey,omitempty"`

// ClientSecretRef is a reference to a Kubernetes Secret containing
// the client certificate (tls.crt), key (tls.key), and optionally
// the CA certificate (ca.crt) for mTLS authentication.
// The Secret must be in the same namespace as the MCPServer/RemoteMCPServer.
// +optional
ClientSecretRef string `json:"clientSecretRef,omitempty"`

// DisableSystemCAs disables the use of system CA certificates.
// When false (default), system CA certificates are used for verification (safe behavior).
// When true, only the custom CA from CACertSecretRef is trusted.
Expand Down
2 changes: 2 additions & 0 deletions go/api/v1alpha2/remotemcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ type RemoteMCPServerSpec struct {
// See: https://gateway-api.sigs.k8s.io/guides/multiple-ns/#cross-namespace-routing
// +optional
AllowedNamespaces *AllowedNamespaces `json:"allowedNamespaces,omitempty"`
// +optional
TLS *TLSConfig `json:"tls,omitempty"`
}

var _ sql.Scanner = (*RemoteMCPServerSpec)(nil)
Expand Down
5 changes: 5 additions & 0 deletions go/api/v1alpha2/zz_generated.deepcopy.go

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

106 changes: 101 additions & 5 deletions go/internal/controller/reconciler/reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ package reconciler
import (
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/url"
"reflect"
"slices"
"strings"
Expand Down Expand Up @@ -815,18 +818,36 @@ func (a *kagentReconciler) createMcpTransport(ctx context.Context, s *v1alpha2.R
return nil, err
}

httpClient := newHTTPClient(headers)
client := newHTTPClient(headers)
serverURL := s.Spec.URL
if s.Spec.TLS != nil {
// Convert URL to https if TLS is configured and scheme is not explicitly set
parsedURL, err := url.Parse(serverURL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
if parsedURL.Scheme == "" || parsedURL.Scheme == "http" {
Comment on lines +824 to +829
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The url.Parse + parsedURL.Scheme == "" branch can produce invalid URLs when the input is missing a scheme (e.g., parsing kyverno-mcp.default:8000/mcp treats it as a path, and setting Scheme="https" yields https:kyverno-mcp...). If you want to support scheme-less URLs, prepend https:// (or // + host) rather than mutating a parsed path-only URL.

Suggested change
// Convert URL to https if TLS is configured and scheme is not explicitly set
parsedURL, err := url.Parse(serverURL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
if parsedURL.Scheme == "" || parsedURL.Scheme == "http" {
// Normalize URL for TLS: if no scheme is provided, assume https.
// This avoids parsing a host:port/path value as a path-only URL.
if !strings.Contains(serverURL, "://") {
serverURL = "https://" + serverURL
}
parsedURL, err := url.Parse(serverURL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
if parsedURL.Scheme == "http" {

Copilot uses AI. Check for mistakes.
parsedURL.Scheme = "https"
serverURL = parsedURL.String()
}

// Create HTTP client with TLS configuration
client, err = a.createTLSHTTPClient(ctx, s.Spec.TLS, s.Namespace)
if err != nil {
return nil, fmt.Errorf("failed to create TLS HTTP client: %w", err)
}
}
Comment on lines +821 to +839
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When TLS is configured, createMcpTransport replaces the header-injecting client from newHTTPClient(headers) with the TLS client from createTLSHTTPClient(...), which drops the resolved headers entirely. Consider composing the transports (e.g., wrap the TLS transport with headerTransport, or accept headers in createTLSHTTPClient and install a headerTransport{base: tlsTransport}), so TLS-enabled MCP calls still include the configured headers.

Copilot uses AI. Check for mistakes.

switch s.Spec.Protocol {
case v1alpha2.RemoteMCPServerProtocolSse:
return &mcp.SSEClientTransport{
Endpoint: s.Spec.URL,
HTTPClient: httpClient,
Endpoint: serverURL,
HTTPClient: client,
}, nil
default:
return &mcp.StreamableClientTransport{
Endpoint: s.Spec.URL,
HTTPClient: httpClient,
Endpoint: serverURL,
HTTPClient: client,
}, nil
}
}
Expand All @@ -845,6 +866,81 @@ func newHTTPClient(headers map[string]string) *http.Client {
}
}

// createTLSHTTPClient creates an HTTP client with TLS configuration including client certificates.
// It reads certificates from the Kubernetes Secret specified in MCPServerTLS.
func (a *kagentReconciler) createTLSHTTPClient(ctx context.Context, tlsConfig *v1alpha2.TLSConfig, namespace string) (*http.Client, error) {
tlsClientConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
}

// Load certificates from Secret if specified
if tlsConfig.ClientSecretRef != "" {
secret := &corev1.Secret{}
secretKey := types.NamespacedName{
Namespace: namespace,
Name: tlsConfig.ClientSecretRef,
}
if err := a.kube.Get(ctx, secretKey, secret); err != nil {
return nil, fmt.Errorf("failed to get TLS secret %s: %w", secretKey, err)
}

// Load client certificate and key
certData, ok := secret.Data[corev1.TLSCertKey]
if !ok {
return nil, fmt.Errorf("secret %s does not contain %s key", secretKey, corev1.TLSCertKey)
}
keyData, ok := secret.Data[corev1.TLSPrivateKeyKey]
if !ok {
return nil, fmt.Errorf("secret %s does not contain %s key", secretKey, corev1.TLSPrivateKeyKey)
}

cert, err := tls.X509KeyPair(certData, keyData)
if err != nil {
return nil, fmt.Errorf("failed to parse client certificate: %w", err)
}
tlsClientConfig.Certificates = []tls.Certificate{cert}

// Load CA certificate if present in the Secret (using standard "ca.crt" key)
if caCertData, ok := secret.Data["ca.crt"]; ok {
caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(caCertData) {
return nil, fmt.Errorf("failed to parse CA certificate from secret %s key ca.crt", secretKey)
}
tlsClientConfig.RootCAs = caCertPool
} else {
// Use system CA certificates if no custom CA is specified in Secret
caCertPool, err := x509.SystemCertPool()
if err != nil {
// Fallback to empty pool if system cert pool is not available
caCertPool = x509.NewCertPool()
}
tlsClientConfig.RootCAs = caCertPool
}
} else {
// Use system CA certificates if no Secret is specified
caCertPool, err := x509.SystemCertPool()
if err != nil {
// Fallback to empty pool if system cert pool is not available
caCertPool = x509.NewCertPool()
}
tlsClientConfig.RootCAs = caCertPool
}

// Configure insecure skip verify if specified
if tlsConfig.DisableVerify {
tlsClientConfig.InsecureSkipVerify = true
}

// Create HTTP client with TLS transport
transport := &http.Transport{
TLSClientConfig: tlsClientConfig,
}

return &http.Client{
Transport: transport,
}, nil
}

// headerTransport is an http.RoundTripper that adds custom headers to requests.
type headerTransport struct {
headers map[string]string
Expand Down
Loading
Loading