From 2918807d48cde16870d6a23042116cd75a629efe Mon Sep 17 00:00:00 2001 From: Anuj Parihar Date: Wed, 27 May 2026 02:54:33 +0530 Subject: [PATCH] feat: add custom ssl support in mcp --- core/mcp/clientmanager.go | 87 +++++++++++++++++-- core/schemas/mcp.go | 50 ++++++++--- docs/openapi/schemas/management/mcp.yaml | 46 ++++++++++ framework/configstore/clientconfig.go | 9 ++ framework/configstore/migrations.go | 34 +++++++- framework/configstore/rdb.go | 15 ++++ framework/configstore/tables/mcp.go | 20 +++++ transports/bifrost-http/handlers/mcp.go | 21 +++++ transports/bifrost-http/lib/config.go | 9 ++ .../mcp-registry/views/mcpClientForm.tsx | 67 +++++++++++++- .../mcp-registry/views/mcpClientSheet.tsx | 73 ++++++++++++++++ ui/lib/types/mcp.ts | 8 ++ ui/lib/types/schemas.ts | 6 ++ 13 files changed, 426 insertions(+), 19 deletions(-) diff --git a/core/mcp/clientmanager.go b/core/mcp/clientmanager.go index 182f511e45f..56b83ec32a0 100644 --- a/core/mcp/clientmanager.go +++ b/core/mcp/clientmanager.go @@ -2,9 +2,12 @@ package mcp import ( "context" + "crypto/tls" + "crypto/x509" "errors" "fmt" "maps" + "net/http" "os" "slices" "strings" @@ -97,7 +100,15 @@ func (m *MCPManager) AcquireClientConn(ctx *schemas.BifrostContext, state *schem targetURL = *preReq.ConnectionString } - httpTransport, err := transport.NewStreamableHTTP(targetURL, transport.WithHTTPHeaders(finalHeaders)) + perUserOpts := []transport.StreamableHTTPCOption{transport.WithHTTPHeaders(finalHeaders)} + perUserTLSClient, tlsErr := m.buildTLSHTTPClient(config.TLSConfig) + if tlsErr != nil { + return nil, fmt.Errorf("failed to build TLS HTTP client: %w", tlsErr) + } + if perUserTLSClient != nil { + perUserOpts = append(perUserOpts, transport.WithHTTPBasicClient(perUserTLSClient)) + } + httpTransport, err := transport.NewStreamableHTTP(targetURL, perUserOpts...) if err != nil { return nil, fmt.Errorf("failed to create HTTP transport: %w", err) } @@ -434,7 +445,15 @@ func (m *MCPManager) VerifyPerUserOAuthConnection(ctx context.Context, config *s maps.Copy(finalHeaders, preReq.Headers) finalHeaders["Authorization"] = fmt.Sprintf("Bearer %s", accessToken) - httpTransport, hErr := transport.NewStreamableHTTP(finalURL, transport.WithHTTPHeaders(finalHeaders)) + verifyOpts := []transport.StreamableHTTPCOption{transport.WithHTTPHeaders(finalHeaders)} + verifyHTTPClient, tlsErr := m.buildTLSHTTPClient(config.TLSConfig) + if tlsErr != nil { + return nil, fmt.Errorf("failed to build TLS HTTP client for verification: %w", tlsErr) + } + if verifyHTTPClient != nil { + verifyOpts = append(verifyOpts, transport.WithHTTPBasicClient(verifyHTTPClient)) + } + httpTransport, hErr := transport.NewStreamableHTTP(finalURL, verifyOpts...) if hErr != nil { return nil, fmt.Errorf("failed to create HTTP transport for verification: %w", hErr) } @@ -578,7 +597,15 @@ func (m *MCPManager) VerifyHeadersConnection(ctx context.Context, config *schema finalHeaders[k] = v } - httpTransport, hErr := transport.NewStreamableHTTP(finalURL, transport.WithHTTPHeaders(finalHeaders)) + headersVerifyOpts := []transport.StreamableHTTPCOption{transport.WithHTTPHeaders(finalHeaders)} + headersVerifyTLSClient, tlsErr := m.buildTLSHTTPClient(config.TLSConfig) + if tlsErr != nil { + return nil, fmt.Errorf("failed to build TLS HTTP client for verification: %w", tlsErr) + } + if headersVerifyTLSClient != nil { + headersVerifyOpts = append(headersVerifyOpts, transport.WithHTTPBasicClient(headersVerifyTLSClient)) + } + httpTransport, hErr := transport.NewStreamableHTTP(finalURL, headersVerifyOpts...) if hErr != nil { return nil, fmt.Errorf("failed to create HTTP transport for verification: %w", hErr) } @@ -949,6 +976,7 @@ func (m *MCPManager) UpdateClient(id string, updatedConfig *schemas.MCPClientCon ToolSyncInterval: updatedConfig.ToolSyncInterval, AllowOnAllVirtualKeys: updatedConfig.AllowOnAllVirtualKeys, Disabled: updatedConfig.Disabled, + TLSConfig: updatedConfig.TLSConfig, PerUserHeaderKeys: slices.Clone(updatedConfig.PerUserHeaderKeys), } @@ -1553,6 +1581,39 @@ func (m *MCPManager) connectToMCPClient(requestCtx context.Context, config *sche return nil } +// buildTLSHTTPClient constructs an *http.Client with a custom TLS configuration derived +// from MCPTLSConfig. Returns nil when tlsCfg is nil so callers can use the library default. +// InsecureSkipVerify takes priority over CACertPEM when both are set. +func (m *MCPManager) buildTLSHTTPClient(tlsCfg *schemas.MCPTLSConfig) (*http.Client, error) { + if tlsCfg == nil { + return nil, nil + } + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12} + if tlsCfg.InsecureSkipVerify { + m.logger.Warn("MCP client: skipping TLS verification — do not use in production") + tlsConfig.InsecureSkipVerify = true + } else if tlsCfg.CACertPEM != nil { + caPEM := tlsCfg.CACertPEM.GetValue() + if caPEM != "" { + rootCAs, err := x509.SystemCertPool() + if err != nil { + rootCAs = x509.NewCertPool() + } + if !rootCAs.AppendCertsFromPEM([]byte(caPEM)) { + return nil, fmt.Errorf("failed to parse MCP CA certificate PEM") + } + tlsConfig.RootCAs = rootCAs + } + } + transport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + transport = &http.Transport{} + } + cloned := transport.Clone() + cloned.TLSClientConfig = tlsConfig + return &http.Client{Transport: cloned}, nil +} + // createHTTPConnection creates an HTTP-based MCP client connection without holding locks. // If overrides is non-nil and carries a populated ConnectionString or Headers, those values // are used instead of resolving them from config. This is how plugin PreHook mutations flow @@ -1590,7 +1651,15 @@ func (m *MCPManager) createHTTPConnection(ctx context.Context, config *schemas.M } // Create StreamableHTTP transport - httpTransport, err := transport.NewStreamableHTTP(url, transport.WithHTTPHeaders(headers)) + opts := []transport.StreamableHTTPCOption{transport.WithHTTPHeaders(headers)} + httpClient, err := m.buildTLSHTTPClient(config.TLSConfig) + if err != nil { + return nil, nil, fmt.Errorf("failed to build TLS HTTP client: %w", err) + } + if httpClient != nil { + opts = append(opts, transport.WithHTTPBasicClient(httpClient)) + } + httpTransport, err := transport.NewStreamableHTTP(url, opts...) if err != nil { return nil, nil, fmt.Errorf("failed to create HTTP transport: %w", err) } @@ -1673,7 +1742,15 @@ func (m *MCPManager) createSSEConnection(ctx context.Context, config *schemas.MC } } - sseTransport, err := transport.NewSSE(url, transport.WithHeaders(headers)) + sseOpts := []transport.ClientOption{transport.WithHeaders(headers)} + sseHTTPClient, err := m.buildTLSHTTPClient(config.TLSConfig) + if err != nil { + return nil, nil, fmt.Errorf("failed to build TLS HTTP client: %w", err) + } + if sseHTTPClient != nil { + sseOpts = append(sseOpts, transport.WithHTTPClient(sseHTTPClient)) + } + sseTransport, err := transport.NewSSE(url, sseOpts...) if err != nil { return nil, nil, fmt.Errorf("failed to create SSE transport: %w", err) } diff --git a/core/schemas/mcp.go b/core/schemas/mcp.go index ce601883b6d..cd542c6746d 100644 --- a/core/schemas/mcp.go +++ b/core/schemas/mcp.go @@ -277,18 +277,19 @@ const ( // MCPClientConfig defines tool filtering for an MCP client. type MCPClientConfig struct { - ID string `json:"client_id"` // Client ID - Name string `json:"name"` // Client name - IsCodeModeClient bool `json:"is_code_mode_client"` // Whether the client is a code mode client - ConnectionType MCPConnectionType `json:"connection_type"` // How to connect (HTTP, STDIO, SSE, or InProcess) - ConnectionString *EnvVar `json:"connection_string,omitempty"` // HTTP or SSE URL (required for HTTP or SSE connections) - StdioConfig *MCPStdioConfig `json:"stdio_config,omitempty"` // STDIO configuration (required for STDIO connections) - AuthType MCPAuthType `json:"auth_type"` // Authentication type (none, headers, or oauth) - OauthConfigID *string `json:"oauth_config_id,omitempty"` // OAuth config ID (references oauth_configs table) - OauthClientID *EnvVar `json:"oauth_client_id,omitempty"` // Redacted OAuth client ID (populated on GET, not stored here) - OauthClientSecret *EnvVar `json:"oauth_client_secret,omitempty"` // Redacted OAuth client secret (populated on GET, not stored here) - State string `json:"state,omitempty"` // Connection state (connected, disconnected, error) - Headers map[string]EnvVar `json:"headers,omitempty"` // Headers to send with the request (for headers auth type) + ID string `json:"client_id"` // Client ID + Name string `json:"name"` // Client name + IsCodeModeClient bool `json:"is_code_mode_client"` // Whether the client is a code mode client + ConnectionType MCPConnectionType `json:"connection_type"` // How to connect (HTTP, STDIO, SSE, or InProcess) + ConnectionString *EnvVar `json:"connection_string,omitempty"` // HTTP or SSE URL (required for HTTP or SSE connections) + StdioConfig *MCPStdioConfig `json:"stdio_config,omitempty"` // STDIO configuration (required for STDIO connections) + TLSConfig *MCPTLSConfig `json:"tls_config,omitempty"` // TLS configuration for HTTP/SSE connections + AuthType MCPAuthType `json:"auth_type"` // Authentication type (none, headers, or oauth) + OauthConfigID *string `json:"oauth_config_id,omitempty"` // OAuth config ID (references oauth_configs table) + OauthClientID *EnvVar `json:"oauth_client_id,omitempty"` // Redacted OAuth client ID (populated on GET, not stored here) + OauthClientSecret *EnvVar `json:"oauth_client_secret,omitempty"` // Redacted OAuth client secret (populated on GET, not stored here) + State string `json:"state,omitempty"` // Connection state (connected, disconnected, error) + Headers map[string]EnvVar `json:"headers,omitempty"` // Headers to send with the request (for headers auth type) // PerUserHeaderKeys lists the header *names* each caller must supply for // MCPAuthTypePerUserHeaders clients. Admin-declared schema only — the // values live per-user in the mcp_per_user_header_credentials table and @@ -450,6 +451,31 @@ type MCPStdioConfig struct { Envs []string `json:"envs"` // Environment variables required } +// MCPTLSConfig holds TLS options for HTTP and SSE MCP connections. +// InsecureSkipVerify takes priority over CACertPEM when both are set. +type MCPTLSConfig struct { + InsecureSkipVerify bool `json:"insecure_skip_verify,omitempty"` // Disable TLS certificate verification (development only) + CACertPEM *EnvVar `json:"ca_cert_pem,omitempty"` // PEM-encoded CA certificate to trust (supports env.*) +} + +// MarshalForStorage serializes MCPTLSConfig for DB persistence. +// ca_cert_pem is stored as a plain string ("env.VAR_NAME" or literal PEM). +// For HTTP API responses use json.Marshal so clients receive the full EnvVar object. +func (t *MCPTLSConfig) MarshalForStorage() ([]byte, error) { + if t == nil { + return []byte("null"), nil + } + type tlsConfigStorage struct { + InsecureSkipVerify bool `json:"insecure_skip_verify,omitempty"` + CACertPEM string `json:"ca_cert_pem,omitempty"` + } + a := tlsConfigStorage{InsecureSkipVerify: t.InsecureSkipVerify} + if t.CACertPEM != nil { + a.CACertPEM = EnvVarAsString(t.CACertPEM) + } + return json.Marshal(a) +} + type MCPConnectionState string const ( diff --git a/docs/openapi/schemas/management/mcp.yaml b/docs/openapi/schemas/management/mcp.yaml index a89f70f18e6..a44cc691bf5 100644 --- a/docs/openapi/schemas/management/mcp.yaml +++ b/docs/openapi/schemas/management/mcp.yaml @@ -154,6 +154,23 @@ MCPClientCreateRequestBase: When true, this MCP client's tools are available to all virtual keys by default, without requiring an explicit virtual key assignment. An explicit virtual key config always overrides this setting for that key. + tls_config: + type: object + description: | + TLS configuration for HTTP and SSE connections. + Not applicable to stdio or inprocess connection types. + properties: + insecure_skip_verify: + type: boolean + description: | + Disable TLS certificate verification. Takes priority over ca_cert_pem when both are set. + Use only in development or trusted isolated environments. Not recommended for production. + ca_cert_pem: + type: string + description: | + PEM-encoded CA certificate to trust for MCP server connections. + Use when the MCP server uses a self-signed or private CA certificate. + Supports env.VAR_NAME syntax to read the certificate from an environment variable. per_user_header_keys: type: array items: @@ -318,6 +335,23 @@ MCPClientUpdateRequest: When true, the client's connection, health monitor, and tool syncer are shut down. The client entry is preserved so it can be re-enabled later by sending disabled: false. Disabled clients do not expose tools to inference requests. + tls_config: + type: object + description: | + TLS configuration for HTTP and SSE connections. + Not applicable to stdio or inprocess connection types. + properties: + insecure_skip_verify: + type: boolean + description: | + Disable TLS certificate verification. Takes priority over ca_cert_pem when both are set. + Use only in development or trusted isolated environments. Not recommended for production. + ca_cert_pem: + type: string + description: | + PEM-encoded CA certificate to trust for MCP server connections. + Use when the MCP server uses a self-signed or private CA certificate. + Supports env.VAR_NAME syntax to read the certificate from an environment variable. vk_configs: type: array items: @@ -367,6 +401,18 @@ MCPClientConfig: description: HTTP or SSE URL (required for HTTP or SSE connections) stdio_config: $ref: '#/MCPStdioConfig' + tls_config: + type: object + description: TLS configuration for HTTP and SSE connections. + properties: + insecure_skip_verify: + type: boolean + description: Disable TLS certificate verification. Development/testing only. + ca_cert_pem: + type: string + description: | + PEM-encoded CA certificate. Supports env.VAR_NAME syntax for input. + Responses return a redacted placeholder rather than the raw PEM value. auth_type: $ref: '#/MCPAuthType' description: Authentication type for the MCP connection diff --git a/framework/configstore/clientconfig.go b/framework/configstore/clientconfig.go index b68a0448cd6..85673455cef 100644 --- a/framework/configstore/clientconfig.go +++ b/framework/configstore/clientconfig.go @@ -1256,6 +1256,15 @@ func GenerateMCPClientHash(m tables.TableMCPClient) (string, error) { hash.Write(data) } + // Hash TLSConfig + if m.TLSConfig != nil { + data, err := sonic.Marshal(m.TLSConfig) + if err != nil { + return "", err + } + hash.Write(data) + } + // Hash ToolsToExecute (sorted for deterministic hashing) if len(m.ToolsToExecute) > 0 { sortedTools := make([]string, len(m.ToolsToExecute)) diff --git a/framework/configstore/migrations.go b/framework/configstore/migrations.go index e8bc6e0c112..8cb261b022b 100644 --- a/framework/configstore/migrations.go +++ b/framework/configstore/migrations.go @@ -813,6 +813,9 @@ func triggerMigrations(ctx context.Context, db *gorm.DB) error { if err := migrationAddPerUserHeadersFlowsTable(ctx, db); err != nil { return err } + if err := migrationAddMCPClientTLSConfigColumn(ctx, db); err != nil { + return err + } return nil } @@ -8866,7 +8869,7 @@ func migrationAddCreatedByUserIDColumnForVirtualKeys(ctx context.Context, db *go return nil } -// migrationAddCreatedByUserIDColumnForVirtualKeys adds the created_by_user_id column to the governance_virtual_keys table. +// migrationDropAzureAPIVersionColumn adds the created_by_user_id column to the governance_virtual_keys table func migrationDropAzureAPIVersionColumn(ctx context.Context, db *gorm.DB) error { m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ ID: "drop_azure_api_version_column", @@ -8894,3 +8897,32 @@ func migrationDropAzureAPIVersionColumn(ctx context.Context, db *gorm.DB) error } return nil } + +// migrationAddMCPClientTLSConfigColumn adds the tls_config_json column to the config_mcp_clients table. +func migrationAddMCPClientTLSConfigColumn(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_mcp_client_tls_config_json_column", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + if !tx.Migrator().HasColumn(&tables.TableMCPClient{}, "tls_config_json") { + if err := tx.Exec("ALTER TABLE config_mcp_clients ADD COLUMN tls_config_json TEXT").Error; err != nil { + return fmt.Errorf("failed to add tls_config_json column: %w", err) + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + if tx.Migrator().HasColumn(&tables.TableMCPClient{}, "tls_config_json") { + if err := tx.Exec("ALTER TABLE config_mcp_clients DROP COLUMN tls_config_json").Error; err != nil { + return fmt.Errorf("failed to drop tls_config_json column: %w", err) + } + } + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error running add_mcp_client_tls_config_json_column migration: %s", err.Error()) + } + return nil +} diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index 8fa3206f4ca..82b7d19beaa 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -1491,6 +1491,7 @@ func (s *RDBConfigStore) GetMCPConfig(ctx context.Context) (*schemas.MCPConfig, ConnectionType: schemas.MCPConnectionType(dbClient.ConnectionType), ConnectionString: dbClient.ConnectionString, StdioConfig: dbClient.StdioConfig, + TLSConfig: dbClient.TLSConfig, AuthType: schemas.MCPAuthType(dbClient.AuthType), OauthConfigID: dbClient.OauthConfigID, ToolsToExecute: dbClient.ToolsToExecute, @@ -1532,6 +1533,7 @@ func (s *RDBConfigStore) GetMCPConfig(ctx context.Context) (*schemas.MCPConfig, ConnectionType: schemas.MCPConnectionType(dbClient.ConnectionType), ConnectionString: dbClient.ConnectionString, StdioConfig: dbClient.StdioConfig, + TLSConfig: dbClient.TLSConfig, AuthType: schemas.MCPAuthType(dbClient.AuthType), OauthConfigID: dbClient.OauthConfigID, ToolsToExecute: dbClient.ToolsToExecute, @@ -1618,6 +1620,7 @@ func (s *RDBConfigStore) GetMCPClientConfigByID(ctx context.Context, id string) ConnectionType: schemas.MCPConnectionType(dbClient.ConnectionType), ConnectionString: dbClient.ConnectionString, StdioConfig: dbClient.StdioConfig, + TLSConfig: dbClient.TLSConfig, AuthType: schemas.MCPAuthType(dbClient.AuthType), OauthConfigID: dbClient.OauthConfigID, ToolsToExecute: dbClient.ToolsToExecute, @@ -1671,6 +1674,7 @@ func (s *RDBConfigStore) CreateMCPClientConfig(ctx context.Context, clientConfig ConnectionType: string(clientConfigCopy.ConnectionType), ConnectionString: clientConfigCopy.ConnectionString, StdioConfig: clientConfigCopy.StdioConfig, + TLSConfig: clientConfigCopy.TLSConfig, AuthType: string(clientConfigCopy.AuthType), OauthConfigID: clientConfigCopy.OauthConfigID, ToolsToExecute: clientConfigCopy.ToolsToExecute, @@ -1764,6 +1768,15 @@ func (s *RDBConfigStore) UpdateMCPClientConfig(ctx context.Context, id string, c stdioStr := string(stdioData) stdioConfigJSON = &stdioStr } + var tlsConfigJSON *string + if clientConfigCopy.TLSConfig != nil { + tlsData, marshalErr := clientConfigCopy.TLSConfig.MarshalForStorage() + if marshalErr != nil { + return fmt.Errorf("failed to marshal tls_config: %w", marshalErr) + } + tlsStr := string(tlsData) + tlsConfigJSON = &tlsStr + } if clientConfigCopy.ToolPricing == nil { clientConfigCopy.ToolPricing = map[string]float64{} @@ -1829,6 +1842,7 @@ func (s *RDBConfigStore) UpdateMCPClientConfig(ctx context.Context, id string, c if clientConfigCopy.OauthConfigID != nil { updates["oauth_config_id"] = clientConfigCopy.OauthConfigID } + updates["tls_config_json"] = tlsConfigJSON if discoveredToolsJSON != "" { updates["discovered_tools_json"] = discoveredToolsJSON } @@ -1862,6 +1876,7 @@ func (s *RDBConfigStore) UpdateMCPClientConfig(ctx context.Context, id string, c updates["connection_type"] = clientConfigCopy.ConnectionType updates["connection_string"] = connectionStringToPersist updates["stdio_config_json"] = stdioConfigJSON + updates["tls_config_json"] = tlsConfigJSON updates["auth_type"] = clientConfigCopy.AuthType updates["oauth_config_id"] = clientConfigCopy.OauthConfigID updates["per_user_header_keys_json"] = perUserHeaderKeysJSON diff --git a/framework/configstore/tables/mcp.go b/framework/configstore/tables/mcp.go index 0f71b493d5b..17cd0a74589 100644 --- a/framework/configstore/tables/mcp.go +++ b/framework/configstore/tables/mcp.go @@ -20,6 +20,7 @@ type TableMCPClient struct { ConnectionType string `gorm:"type:varchar(20);not null" json:"connection_type"` // schemas.MCPConnectionType ConnectionString *schemas.EnvVar `gorm:"type:text" json:"connection_string,omitempty"` StdioConfigJSON *string `gorm:"type:text" json:"-"` // JSON serialized schemas.MCPStdioConfig + TLSConfigJSON *string `gorm:"type:text" json:"-"` // JSON serialized schemas.MCPTLSConfig ToolsToExecuteJSON string `gorm:"type:text" json:"-"` // JSON serialized []string ToolsToAutoExecuteJSON string `gorm:"type:text" json:"-"` // JSON serialized []string HeadersJSON string `gorm:"type:text" json:"-"` // JSON serialized map[string]string @@ -57,6 +58,7 @@ type TableMCPClient struct { // Virtual fields for runtime use (not stored in DB) StdioConfig *schemas.MCPStdioConfig `gorm:"-" json:"stdio_config,omitempty"` + TLSConfig *schemas.MCPTLSConfig `gorm:"-" json:"tls_config,omitempty"` ToolsToExecute schemas.WhiteList `gorm:"-" json:"tools_to_execute"` ToolsToAutoExecute schemas.WhiteList `gorm:"-" json:"tools_to_auto_execute"` Headers map[string]schemas.EnvVar `gorm:"-" json:"headers"` @@ -85,6 +87,17 @@ func (c *TableMCPClient) BeforeSave(tx *gorm.DB) error { c.StdioConfigJSON = nil } + if c.TLSConfig != nil { + data, err := c.TLSConfig.MarshalForStorage() + if err != nil { + return err + } + config := string(data) + c.TLSConfigJSON = &config + } else { + c.TLSConfigJSON = nil + } + if c.ToolsToExecute != nil { if err := c.ToolsToExecute.Validate(); err != nil { return fmt.Errorf("invalid tools_to_execute: %w", err) @@ -231,6 +244,13 @@ func (c *TableMCPClient) AfterFind(tx *gorm.DB) error { } c.StdioConfig = &config } + if c.TLSConfigJSON != nil { + var config schemas.MCPTLSConfig + if err := sonic.Unmarshal([]byte(*c.TLSConfigJSON), &config); err != nil { + return err + } + c.TLSConfig = &config + } if c.ToolsToExecuteJSON != "" { if err := sonic.Unmarshal([]byte(c.ToolsToExecuteJSON), &c.ToolsToExecute); err != nil { return err diff --git a/transports/bifrost-http/handlers/mcp.go b/transports/bifrost-http/handlers/mcp.go index e9cf5b2e135..ff75c493139 100644 --- a/transports/bifrost-http/handlers/mcp.go +++ b/transports/bifrost-http/handlers/mcp.go @@ -218,6 +218,7 @@ func (h *MCPHandler) getMCPClientsPaginated(ctx *fasthttp.RequestCtx, limitStr, ConnectionType: schemas.MCPConnectionType(dbClient.ConnectionType), ConnectionString: dbClient.ConnectionString, StdioConfig: dbClient.StdioConfig, + TLSConfig: dbClient.TLSConfig, AuthType: schemas.MCPAuthType(dbClient.AuthType), OauthConfigID: dbClient.OauthConfigID, ToolsToExecute: dbClient.ToolsToExecute, @@ -563,6 +564,7 @@ func (h *MCPHandler) addMCPClient(ctx *fasthttp.RequestCtx) { ConnectionType: schemas.MCPConnectionType(req.ConnectionType), ConnectionString: req.ConnectionString, StdioConfig: req.StdioConfig, + TLSConfig: req.TLSConfig, AuthType: schemas.MCPAuthTypePerUserOauth, OauthConfigID: &flowInitiation.OauthConfigID, ToolsToExecute: req.ToolsToExecute, @@ -655,6 +657,7 @@ func (h *MCPHandler) addMCPClient(ctx *fasthttp.RequestCtx) { ConnectionType: schemas.MCPConnectionType(req.ConnectionType), ConnectionString: req.ConnectionString, StdioConfig: req.StdioConfig, + TLSConfig: req.TLSConfig, AuthType: schemas.MCPAuthType(req.AuthType), OauthConfigID: &flowInitiation.OauthConfigID, ToolsToExecute: req.ToolsToExecute, @@ -716,6 +719,7 @@ func (h *MCPHandler) addMCPClient(ctx *fasthttp.RequestCtx) { ConnectionType: schemas.MCPConnectionType(req.ConnectionType), ConnectionString: req.ConnectionString, StdioConfig: req.StdioConfig, + TLSConfig: req.TLSConfig, ToolsToExecute: req.ToolsToExecute, ToolsToAutoExecute: req.ToolsToAutoExecute, Headers: req.Headers, @@ -1025,6 +1029,7 @@ func (h *MCPHandler) updateMCPClient(ctx *fasthttp.RequestCtx) { ConnectionType: existingConfig.ConnectionType, ConnectionString: existingConfig.ConnectionString, StdioConfig: existingConfig.StdioConfig, + TLSConfig: req.TLSConfig, ToolsToExecute: resolvedToolsToExecute, ToolsToAutoExecute: resolvedToolsToAutoExecute, Headers: req.Headers, @@ -1401,6 +1406,22 @@ func mergeMCPRedactedValues(incoming *configstoreTables.TableMCPClient, oldRaw, merged.AllowedExtraHeaders = oldRaw.AllowedExtraHeaders } + // Handle TLSConfig - preserve existing when not sent, restore raw CA cert when redacted + if incoming.TLSConfig == nil { + merged.TLSConfig = oldRaw.TLSConfig + } else { + tlsCopy := *incoming.TLSConfig + // If CACertPEM is a redacted placeholder that matches the old redacted value, + // restore the raw value to avoid writing ***** to the DB. + if tlsCopy.CACertPEM != nil && + oldRaw.TLSConfig != nil && oldRaw.TLSConfig.CACertPEM != nil && + oldRedacted.TLSConfig != nil && oldRedacted.TLSConfig.CACertPEM != nil && + tlsCopy.CACertPEM.IsRedacted() && tlsCopy.CACertPEM.Equals(oldRedacted.TLSConfig.CACertPEM) { + tlsCopy.CACertPEM = oldRaw.TLSConfig.CACertPEM + } + merged.TLSConfig = &tlsCopy + } + return merged } diff --git a/transports/bifrost-http/lib/config.go b/transports/bifrost-http/lib/config.go index 2d0e7c014bf..e209db31a1d 100644 --- a/transports/bifrost-http/lib/config.go +++ b/transports/bifrost-http/lib/config.go @@ -5084,6 +5084,15 @@ func (c *Config) RedactMCPClientConfig(config *schemas.MCPClientConfig) *schemas configCopy.OauthClientSecret = config.OauthClientSecret.Redacted() } + // Redact TLS CA cert PEM if present + if config.TLSConfig != nil { + tlsCopy := *config.TLSConfig + if config.TLSConfig.CACertPEM != nil { + tlsCopy.CACertPEM = config.TLSConfig.CACertPEM.Redacted() + } + configCopy.TLSConfig = &tlsCopy + } + return &configCopy } diff --git a/ui/app/workspace/mcp-registry/views/mcpClientForm.tsx b/ui/app/workspace/mcp-registry/views/mcpClientForm.tsx index 836ce0f90b3..661adcb7879 100644 --- a/ui/app/workspace/mcp-registry/views/mcpClientForm.tsx +++ b/ui/app/workspace/mcp-registry/views/mcpClientForm.tsx @@ -12,7 +12,7 @@ import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { useToast } from "@/hooks/use-toast"; import { getErrorMessage, useCreateMCPClientMutation } from "@/lib/store"; -import { CreateMCPClientRequest, EnvVar, MCPConnectionType, MCPStdioConfig } from "@/lib/types/mcp"; +import { CreateMCPClientRequest, EnvVar, MCPAuthType, MCPConnectionType, MCPStdioConfig, MCPTLSConfig } from "@/lib/types/mcp"; import { parseArrayFromText } from "@/lib/utils/array"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; import { Info } from "lucide-react"; @@ -35,6 +35,15 @@ const emptyStdioConfig: MCPStdioConfig = { const emptyEnvVar: EnvVar = { value: "", env_var: "", from_env: false }; +/** Strips empty TLS config so we don't send `{}` to the server. */ +function buildTLSConfigPayload(tls: MCPTLSConfig | undefined): MCPTLSConfig | undefined { + if (!tls) return undefined; + const hasSkipVerify = tls.insecure_skip_verify === true; + const hasCACert = tls.ca_cert_pem?.value || tls.ca_cert_pem?.from_env; + if (!hasSkipVerify && !hasCACert) return undefined; + return { insecure_skip_verify: tls.insecure_skip_verify, ca_cert_pem: hasCACert ? tls.ca_cert_pem : undefined }; +} + const emptyForm: CreateMCPClientRequest = { name: "", is_code_mode_client: false, @@ -218,6 +227,10 @@ const ClientForm: React.FC = ({ open, onClose, onSaved }) => { envs: parseArrayFromText(envsText), } : undefined, + tls_config: + connectionType === "http" || connectionType === "sse" + ? buildTLSConfigPayload(data.tls_config) + : undefined, oauth_config: authType === "oauth" || authType === "per_user_oauth" ? { @@ -715,6 +728,58 @@ const ClientForm: React.FC = ({ open, onClose, onSaved }) => { )} + + {/* TLS / Certificate */} +
+

TLS / Certificate

+ ( + +
+ Skip TLS verification +

+ Disable TLS certificate verification. Use only in trusted isolated environments. Takes priority over CA certificate. +

+
+ + + +
+ )} + /> + ( + + CA Certificate (PEM) (Optional) + + + +

+ PEM-encoded CA certificate to trust for MCP server connections (e.g. self-signed or private CA). +

+ +
+ )} + /> +
)} diff --git a/ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx b/ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx index 50927e4badc..5381e7fd9b2 100644 --- a/ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx +++ b/ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx @@ -187,6 +187,12 @@ export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess, on oauth_config: supportsOAuthCredentialUpdate ? { client_id: mcpClient.config.oauth_client_id, client_secret: mcpClient.config.oauth_client_secret } : undefined, + tls_config: mcpClient.config.tls_config + ? { + insecure_skip_verify: mcpClient.config.tls_config.insecure_skip_verify, + ca_cert_pem: mcpClient.config.tls_config.ca_cert_pem, + } + : undefined, }, }); const isDisabled = form.watch("disabled"); @@ -209,6 +215,12 @@ export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess, on oauth_config: supportsOAuthCredentialUpdate ? { client_id: mcpClient.config.oauth_client_id, client_secret: mcpClient.config.oauth_client_secret } : undefined, + tls_config: mcpClient.config.tls_config + ? { + insecure_skip_verify: mcpClient.config.tls_config.insecure_skip_verify, + ca_cert_pem: mcpClient.config.tls_config.ca_cert_pem, + } + : undefined, }); }, [form, mcpClient]); @@ -269,6 +281,12 @@ export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess, on client_secret: oauthClientSecret, } : undefined, + tls_config: data.tls_config !== undefined + ? { + insecure_skip_verify: data.tls_config.insecure_skip_verify ?? false, + ca_cert_pem: data.tls_config.ca_cert_pem, + } + : undefined, vk_configs: vkConfigsDirty ? vkConfigs : undefined, }, }).unwrap(); @@ -614,6 +632,61 @@ export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess, on )} /> + {(mcpClient.config.connection_type === "http" || mcpClient.config.connection_type === "sse") && ( +
+

TLS / Certificate

+ ( + +
+ Skip TLS verification +

+ Disable TLS certificate verification. Use only in trusted isolated environments. Takes priority over CA + certificate. +

+
+ + + +
+ )} + /> + ( + + CA Certificate (PEM) (Optional) + + + +

+ PEM-encoded CA certificate to trust for MCP server connections (e.g. self-signed or private CA). +

+ +
+ )} + /> +
+ )}