Skip to content
Merged
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
87 changes: 82 additions & 5 deletions core/mcp/clientmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ package mcp

import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"maps"
"net/http"
"os"
"slices"
"strings"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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),
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
50 changes: 38 additions & 12 deletions core/schemas/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
Expand Down
46 changes: 46 additions & 0 deletions docs/openapi/schemas/management/mcp.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions framework/configstore/clientconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
34 changes: 33 additions & 1 deletion framework/configstore/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
}
Loading
Loading