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
34 changes: 34 additions & 0 deletions docs/deployment-guides/config-json/client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions docs/deployment-guides/config-json/schema-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
48 changes: 48 additions & 0 deletions docs/openapi/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
},
Expand Down Expand Up @@ -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"
}
}
},
Expand Down
17 changes: 17 additions & 0 deletions docs/openapi/schemas/management/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
Pratham-Mishra04 marked this conversation as resolved.

FrameworkConfig:
type: object
Expand Down
9 changes: 9 additions & 0 deletions framework/configstore/clientconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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()))
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return hex.EncodeToString(hash.Sum(nil)), nil
}

Expand Down
33 changes: 33 additions & 0 deletions framework/configstore/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
16 changes: 16 additions & 0 deletions framework/configstore/rdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions framework/configstore/tables/clientconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions transports/bifrost-http/handlers/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 2 additions & 13 deletions transports/bifrost-http/handlers/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 1 addition & 6 deletions transports/bifrost-http/handlers/mcpserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
16 changes: 2 additions & 14 deletions transports/bifrost-http/handlers/oauth2_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down
7 changes: 1 addition & 6 deletions transports/bifrost-http/handlers/oauth2_per_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions transports/bifrost-http/handlers/webrtc_realtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}`))
Expand Down
3 changes: 2 additions & 1 deletion transports/bifrost-http/handlers/wsresponses_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}

Expand Down
4 changes: 4 additions & 0 deletions transports/bifrost-http/integrations/bedrock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Loading