diff --git a/docs/deployment-guides/config-json/client.mdx b/docs/deployment-guides/config-json/client.mdx index 1a974df77b..9517fce945 100644 --- a/docs/deployment-guides/config-json/client.mdx +++ b/docs/deployment-guides/config-json/client.mdx @@ -52,6 +52,38 @@ Set `disable_content_logging: true` for HIPAA / PCI compliance workloads where m --- +## Reverse Proxy + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `mcp_external_base_url` | string or EnvVar | — | Public base URL advertised for OAuth callbacks and discovery metadata | + +When Bifrost runs behind a reverse proxy, the OAuth callback URL and well-known discovery endpoints (`/.well-known/oauth-authorization-server`, etc.) are built from the incoming `Host` header — which is your proxy's internal address, not its public address. Set `mcp_external_base_url` to override this with the proxy's public URL. + +The field supports env var syntax so the URL can be injected at runtime: + +```json +{ + "client": { + "mcp_external_base_url": "env.BIFROST_EXTERNAL_URL" + } +} +``` + +Or as a plain URL: + +```json +{ + "client": { + "mcp_external_base_url": "https://api.yourcompany.com" + } +} +``` + +This setting is also configurable via the UI (**MCP Gateway → MCP Settings**) and the management API, with no restart required. + +--- + ## Security & CORS | Field | Type | Default | Description | @@ -250,6 +282,8 @@ A top-level `auth_config` is also accepted for backwards compatibility, but `gov "log_retention_days": 90, "logging_headers": ["x-request-id", "x-user-id"], + "mcp_external_base_url": "env.BIFROST_EXTERNAL_URL", + "allowed_origins": ["https://app.yourcompany.com"], "allow_direct_keys": false, "enforce_auth_on_inference": true, diff --git a/docs/deployment-guides/config-json/schema-reference.mdx b/docs/deployment-guides/config-json/schema-reference.mdx index f80d86a2d2..3f3ffa7f42 100644 --- a/docs/deployment-guides/config-json/schema-reference.mdx +++ b/docs/deployment-guides/config-json/schema-reference.mdx @@ -77,6 +77,7 @@ Controls the worker pool, logging pipeline, security, and SDK shims. All fields | `async_job_result_ttl` | integer | `3600` | TTL for async job results in seconds | | `disable_db_pings_in_health` | boolean | `false` | Exclude DB connectivity from `/health` | | `routing_chain_max_depth` | integer | `10` | Max routing rule chain evaluation depth | +| `mcp_external_base_url` | string \| EnvVar | — | Public base URL for OAuth callbacks/discovery when behind a reverse proxy; supports `"env.MY_VAR"` | Full documentation: [Client Configuration](/deployment-guides/config-json/client). diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index e69645483d..86ba8d08b7 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -52596,6 +52596,30 @@ "type": "string" }, "description": "Headers to capture in log metadata. Values are extracted from incoming requests and stored in the metadata field of log entries. Case-insensitive matching. No restart required." + }, + "mcp_external_base_url": { + "oneOf": [ + { + "type": "string", + "description": "Plain URL or env var reference (e.g. \"env.MY_VAR\")" + }, + { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "env_var": { + "type": "string" + }, + "from_env": { + "type": "boolean" + } + }, + "additionalProperties": false + } + ], + "description": "Public base URL for OAuth callbacks and discovery metadata when Bifrost runs behind a reverse proxy. Overrides the host derived from the incoming request. Supports env var syntax (\"env.MY_VAR\"). Configurable via UI, API, or config.json.\n" } } }, @@ -52841,6 +52865,30 @@ "type": "string" }, "description": "Headers to capture in log metadata. Values are extracted from incoming requests and stored in the metadata field of log entries. Case-insensitive matching. No restart required." + }, + "mcp_external_base_url": { + "oneOf": [ + { + "type": "string", + "description": "Plain URL or env var reference (e.g. \"env.MY_VAR\")" + }, + { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "env_var": { + "type": "string" + }, + "from_env": { + "type": "boolean" + } + }, + "additionalProperties": false + } + ], + "description": "Public base URL for OAuth callbacks and discovery metadata when Bifrost runs behind a reverse proxy. Overrides the host derived from the incoming request. Supports env var syntax (\"env.MY_VAR\"). Configurable via UI, API, or config.json.\n" } } }, diff --git a/docs/openapi/schemas/management/config.yaml b/docs/openapi/schemas/management/config.yaml index 40237e52ab..4aaf16b098 100644 --- a/docs/openapi/schemas/management/config.yaml +++ b/docs/openapi/schemas/management/config.yaml @@ -94,6 +94,23 @@ ClientConfig: items: type: string description: Headers to capture in log metadata. Values are extracted from incoming requests and stored in the metadata field of log entries. Case-insensitive matching. No restart required. + mcp_external_base_url: + oneOf: + - type: string + description: Plain URL or env var reference (e.g. "env.MY_VAR") + - type: object + properties: + value: + type: string + env_var: + type: string + from_env: + type: boolean + additionalProperties: false + description: > + Public base URL for OAuth callbacks and discovery metadata when Bifrost runs behind a + reverse proxy. Overrides the host derived from the incoming request. Supports env var + syntax ("env.MY_VAR"). Configurable via UI, API, or config.json. FrameworkConfig: type: object diff --git a/framework/configstore/clientconfig.go b/framework/configstore/clientconfig.go index ddeb6ab9a7..08ebdf9c49 100644 --- a/framework/configstore/clientconfig.go +++ b/framework/configstore/clientconfig.go @@ -75,6 +75,7 @@ type ClientConfig struct { WhitelistedRoutes []string `json:"whitelisted_routes,omitempty"` // Routes that bypass auth middleware HideDeletedVirtualKeysInFilters bool `json:"hide_deleted_virtual_keys_in_filters"` // Hide deleted virtual keys from logs/MCP filter data RoutingChainMaxDepth int `json:"routing_chain_max_depth"` // Maximum depth for routing rule chain evaluation (default: 10) + MCPExternalBaseURL *schemas.EnvVar `json:"mcp_external_base_url,omitempty"` // Public base URL for OAuth callbacks/discovery when behind a reverse proxy; supports env var syntax ("env.MY_VAR") ConfigHash string `json:"-"` // Config hash for reconciliation (not serialized) } @@ -313,6 +314,14 @@ func (c *ClientConfig) GenerateClientConfigHash() (string, error) { } } + if c.MCPExternalBaseURL.IsSet() { + if c.MCPExternalBaseURL.IsFromEnv() { + hash.Write([]byte("externalBaseURL:env:" + c.MCPExternalBaseURL.EnvVar)) + } else { + hash.Write([]byte("externalBaseURL:val:" + c.MCPExternalBaseURL.GetValue())) + } + } + return hex.EncodeToString(hash.Sum(nil)), nil } diff --git a/framework/configstore/migrations.go b/framework/configstore/migrations.go index 4124ea309d..fdfa986cab 100644 --- a/framework/configstore/migrations.go +++ b/framework/configstore/migrations.go @@ -617,6 +617,9 @@ func triggerMigrations(ctx context.Context, db *gorm.DB) error { if err := migrationConvertMCPClientToolSyncIntervalMinutesToSeconds(ctx, db); err != nil { return err } + if err := migrationAddMCPExternalBaseURLColumn(ctx, db); err != nil { + return err + } if err := migrationMakeOAuthTokenExpiryNullable(ctx, db); err != nil { return err } @@ -7071,3 +7074,33 @@ func migrationAddOCRPricingColumns(ctx context.Context, db *gorm.DB) error { } return nil } + +func migrationAddMCPExternalBaseURLColumn(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_mcp_external_base_url_column", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mg := tx.Migrator() + if !mg.HasColumn(&tables.TableClientConfig{}, "mcp_external_base_url") { + if err := mg.AddColumn(&tables.TableClientConfig{}, "MCPExternalBaseURL"); err != nil { + return fmt.Errorf("failed to add mcp_external_base_url column: %w", err) + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mg := tx.Migrator() + if mg.HasColumn(&tables.TableClientConfig{}, "mcp_external_base_url") { + if err := mg.DropColumn(&tables.TableClientConfig{}, "mcp_external_base_url"); err != nil { + return fmt.Errorf("failed to drop mcp_external_base_url column: %w", err) + } + } + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error running add_mcp_external_base_url_column migration: %s", err.Error()) + } + return nil +} diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index 9582e95e7e..6ce66d614b 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -136,6 +136,20 @@ func tableKeyFromSchemaKey(provider tables.TableProvider, key schemas.Key) (tabl return dbKey, nil } +// mcpExternalBaseURLToString converts an *schemas.EnvVar to its storage string form. +// Stores "env.MY_VAR" when sourced from an env var, or the raw URL otherwise. +func mcpExternalBaseURLToString(e *schemas.EnvVar) string { + if e == nil { + return "" + } + if v, err := e.Value(); err == nil { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + // UpdateClientConfig updates the client configuration in the database. func (s *RDBConfigStore) UpdateClientConfig(ctx context.Context, config *ClientConfig) error { dbConfig := tables.TableClientConfig{ @@ -168,6 +182,7 @@ func (s *RDBConfigStore) UpdateClientConfig(ctx context.Context, config *ClientC WhitelistedRoutes: config.WhitelistedRoutes, HideDeletedVirtualKeysInFilters: config.HideDeletedVirtualKeysInFilters, RoutingChainMaxDepth: config.RoutingChainMaxDepth, + MCPExternalBaseURL: mcpExternalBaseURLToString(config.MCPExternalBaseURL), HeaderFilterConfig: config.HeaderFilterConfig, ConfigHash: config.ConfigHash, } @@ -378,6 +393,7 @@ func (s *RDBConfigStore) GetClientConfig(ctx context.Context) (*ClientConfig, er WhitelistedRoutes: dbConfig.WhitelistedRoutes, HideDeletedVirtualKeysInFilters: dbConfig.HideDeletedVirtualKeysInFilters, RoutingChainMaxDepth: dbConfig.RoutingChainMaxDepth, + MCPExternalBaseURL: schemas.NewEnvVar(dbConfig.MCPExternalBaseURL), HeaderFilterConfig: dbConfig.HeaderFilterConfig, ConfigHash: dbConfig.ConfigHash, }, nil diff --git a/framework/configstore/tables/clientconfig.go b/framework/configstore/tables/clientconfig.go index 7dafc96f8e..085e815e03 100644 --- a/framework/configstore/tables/clientconfig.go +++ b/framework/configstore/tables/clientconfig.go @@ -35,6 +35,7 @@ type TableClientConfig struct { LoggingHeadersJSON string `gorm:"type:text" json:"-"` // JSON serialized []string HideDeletedVirtualKeysInFilters bool `gorm:"default:false" json:"hide_deleted_virtual_keys_in_filters"` // Hide deleted virtual keys in logs filter dropdowns RoutingChainMaxDepth int `gorm:"default:10" json:"routing_chain_max_depth"` // Maximum depth for routing rule chain evaluation (default: 10) + MCPExternalBaseURL string `gorm:"type:varchar(512)" json:"mcp_external_base_url,omitempty"` // Public base URL for OAuth callbacks/discovery when behind a reverse proxy WhitelistedRoutesJSON string `gorm:"type:text" json:"-"` // JSON serialized []string // Compat plugin feature flags diff --git a/transports/bifrost-http/handlers/config.go b/transports/bifrost-http/handlers/config.go index 85f0dfb6d3..26d8d24ece 100644 --- a/transports/bifrost-http/handlers/config.go +++ b/transports/bifrost-http/handlers/config.go @@ -423,6 +423,9 @@ func (h *ConfigHandler) updateConfig(ctx *fasthttp.RequestCtx) { updatedConfig.RoutingChainMaxDepth = payload.ClientConfig.RoutingChainMaxDepth } + // Update external base URL for OAuth callbacks/discovery (nil clears the override). + updatedConfig.MCPExternalBaseURL = payload.ClientConfig.MCPExternalBaseURL + // Handle HeaderFilterConfig changes if !headerFilterConfigEqual(payload.ClientConfig.HeaderFilterConfig, currentConfig.HeaderFilterConfig) { // Validate that no security headers are in the allowlist or denylist diff --git a/transports/bifrost-http/handlers/mcp.go b/transports/bifrost-http/handlers/mcp.go index 0ea7f76126..8152d082ae 100644 --- a/transports/bifrost-http/handlers/mcp.go +++ b/transports/bifrost-http/handlers/mcp.go @@ -439,12 +439,7 @@ func (h *MCPHandler) addMCPClient(ctx *fasthttp.RequestCtx) { return } - scheme := "http" - if ctx.IsTLS() || string(ctx.Request.Header.Peek("X-Forwarded-Proto")) == "https" { - scheme = "https" - } - host := string(ctx.Host()) - redirectURI := fmt.Sprintf("%s://%s/api/oauth/callback", scheme, host) + redirectURI := lib.BuildBaseURL(ctx, h.store.GetMCPExternalBaseURL()) + "/api/oauth/callback" flowInitiation, err := h.oauthHandler.InitiateOAuthFlow(ctx, OAuthInitiationRequest{ ClientID: req.OauthConfig.ClientID, @@ -532,13 +527,7 @@ func (h *MCPHandler) addMCPClient(ctx *fasthttp.RequestCtx) { } // Build redirect URI - use Bifrost's own callback endpoint - // Extract the base URL from the current request - scheme := "http" - if ctx.IsTLS() || string(ctx.Request.Header.Peek("X-Forwarded-Proto")) == "https" { - scheme = "https" - } - host := string(ctx.Host()) - redirectURI := fmt.Sprintf("%s://%s/api/oauth/callback", scheme, host) + redirectURI := lib.BuildBaseURL(ctx, h.store.GetMCPExternalBaseURL()) + "/api/oauth/callback" // Initiate OAuth flow // ServerURL comes from ConnectionString (MCP server URL for OAuth discovery) diff --git a/transports/bifrost-http/handlers/mcpserver.go b/transports/bifrost-http/handlers/mcpserver.go index ca735dee3f..9964633790 100644 --- a/transports/bifrost-http/handlers/mcpserver.go +++ b/transports/bifrost-http/handlers/mcpserver.go @@ -463,12 +463,7 @@ func (h *MCPServerHandler) getMCPServerForRequest(ctx *fasthttp.RequestCtx) (*se // If per_user_oauth MCP clients are configured and no valid auth, return 401 with discovery if clients := h.config.GetPerUserOAuthMCPClients(); len(clients) > 0 && userOauthSession == nil && vk == "" { - scheme := "http" - if ctx.IsTLS() || string(ctx.Request.Header.Peek("X-Forwarded-Proto")) == "https" { - scheme = "https" - } - host := string(ctx.Host()) - resourceMetadataURL := fmt.Sprintf("%s://%s/.well-known/oauth-protected-resource", scheme, host) + resourceMetadataURL := lib.BuildBaseURL(ctx, h.config.GetMCPExternalBaseURL()) + "/.well-known/oauth-protected-resource" ctx.Response.Header.Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata="%s"`, resourceMetadataURL)) return nil, nil, fmt.Errorf("oauth authentication required for mcp access") diff --git a/transports/bifrost-http/handlers/oauth2_metadata.go b/transports/bifrost-http/handlers/oauth2_metadata.go index 2a764291e4..6d40237f10 100644 --- a/transports/bifrost-http/handlers/oauth2_metadata.go +++ b/transports/bifrost-http/handlers/oauth2_metadata.go @@ -6,8 +6,6 @@ package handlers import ( - "fmt" - "github.com/fasthttp/router" "github.com/maximhq/bifrost/core/schemas" "github.com/maximhq/bifrost/transports/bifrost-http/lib" @@ -47,12 +45,7 @@ func (h *OAuthMetadataHandler) handleProtectedResourceMetadata(ctx *fasthttp.Req sendStringError(ctx, fasthttp.StatusNotFound, "Not Found") return } - scheme := "http" - if ctx.IsTLS() || string(ctx.Request.Header.Peek("X-Forwarded-Proto")) == "https" { - scheme = "https" - } - host := string(ctx.Host()) - baseURL := fmt.Sprintf("%s://%s", scheme, host) + baseURL := lib.BuildBaseURL(ctx, h.store.GetMCPExternalBaseURL()) SendJSON(ctx, map[string]interface{}{ "resource": baseURL + "/mcp", @@ -72,12 +65,7 @@ func (h *OAuthMetadataHandler) handleAuthorizationServerMetadata(ctx *fasthttp.R sendStringError(ctx, fasthttp.StatusNotFound, "Not Found") return } - scheme := "http" - if ctx.IsTLS() || string(ctx.Request.Header.Peek("X-Forwarded-Proto")) == "https" { - scheme = "https" - } - host := string(ctx.Host()) - baseURL := fmt.Sprintf("%s://%s", scheme, host) + baseURL := lib.BuildBaseURL(ctx, h.store.GetMCPExternalBaseURL()) SendJSON(ctx, map[string]interface{}{ "issuer": baseURL, diff --git a/transports/bifrost-http/handlers/oauth2_per_user.go b/transports/bifrost-http/handlers/oauth2_per_user.go index 46573c3b6c..5b2bce114f 100644 --- a/transports/bifrost-http/handlers/oauth2_per_user.go +++ b/transports/bifrost-http/handlers/oauth2_per_user.go @@ -505,12 +505,7 @@ func (h *PerUserOAuthHandler) handleUpstreamAuthorize(ctx *fasthttp.RequestCtx) } // Build redirect URI (Bifrost's callback endpoint). - scheme := "http" - if ctx.IsTLS() || string(ctx.Request.Header.Peek("X-Forwarded-Proto")) == "https" { - scheme = "https" - } - host := string(ctx.Host()) - redirectURI := fmt.Sprintf("%s://%s/api/oauth/callback", scheme, host) + redirectURI := lib.BuildBaseURL(ctx, h.store.GetMCPExternalBaseURL()) + "/api/oauth/callback" var vkId *string if virtualKeyID != "" { vkId = &virtualKeyID diff --git a/transports/bifrost-http/handlers/webrtc_realtime_test.go b/transports/bifrost-http/handlers/webrtc_realtime_test.go index f0eef3fc78..a9355d989e 100644 --- a/transports/bifrost-http/handlers/webrtc_realtime_test.go +++ b/transports/bifrost-http/handlers/webrtc_realtime_test.go @@ -30,6 +30,7 @@ func (s testHandlerStore) GetKVStore() *kvstore.Store { re func (s testHandlerStore) GetMCPHeaderCombinedAllowlist() schemas.WhiteList { return nil } func (s testHandlerStore) ShouldAllowPerRequestStorageOverride() bool { return false } func (s testHandlerStore) ShouldAllowPerRequestRawOverride() bool { return false } +func (s testHandlerStore) GetMCPExternalBaseURL() string { return "" } func TestResolveRealtimeSDPTarget_BaseRouteRequiresProviderPrefix(t *testing.T) { _, _, _, err := resolveRealtimeSDPTarget("/v1/realtime", []byte(`{"model":"gpt-4o-realtime-preview"}`)) diff --git a/transports/bifrost-http/handlers/wsresponses_test.go b/transports/bifrost-http/handlers/wsresponses_test.go index 9b8fd78e76..8d365ad7f5 100644 --- a/transports/bifrost-http/handlers/wsresponses_test.go +++ b/transports/bifrost-http/handlers/wsresponses_test.go @@ -51,7 +51,8 @@ func (s testWSHandlerStore) GetMCPHeaderCombinedAllowlist() schemas.WhiteList { } func (s testWSHandlerStore) ShouldAllowPerRequestStorageOverride() bool { return false } -func (s testWSHandlerStore) ShouldAllowPerRequestRawOverride() bool { return false } +func (s testWSHandlerStore) ShouldAllowPerRequestRawOverride() bool { return false } +func (s testWSHandlerStore) GetMCPExternalBaseURL() string { return "" } type timeoutNetError struct{} diff --git a/transports/bifrost-http/integrations/bedrock_test.go b/transports/bifrost-http/integrations/bedrock_test.go index 8c01edaa83..3622117985 100644 --- a/transports/bifrost-http/integrations/bedrock_test.go +++ b/transports/bifrost-http/integrations/bedrock_test.go @@ -62,6 +62,10 @@ func (m *mockHandlerStore) ShouldAllowPerRequestRawOverride() bool { return false } +func (m *mockHandlerStore) GetMCPExternalBaseURL() string { + return "" +} + // Ensure mockHandlerStore implements lib.HandlerStore var _ lib.HandlerStore = (*mockHandlerStore)(nil) diff --git a/transports/bifrost-http/lib/config.go b/transports/bifrost-http/lib/config.go index 77ed94437f..632c77ade5 100644 --- a/transports/bifrost-http/lib/config.go +++ b/transports/bifrost-http/lib/config.go @@ -83,6 +83,9 @@ type HandlerStore interface { ShouldAllowPerRequestStorageOverride() bool // ShouldAllowPerRequestRawOverride returns whether per-request overrides for raw request/response visibility are permitted ShouldAllowPerRequestRawOverride() bool + // GetMCPExternalBaseURL returns the configured external base URL for OAuth callbacks/metadata, + // or empty string if not configured (falls back to dynamic Host-header-based URL). + GetMCPExternalBaseURL() string } // Retry backoff constants for validation @@ -3254,6 +3257,12 @@ func (c *Config) ShouldAllowPerRequestRawOverride() bool { return c.ClientConfig.AllowPerRequestRawOverride } +// GetMCPExternalBaseURL returns the configured external base URL for OAuth callbacks and metadata, +// or empty string if not configured. Resolves env var references automatically. +func (c *Config) GetMCPExternalBaseURL() string { + return c.ClientConfig.MCPExternalBaseURL.GetValue() +} + // GetHeaderMatcher returns the precompiled header matcher for header filtering. // Lock-free via atomic pointer; safe for concurrent reads from hot paths. func (c *Config) GetHeaderMatcher() *HeaderMatcher { diff --git a/transports/bifrost-http/lib/ctx.go b/transports/bifrost-http/lib/ctx.go index 231baa167b..9894df7bec 100644 --- a/transports/bifrost-http/lib/ctx.go +++ b/transports/bifrost-http/lib/ctx.go @@ -10,6 +10,7 @@ import ( "context" "encoding/json" "fmt" + "net/url" "strconv" "strings" "time" @@ -607,13 +608,13 @@ func ConvertToBifrostContext(ctx *fasthttp.RequestCtx, store HandlerStore) (*sch } // Build and set OAuth redirect URI for per-user OAuth flows - scheme := "http" - if ctx.IsTLS() || string(ctx.Request.Header.Peek("X-Forwarded-Proto")) == "https" { - scheme = "https" + var externalBaseURL string + if store != nil { + externalBaseURL = store.GetMCPExternalBaseURL() } - host := string(ctx.Host()) - if host != "" { - bifrostCtx.SetValue(schemas.BifrostContextKeyOAuthRedirectURI, fmt.Sprintf("%s://%s/api/oauth/callback", scheme, host)) + baseURL := BuildBaseURL(ctx, externalBaseURL) + if baseURL != "" { + bifrostCtx.SetValue(schemas.BifrostContextKeyOAuthRedirectURI, baseURL+"/api/oauth/callback") } if allowDirectKeys { @@ -665,6 +666,31 @@ func ConvertToBifrostContext(ctx *fasthttp.RequestCtx, store HandlerStore) (*sch return bifrostCtx, cancel } +// BuildBaseURL returns the effective base URL for OAuth callbacks and metadata discovery. +// When externalBaseURL is non-empty (set via config/UI/API), it takes priority so that +// deployments behind a reverse proxy advertise the proxy's public URL rather than the +// internal Host header seen by Bifrost. +func BuildBaseURL(ctx *fasthttp.RequestCtx, externalBaseURL string) string { + if override := strings.TrimRight(strings.TrimSpace(externalBaseURL), "/"); override != "" { + if parsed, err := url.Parse(override); err == nil && parsed.Scheme != "" && parsed.Host != "" { + return override + } + } + scheme := "http" + xfProto := strings.ToLower(strings.TrimSpace(string(ctx.Request.Header.Peek("X-Forwarded-Proto")))) + if comma := strings.IndexByte(xfProto, ','); comma >= 0 { + xfProto = strings.TrimSpace(xfProto[:comma]) + } + if ctx.IsTLS() || xfProto == "https" { + scheme = "https" + } + host := string(ctx.Host()) + if host == "" { + return "" + } + return fmt.Sprintf("%s://%s", scheme, host) +} + // BuildHTTPRequestFromFastHTTP creates an HTTPRequest from fasthttp context for streaming handlers. // The returned request should be released with schemas.ReleaseHTTPRequest when done. // Note: Body is not copied for streaming (body was already consumed for the request). diff --git a/transports/bifrost-http/lib/ctx_test.go b/transports/bifrost-http/lib/ctx_test.go index 924196249c..b85f3fa29f 100644 --- a/transports/bifrost-http/lib/ctx_test.go +++ b/transports/bifrost-http/lib/ctx_test.go @@ -30,6 +30,7 @@ func (s testHandlerStore) GetMCPHeaderCombinedAllowlist() schemas.WhiteList { } func (s testHandlerStore) ShouldAllowPerRequestStorageOverride() bool { return false } func (s testHandlerStore) ShouldAllowPerRequestRawOverride() bool { return false } +func (s testHandlerStore) GetMCPExternalBaseURL() string { return "" } func TestParseSessionIDFromBaggage(t *testing.T) { tests := []struct { diff --git a/transports/config.schema.json b/transports/config.schema.json index fd69a78878..72a42da3d9 100644 --- a/transports/config.schema.json +++ b/transports/config.schema.json @@ -227,6 +227,23 @@ "minimum": 1, "description": "Maximum depth for routing rule chain evaluation", "default": 10 + }, + "mcp_external_base_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "value": { "type": "string" }, + "env_var": { "type": "string" }, + "from_env": { "type": "boolean" } + }, + "additionalProperties": false + } + ], + "description": "Public base URL for OAuth callbacks and discovery metadata when Bifrost runs behind a reverse proxy (e.g. \"https://api.example.com\"). Supports env var syntax: \"env.MY_VAR\"." } }, "additionalProperties": false diff --git a/ui/app/workspace/config/views/mcpView.tsx b/ui/app/workspace/config/views/mcpView.tsx index 551555a22b..7e1918a207 100644 --- a/ui/app/workspace/config/views/mcpView.tsx +++ b/ui/app/workspace/config/views/mcpView.tsx @@ -1,4 +1,5 @@ import { Button } from "@/components/ui/button"; +import { EnvVarInput } from "@/components/ui/envVarInput"; import { Input } from "@/components/ui/input"; import { Select, @@ -14,6 +15,7 @@ import { useUpdateCoreConfigMutation, } from "@/lib/store"; import { CoreConfig, DefaultCoreConfig } from "@/lib/types/config"; +import { EnvVar } from "@/lib/types/schemas"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -57,6 +59,10 @@ export default function MCPView() { const hasChanges = useMemo(() => { if (!config) return false; + const externalBaseURLChanged = + (localConfig.mcp_external_base_url?.value ?? "") !== (config.mcp_external_base_url?.value ?? "") || + (localConfig.mcp_external_base_url?.env_var ?? "") !== (config.mcp_external_base_url?.env_var ?? "") || + (localConfig.mcp_external_base_url?.from_env ?? false) !== (config.mcp_external_base_url?.from_env ?? false); return ( localConfig.mcp_agent_depth !== config.mcp_agent_depth || localConfig.mcp_tool_execution_timeout !== @@ -66,7 +72,8 @@ export default function MCPView() { localConfig.mcp_tool_sync_interval !== (config.mcp_tool_sync_interval ?? 10) || localConfig.mcp_disable_auto_tool_inject !== - (config.mcp_disable_auto_tool_inject ?? false) + (config.mcp_disable_auto_tool_inject ?? false) || + externalBaseURLChanged ); }, [config, localConfig]); @@ -319,6 +326,29 @@ export default function MCPView() { )} + {/* External Base URL */} +
+
+ +

+ Override the base URL used for OAuth callbacks and discovery metadata ( + /.well-known/oauth-authorization-server, etc.) when Bifrost runs behind a reverse proxy. + Supports env var syntax (e.g. env.BIFROST_EXTERNAL_URL). +

+
+ + setLocalConfig((prev) => ({ ...prev, mcp_external_base_url: value })) + } + disabled={!hasSettingsUpdateAccess} + /> +