diff --git a/core/mcp/clientmanager.go b/core/mcp/clientmanager.go index c217bc8c60..72700464c8 100644 --- a/core/mcp/clientmanager.go +++ b/core/mcp/clientmanager.go @@ -243,14 +243,15 @@ func (m *MCPManager) UpdateClient(id string, updatedConfig *schemas.MCPClientCon ConfigHash: client.ExecutionConfig.ConfigHash, ToolPricing: maps.Clone(client.ExecutionConfig.ToolPricing), // Updatable fields - copy from updated config with proper cloning - Name: updatedConfig.Name, - IsCodeModeClient: updatedConfig.IsCodeModeClient, - Headers: maps.Clone(updatedConfig.Headers), - ToolsToExecute: slices.Clone(updatedConfig.ToolsToExecute), - ToolsToAutoExecute: slices.Clone(updatedConfig.ToolsToAutoExecute), - AllowedExtraHeaders: slices.Clone(updatedConfig.AllowedExtraHeaders), - IsPingAvailable: updatedConfig.IsPingAvailable, - ToolSyncInterval: updatedConfig.ToolSyncInterval, + Name: updatedConfig.Name, + IsCodeModeClient: updatedConfig.IsCodeModeClient, + Headers: maps.Clone(updatedConfig.Headers), + ToolsToExecute: slices.Clone(updatedConfig.ToolsToExecute), + ToolsToAutoExecute: slices.Clone(updatedConfig.ToolsToAutoExecute), + AllowedExtraHeaders: slices.Clone(updatedConfig.AllowedExtraHeaders), + IsPingAvailable: updatedConfig.IsPingAvailable, + ToolSyncInterval: updatedConfig.ToolSyncInterval, + AllowOnAllVirtualKeys: updatedConfig.AllowOnAllVirtualKeys, } // Atomically replace the config pointer diff --git a/core/schemas/mcp.go b/core/schemas/mcp.go index 898353499f..5cfb19a004 100644 --- a/core/schemas/mcp.go +++ b/core/schemas/mcp.go @@ -102,10 +102,11 @@ type MCPClientConfig struct { // - nil/omitted => treated as [] (no tools) // - ["tool1", "tool2"] => auto-execute only the specified tools // Note: If a tool is in ToolsToAutoExecute but not in ToolsToExecute, it will be skipped. - IsPingAvailable *bool `json:"is_ping_available,omitempty"` // Whether the MCP server supports ping for health checks (nil/true = ping; false = listTools). Defaults to true. - ToolSyncInterval time.Duration `json:"tool_sync_interval,omitempty"` // Per-client override for tool sync interval (0 = use global, negative = disabled) - ToolPricing map[string]float64 `json:"tool_pricing,omitempty"` // Tool pricing for each tool (cost per execution) - ConfigHash string `json:"-"` // Config hash for reconciliation (not serialized) + IsPingAvailable *bool `json:"is_ping_available,omitempty"` // Whether the MCP server supports ping for health checks (nil/true = ping; false = listTools). Defaults to true. + ToolSyncInterval time.Duration `json:"tool_sync_interval,omitempty"` // Per-client override for tool sync interval (0 = use global, negative = disabled) + ToolPricing map[string]float64 `json:"tool_pricing,omitempty"` // Tool pricing for each tool (cost per execution) + ConfigHash string `json:"-"` // Config hash for reconciliation (not serialized) + AllowOnAllVirtualKeys bool `json:"allow_on_all_virtual_keys"` // Whether to allow the MCP client to run on all virtual keys } // NewMCPClientConfigFromMap creates a new MCP client config from a map[string]any. diff --git a/docs/openapi/schemas/management/mcp.yaml b/docs/openapi/schemas/management/mcp.yaml index 97f0c9f9ea..893885ca36 100644 --- a/docs/openapi/schemas/management/mcp.yaml +++ b/docs/openapi/schemas/management/mcp.yaml @@ -120,6 +120,13 @@ MCPClientCreateRequestBase: ["*"] => all executable tools can be auto-executed [] => no tools are auto-executed ["tool1", "tool2"] => only specified tools can be auto-executed + allow_on_all_virtual_keys: + type: boolean + default: false + description: | + 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. MCPClientCreateRequestHTTP: allOf: - $ref: '#/MCPClientCreateRequestBase' @@ -227,6 +234,13 @@ MCPClientUpdateRequest: Key is the tool name, value is the cost per execution. Example: {"read_file": 0.001, "write_file": 0.002} Note: Only available when updating an existing client after tools have been fetched. + allow_on_all_virtual_keys: + type: boolean + default: false + description: | + When true, this MCP client's tools are accessible to all virtual keys without requiring + explicit per-key assignment. All tools are allowed by default. If a virtual key has an + explicit MCP config for this client, that config takes precedence and overrides this behaviour. vk_configs: type: array items: @@ -320,6 +334,13 @@ MCPClientConfig: Per-tool cost in USD for execution. Key is the tool name, value is the cost per execution. Example: {"read_file": 0.001, "write_file": 0.002} + allow_on_all_virtual_keys: + type: boolean + default: false + description: | + When true, this MCP client's tools are accessible to all virtual keys without requiring + explicit per-key assignment. All tools are allowed by default. If a virtual key has an + explicit MCP config for this client, that config takes precedence and overrides this behaviour. ChatToolFunction: type: object diff --git a/framework/changelog.md b/framework/changelog.md index d19f534f76..736e620e90 100644 --- a/framework/changelog.md +++ b/framework/changelog.md @@ -2,3 +2,4 @@ - feat: add MCPDisableAutoToolInject column to TableClientConfig - refactor: standardize empty array conventions in modelcatalog and tables. - feat: add AllowedExtraHeadersJSON column to TableMCPClient +- feat: add AllowOnAllVirtualKeys column to TableMCPClient \ No newline at end of file diff --git a/framework/configstore/migrations.go b/framework/configstore/migrations.go index 8f674615c4..701b42d985 100644 --- a/framework/configstore/migrations.go +++ b/framework/configstore/migrations.go @@ -335,6 +335,9 @@ func triggerMigrations(ctx context.Context, db *gorm.DB) error { if err := migrationMakeBasePricingColumnsNullable(ctx, db); err != nil { return err } + if err := migrationAddAllowOnAllVirtualKeysColumn(ctx, db); err != nil { + return err + } return nil } @@ -5202,3 +5205,33 @@ func migrationMakeBasePricingColumnsNullable(ctx context.Context, db *gorm.DB) e } return nil } + +func migrationAddAllowOnAllVirtualKeysColumn(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_allow_on_all_virtual_keys_column", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if !migrator.HasColumn(&tables.TableMCPClient{}, "allow_on_all_virtual_keys") { + if err := migrator.AddColumn(&tables.TableMCPClient{}, "allow_on_all_virtual_keys"); err != nil { + return fmt.Errorf("failed to add allow_on_all_virtual_keys column: %w", err) + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + if migrator.HasColumn(&tables.TableMCPClient{}, "allow_on_all_virtual_keys") { + if err := migrator.DropColumn(&tables.TableMCPClient{}, "allow_on_all_virtual_keys"); err != nil { + return fmt.Errorf("failed to drop allow_on_all_virtual_keys column: %w", err) + } + } + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error while running add_allow_on_all_virtual_keys_column migration: %s", err.Error()) + } + return nil +} diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index a3cad08f76..1bb1c10339 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -866,21 +866,22 @@ func (s *RDBConfigStore) GetMCPConfig(ctx context.Context) (*schemas.MCPConfig, clientConfigs := make([]*schemas.MCPClientConfig, len(dbMCPClients)) for i, dbClient := range dbMCPClients { clientConfigs[i] = &schemas.MCPClientConfig{ - ID: dbClient.ClientID, - Name: dbClient.Name, - IsCodeModeClient: dbClient.IsCodeModeClient, - ConnectionType: schemas.MCPConnectionType(dbClient.ConnectionType), - ConnectionString: dbClient.ConnectionString, - StdioConfig: dbClient.StdioConfig, - AuthType: schemas.MCPAuthType(dbClient.AuthType), - OauthConfigID: dbClient.OauthConfigID, - ToolsToExecute: dbClient.ToolsToExecute, - ToolsToAutoExecute: dbClient.ToolsToAutoExecute, - Headers: dbClient.Headers, - AllowedExtraHeaders: dbClient.AllowedExtraHeaders, - IsPingAvailable: dbClient.IsPingAvailable, - ToolSyncInterval: time.Duration(dbClient.ToolSyncInterval) * time.Minute, - ToolPricing: dbClient.ToolPricing, + ID: dbClient.ClientID, + Name: dbClient.Name, + IsCodeModeClient: dbClient.IsCodeModeClient, + ConnectionType: schemas.MCPConnectionType(dbClient.ConnectionType), + ConnectionString: dbClient.ConnectionString, + StdioConfig: dbClient.StdioConfig, + AuthType: schemas.MCPAuthType(dbClient.AuthType), + OauthConfigID: dbClient.OauthConfigID, + ToolsToExecute: dbClient.ToolsToExecute, + ToolsToAutoExecute: dbClient.ToolsToAutoExecute, + Headers: dbClient.Headers, + AllowedExtraHeaders: dbClient.AllowedExtraHeaders, + IsPingAvailable: dbClient.IsPingAvailable, + ToolSyncInterval: time.Duration(dbClient.ToolSyncInterval) * time.Minute, + ToolPricing: dbClient.ToolPricing, + AllowOnAllVirtualKeys: dbClient.AllowOnAllVirtualKeys, } } return &schemas.MCPConfig{ @@ -902,21 +903,22 @@ func (s *RDBConfigStore) GetMCPConfig(ctx context.Context) (*schemas.MCPConfig, clientConfigs := make([]*schemas.MCPClientConfig, len(dbMCPClients)) for i, dbClient := range dbMCPClients { clientConfigs[i] = &schemas.MCPClientConfig{ - ID: dbClient.ClientID, - Name: dbClient.Name, - IsCodeModeClient: dbClient.IsCodeModeClient, - ConnectionType: schemas.MCPConnectionType(dbClient.ConnectionType), - ConnectionString: dbClient.ConnectionString, - StdioConfig: dbClient.StdioConfig, - AuthType: schemas.MCPAuthType(dbClient.AuthType), - OauthConfigID: dbClient.OauthConfigID, - ToolsToExecute: dbClient.ToolsToExecute, - ToolsToAutoExecute: dbClient.ToolsToAutoExecute, - Headers: dbClient.Headers, - AllowedExtraHeaders: dbClient.AllowedExtraHeaders, - IsPingAvailable: dbClient.IsPingAvailable, - ToolSyncInterval: time.Duration(dbClient.ToolSyncInterval) * time.Minute, - ToolPricing: dbClient.ToolPricing, + ID: dbClient.ClientID, + Name: dbClient.Name, + IsCodeModeClient: dbClient.IsCodeModeClient, + ConnectionType: schemas.MCPConnectionType(dbClient.ConnectionType), + ConnectionString: dbClient.ConnectionString, + StdioConfig: dbClient.StdioConfig, + AuthType: schemas.MCPAuthType(dbClient.AuthType), + OauthConfigID: dbClient.OauthConfigID, + ToolsToExecute: dbClient.ToolsToExecute, + ToolsToAutoExecute: dbClient.ToolsToAutoExecute, + Headers: dbClient.Headers, + AllowedExtraHeaders: dbClient.AllowedExtraHeaders, + IsPingAvailable: dbClient.IsPingAvailable, + ToolSyncInterval: time.Duration(dbClient.ToolSyncInterval) * time.Minute, + AllowOnAllVirtualKeys: dbClient.AllowOnAllVirtualKeys, + ToolPricing: dbClient.ToolPricing, } } return &schemas.MCPConfig{ @@ -1001,20 +1003,21 @@ func (s *RDBConfigStore) CreateMCPClientConfig(ctx context.Context, clientConfig } // Create new client dbClient := tables.TableMCPClient{ - ClientID: clientConfigCopy.ID, - Name: clientConfigCopy.Name, - IsCodeModeClient: clientConfigCopy.IsCodeModeClient, - ConnectionType: string(clientConfigCopy.ConnectionType), - ConnectionString: clientConfigCopy.ConnectionString, - StdioConfig: clientConfigCopy.StdioConfig, - AuthType: string(clientConfigCopy.AuthType), - OauthConfigID: clientConfigCopy.OauthConfigID, - ToolsToExecute: clientConfigCopy.ToolsToExecute, - ToolsToAutoExecute: clientConfigCopy.ToolsToAutoExecute, - Headers: clientConfigCopy.Headers, - AllowedExtraHeaders: clientConfigCopy.AllowedExtraHeaders, - IsPingAvailable: clientConfigCopy.IsPingAvailable, - ToolSyncInterval: int(clientConfigCopy.ToolSyncInterval.Minutes()), + ClientID: clientConfigCopy.ID, + Name: clientConfigCopy.Name, + IsCodeModeClient: clientConfigCopy.IsCodeModeClient, + ConnectionType: string(clientConfigCopy.ConnectionType), + ConnectionString: clientConfigCopy.ConnectionString, + StdioConfig: clientConfigCopy.StdioConfig, + AuthType: string(clientConfigCopy.AuthType), + OauthConfigID: clientConfigCopy.OauthConfigID, + ToolsToExecute: clientConfigCopy.ToolsToExecute, + ToolsToAutoExecute: clientConfigCopy.ToolsToAutoExecute, + Headers: clientConfigCopy.Headers, + AllowedExtraHeaders: clientConfigCopy.AllowedExtraHeaders, + IsPingAvailable: clientConfigCopy.IsPingAvailable, + ToolSyncInterval: int(clientConfigCopy.ToolSyncInterval.Minutes()), + AllowOnAllVirtualKeys: clientConfigCopy.AllowOnAllVirtualKeys, } if err := tx.WithContext(ctx).Create(&dbClient).Error; err != nil { return s.parseGormError(err) @@ -1109,6 +1112,7 @@ func (s *RDBConfigStore) UpdateMCPClientConfig(ctx context.Context, id string, c "allowed_extra_headers_json": string(allowedExtraHeadersJSON), "tool_pricing_json": string(toolPricingJSON), "tool_sync_interval": clientConfigCopy.ToolSyncInterval, + "allow_on_all_virtual_keys": clientConfigCopy.AllowOnAllVirtualKeys, "updated_at": time.Now(), } if encrypt.IsEnabled() { diff --git a/framework/configstore/tables/mcp.go b/framework/configstore/tables/mcp.go index bdecb80b3f..acf8672fa9 100644 --- a/framework/configstore/tables/mcp.go +++ b/framework/configstore/tables/mcp.go @@ -33,6 +33,8 @@ type TableMCPClient struct { OauthConfigID *string `gorm:"type:varchar(255);index;constraint:OnDelete:CASCADE" json:"oauth_config_id"` // Foreign key to oauth_configs.ID with CASCADE delete OauthConfig *TableOauthConfig `gorm:"foreignKey:OauthConfigID;references:ID;constraint:OnDelete:CASCADE" json:"-"` // Gorm relationship + AllowOnAllVirtualKeys bool `gorm:"default:false" json:"allow_on_all_virtual_keys"` // Whether to allow the MCP client to run on all virtual keys + // Config hash is used to detect the changes synced from config.json file // Every time we sync the config.json file, we will update the config hash ConfigHash string `gorm:"type:varchar(255);null" json:"config_hash"` diff --git a/plugins/governance/allow_on_all_virtual_keys_test.go b/plugins/governance/allow_on_all_virtual_keys_test.go new file mode 100644 index 0000000000..35c6d0f412 --- /dev/null +++ b/plugins/governance/allow_on_all_virtual_keys_test.go @@ -0,0 +1,167 @@ +package governance + +import ( + "testing" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/maximhq/bifrost/framework/configstore" + configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" + "github.com/stretchr/testify/assert" +) + +// mockInMemoryStore is a test double for InMemoryStore. +type mockInMemoryStore struct { + allowAllClients map[string]string // clientID → clientName +} + +func (m *mockInMemoryStore) GetConfiguredProviders() map[schemas.ModelProvider]configstore.ProviderConfig { + return nil +} + +func (m *mockInMemoryStore) GetMCPClientsAllowingAllVirtualKeys() map[string]string { + return m.allowAllClients +} + +// newPluginWithInMemoryStore builds a minimal GovernancePlugin wired with a mock InMemoryStore. +func newPluginWithInMemoryStore(store InMemoryStore) *GovernancePlugin { + return &GovernancePlugin{inMemoryStore: store} +} + +// buildVKWithMCPConfigs returns a VK that has explicit MCPConfigs for the given client. +func buildVKWithMCPConfigs(clientID, clientName string, tools []string) *configstoreTables.TableVirtualKey { + return &configstoreTables.TableVirtualKey{ + ID: "vk-1", + Name: "test-vk", + MCPConfigs: []configstoreTables.TableVirtualKeyMCPConfig{ + { + MCPClient: configstoreTables.TableMCPClient{ + ClientID: clientID, + Name: clientName, + }, + ToolsToExecute: tools, + }, + }, + } +} + +// buildVKNoMCPConfigs returns a VK with no MCPConfigs at all. +func buildVKNoMCPConfigs() *configstoreTables.TableVirtualKey { + return &configstoreTables.TableVirtualKey{ + ID: "vk-2", + Name: "test-vk-empty", + } +} + +// ============================================================================ +// isMCPToolAllowedByVKWith — AllowOnAllVirtualKeys scenarios +// ============================================================================ + +// VK with no MCPConfigs + AllowOnAllVirtualKeys client → tools allowed +func TestIsMCPToolAllowedByVKWith_NoVKConfig_AllowAllEnabled(t *testing.T) { + p := newPluginWithInMemoryStore(&mockInMemoryStore{ + allowAllClients: map[string]string{"client-1": "youtube"}, + }) + vk := buildVKNoMCPConfigs() + + assert.True(t, p.isMCPToolAllowedByVKWith(vk, "youtube-search", map[string]string{"client-1": "youtube"}), + "specific tool should be allowed when AllowOnAllVirtualKeys is set and VK has no explicit config") + + assert.True(t, p.isMCPToolAllowedByVKWith(vk, "youtube-*", map[string]string{"client-1": "youtube"}), + "wildcard pattern should be allowed when AllowOnAllVirtualKeys is set and VK has no explicit config") +} + +// VK with explicit empty tools config for an AllowOnAllVirtualKeys client → tools blocked +func TestIsMCPToolAllowedByVKWith_ExplicitEmptyConfig_Blocks(t *testing.T) { + p := newPluginWithInMemoryStore(&mockInMemoryStore{ + allowAllClients: map[string]string{"client-1": "youtube"}, + }) + // Explicit VK config with empty tools list (deny-all for this client) + vk := buildVKWithMCPConfigs("client-1", "youtube", []string{}) + + assert.False(t, p.isMCPToolAllowedByVKWith(vk, "youtube-search", map[string]string{"client-1": "youtube"}), + "explicit empty tools list should block access even when AllowOnAllVirtualKeys is set") + + assert.False(t, p.isMCPToolAllowedByVKWith(vk, "youtube-*", map[string]string{"client-1": "youtube"}), + "wildcard should be blocked when explicit config has empty tools list") +} + +// VK with explicit ["tool1"] config for an AllowOnAllVirtualKeys client → only tool1 allowed +func TestIsMCPToolAllowedByVKWith_ExplicitPartialConfig_OnlyListedToolsAllowed(t *testing.T) { + p := newPluginWithInMemoryStore(&mockInMemoryStore{ + allowAllClients: map[string]string{"client-1": "youtube"}, + }) + vk := buildVKWithMCPConfigs("client-1", "youtube", []string{"search"}) + + assert.True(t, p.isMCPToolAllowedByVKWith(vk, "youtube-search", map[string]string{"client-1": "youtube"}), + "explicitly listed tool should be allowed") + + assert.False(t, p.isMCPToolAllowedByVKWith(vk, "youtube-upload", map[string]string{"client-1": "youtube"}), + "non-listed tool should be blocked even when AllowOnAllVirtualKeys is set") +} + +// inMemoryStore is nil → AllowOnAllVirtualKeys clients are treated as not configured (all blocked) +func TestIsMCPToolAllowedByVKWith_NilInMemoryStore_AllBlocked(t *testing.T) { + p := &GovernancePlugin{inMemoryStore: nil} + vk := buildVKNoMCPConfigs() + + allowed := p.isMCPToolAllowedByVKWith(vk, "youtube-search", nil) + assert.False(t, allowed, + "nil inMemoryStore means no AllowOnAllVirtualKeys clients; tool should be blocked") +} + +// Wildcard pattern (clientName-*) with AllowOnAllVirtualKeys client and no VK config → allowed +func TestIsMCPToolAllowedByVKWith_WildcardPattern_AllowAll_NoVKConfig(t *testing.T) { + p := newPluginWithInMemoryStore(&mockInMemoryStore{ + allowAllClients: map[string]string{"client-1": "youtube"}, + }) + vk := buildVKNoMCPConfigs() + + assert.True(t, p.isMCPToolAllowedByVKWith(vk, "youtube-*", map[string]string{"client-1": "youtube"}), + "clientName-* wildcard should match AllowOnAllVirtualKeys fallback") +} + +// Explicit unrestricted config (["*"]) for AllowOnAllVirtualKeys client → all tools allowed +func TestIsMCPToolAllowedByVKWith_ExplicitUnrestrictedConfig_AllowsAll(t *testing.T) { + p := newPluginWithInMemoryStore(&mockInMemoryStore{ + allowAllClients: map[string]string{"client-1": "youtube"}, + }) + vk := buildVKWithMCPConfigs("client-1", "youtube", []string{"*"}) + + assert.True(t, p.isMCPToolAllowedByVKWith(vk, "youtube-search", map[string]string{"client-1": "youtube"}), + "unrestricted explicit config should allow all tools") + + assert.True(t, p.isMCPToolAllowedByVKWith(vk, "youtube-*", map[string]string{"client-1": "youtube"}), + "wildcard should match when explicit config is unrestricted") +} + +// Tool belonging to a different client is not allowed via AllowOnAllVirtualKeys of another client +func TestIsMCPToolAllowedByVKWith_DifferentClient_Blocked(t *testing.T) { + p := newPluginWithInMemoryStore(&mockInMemoryStore{ + allowAllClients: map[string]string{"client-1": "youtube"}, + }) + vk := buildVKNoMCPConfigs() + + assert.False(t, p.isMCPToolAllowedByVKWith(vk, "github-list_repos", map[string]string{"client-1": "youtube"}), + "tool from a different client should not be allowed via another client's AllowOnAllVirtualKeys") +} + +// isMCPToolAllowedByVK delegates to inMemoryStore correctly +func TestIsMCPToolAllowedByVK_UsesInMemoryStore(t *testing.T) { + store := &mockInMemoryStore{ + allowAllClients: map[string]string{"client-1": "youtube"}, + } + p := newPluginWithInMemoryStore(store) + vk := buildVKNoMCPConfigs() + + assert.True(t, p.isMCPToolAllowedByVK(vk, "youtube-search"), + "isMCPToolAllowedByVK should use inMemoryStore to resolve AllowOnAllVirtualKeys") +} + +// isMCPToolAllowedByVK with nil inMemoryStore → blocked +func TestIsMCPToolAllowedByVK_NilStore_Blocked(t *testing.T) { + p := &GovernancePlugin{inMemoryStore: nil} + vk := buildVKNoMCPConfigs() + + assert.False(t, p.isMCPToolAllowedByVK(vk, "youtube-search"), + "nil inMemoryStore should result in blocked access") +} diff --git a/plugins/governance/changelog.md b/plugins/governance/changelog.md index f4a9d7c43c..ea066bb51f 100644 --- a/plugins/governance/changelog.md +++ b/plugins/governance/changelog.md @@ -1,2 +1,3 @@ - feat: migrate VK provider config allowed keys to deny-by-default — `key_ids: ["*"]` (API) / `allowed_keys: ["*"]` (config) maps to `AllowAllKeys=true` without DB lookups; empty list sets `AllowAllKeys=false` and blocks all keys; resolver populates `includeOnlyKeys` for specific keys or clears it to nil for allow-all to prevent stale filters - feat: enforce VK MCPConfigs as an execution-time allow-list — empty MCPConfigs denies all MCP tools, non-empty validates each tool in both PreMCPHook and evaluateGovernanceRequest; respects disable_auto_tool_inject toggle (transport config key: mcp_disable_auto_tool_inject) and skips auto-injection header when caller already set it +- feat: adds handling for MCP clients with AllowOnAllVirtualKeys to allow all tools for all virtual keys. \ No newline at end of file diff --git a/plugins/governance/main.go b/plugins/governance/main.go index 0f6e8dd796..106e2be11d 100644 --- a/plugins/governance/main.go +++ b/plugins/governance/main.go @@ -45,6 +45,7 @@ type Config struct { type InMemoryStore interface { GetConfiguredProviders() map[schemas.ModelProvider]configstore.ProviderConfig + GetMCPClientsAllowingAllVirtualKeys() map[string]string // clientID → clientName } type BaseGovernancePlugin interface { @@ -895,33 +896,48 @@ func (p *GovernancePlugin) addMCPIncludeTools(headers map[string]string, virtual headers = make(map[string]string) } - // Empty MCPConfigs means no MCP tools are allowed (deny-by-default) - if len(virtualKey.MCPConfigs) == 0 { - headers["x-bf-mcp-include-tools"] = "" - return headers, nil + executeOnlyTools := make([]string, 0) + + // Build a lookup of AllowOnAllVirtualKeys clients: clientID -> clientName + var allowAllVKsClients map[string]string + if p.inMemoryStore != nil { + allowAllVKsClients = p.inMemoryStore.GetMCPClientsAllowingAllVirtualKeys() + } + if allowAllVKsClients == nil { + allowAllVKsClients = make(map[string]string) } - executeOnlyTools := make([]string, 0) + // Process VK-specific MCP configs first — explicit config always overrides AllowOnAllVirtualKeys. + // Track which AllowOnAllVirtualKeys clients have an explicit VK config so we don't double-add them. + handledClients := make(map[string]bool) for _, vkMcpConfig := range virtualKey.MCPConfigs { + clientID := vkMcpConfig.MCPClient.ClientID + if _, isAllowAll := allowAllVKsClients[clientID]; isAllowAll { + // Explicit VK config exists — it takes precedence; mark as handled regardless of tool list + handledClients[clientID] = true + } if vkMcpConfig.ToolsToExecute.IsEmpty() { // No tools specified in virtual key config - skip this client entirely continue } - // Handle wildcard in virtual key config - allow all tools from this client if vkMcpConfig.ToolsToExecute.IsUnrestricted() { - // Virtual key uses wildcard - use client-specific wildcard executeOnlyTools = append(executeOnlyTools, fmt.Sprintf("%s-*", vkMcpConfig.MCPClient.Name)) continue } - for _, tool := range vkMcpConfig.ToolsToExecute { if tool != "" { - // Add the tool - client config filtering will be handled by mcp.go executeOnlyTools = append(executeOnlyTools, fmt.Sprintf("%s-%s", vkMcpConfig.MCPClient.Name, tool)) } } } + // For AllowOnAllVirtualKeys clients with no explicit VK config, fall back to allowing all tools + for clientID, clientName := range allowAllVKsClients { + if !handledClients[clientID] { + executeOnlyTools = append(executeOnlyTools, fmt.Sprintf("%s-*", clientName)) + } + } + // Set even when empty to exclude tools when no tools are present in the virtual key config headers["x-bf-mcp-include-tools"] = strings.Join(executeOnlyTools, ",") @@ -1020,26 +1036,23 @@ func (p *GovernancePlugin) evaluateGovernanceRequest(ctx *schemas.BifrostContext // than raw header patterns (e.g. "youtube-*"), giving us exact per-tool validation. if result.Decision == DecisionAllow && result.VirtualKey != nil { if addedTools, ok := ctx.Value(schemas.BifrostContextKeyMCPAddedTools).([]string); ok && len(addedTools) > 0 { - if len(result.VirtualKey.MCPConfigs) == 0 { + // Fetch once before the loop to avoid repeated lock acquisitions per tool. + var allowAllClients map[string]string + if p.inMemoryStore != nil { + allowAllClients = p.inMemoryStore.GetMCPClientsAllowingAllVirtualKeys() + } + var disallowed []string + for _, tool := range addedTools { + if !p.isMCPToolAllowedByVKWith(result.VirtualKey, tool, allowAllClients) { + disallowed = append(disallowed, tool) + } + } + if len(disallowed) > 0 { result = &EvaluationResult{ Decision: DecisionMCPToolBlocked, - Reason: fmt.Sprintf("no MCP tools are configured for virtual key '%s'", result.VirtualKey.Name), + Reason: fmt.Sprintf("MCP tools not allowed for virtual key '%s': %s", result.VirtualKey.Name, strings.Join(disallowed, ", ")), VirtualKey: result.VirtualKey, } - } else { - var disallowed []string - for _, tool := range addedTools { - if !isMCPToolAllowedByVK(result.VirtualKey, tool) { - disallowed = append(disallowed, tool) - } - } - if len(disallowed) > 0 { - result = &EvaluationResult{ - Decision: DecisionMCPToolBlocked, - Reason: fmt.Sprintf("MCP tools not allowed for virtual key '%s': %s", result.VirtualKey.Name, strings.Join(disallowed, ", ")), - VirtualKey: result.VirtualKey, - } - } } } } @@ -1108,28 +1121,45 @@ func (p *GovernancePlugin) evaluateGovernanceRequest(ctx *schemas.BifrostContext // isMCPToolAllowedByVK checks whether a tool pattern (in "clientName-toolName" or "clientName-*" // format) is permitted by the virtual key's MCPConfigs. // +// Priority order: +// 1. If the VK has an explicit MCP config for this client, that config is authoritative (can allow or deny). +// 2. If no explicit config exists and the client has AllowOnAllVirtualKeys=true, all tools are allowed. +// // For wildcard patterns ("clientName-*"): allowed if VK has the client configured with any tools. // Specific tool enforcement happens at execution time via checkVKMCPToolAllowance. // For specific tools ("clientName-toolName"): allowed if VK has "*" or the exact tool name. -func isMCPToolAllowedByVK(vk *configstoreTables.TableVirtualKey, toolPattern string) bool { +func (p *GovernancePlugin) isMCPToolAllowedByVK(vk *configstoreTables.TableVirtualKey, toolPattern string) bool { + var allowAllClients map[string]string + if p.inMemoryStore != nil { + allowAllClients = p.inMemoryStore.GetMCPClientsAllowingAllVirtualKeys() + } + return p.isMCPToolAllowedByVKWith(vk, toolPattern, allowAllClients) +} + +// isMCPToolAllowedByVKWith checks whether a tool pattern is allowed by the virtual key, +// using a pre-fetched allowAllClients map (clientID → clientName) to avoid repeated lock +// acquisitions in loops. +func (p *GovernancePlugin) isMCPToolAllowedByVKWith(vk *configstoreTables.TableVirtualKey, toolPattern string, allowAllClients map[string]string) bool { + // Check VK-specific MCP configs first — explicit config always overrides AllowOnAllVirtualKeys. for _, mcpConfig := range vk.MCPConfigs { clientName := mcpConfig.MCPClient.Name - // Wildcard pattern "clientName-*": VK just needs to have this client configured at all. - if toolPattern == clientName+"-*" { - if !mcpConfig.ToolsToExecute.IsEmpty() { - return true - } + if toolPattern != clientName+"-*" && !strings.HasPrefix(toolPattern, clientName+"-") { continue } - // Specific tool "clientName-toolName" - if strings.HasPrefix(toolPattern, clientName+"-") { - if mcpConfig.ToolsToExecute.IsUnrestricted() { - return true - } - toolSuffix := strings.TrimPrefix(toolPattern, clientName+"-") - if mcpConfig.ToolsToExecute.Contains(toolSuffix) { - return true - } + // Found an explicit config for this client — use it; do not fall back to AllowOnAllVirtualKeys. + if toolPattern == clientName+"-*" { + return !mcpConfig.ToolsToExecute.IsEmpty() + } + if mcpConfig.ToolsToExecute.IsUnrestricted() { + return true + } + toolSuffix := strings.TrimPrefix(toolPattern, clientName+"-") + return mcpConfig.ToolsToExecute.Contains(toolSuffix) + } + // No explicit VK config found — fall back to AllowOnAllVirtualKeys (allows all tools). + for _, clientName := range allowAllClients { + if strings.HasPrefix(toolPattern, clientName+"-") || toolPattern == clientName+"-*" { + return true } } return false @@ -1303,17 +1333,7 @@ func (p *GovernancePlugin) PreMCPHook(ctx *schemas.BifrostContext, req *schemas. }, }}, nil } - if len(vk.MCPConfigs) == 0 { - ctx.SetValue(governanceRejectedContextKey, true) - return req, &schemas.MCPPluginShortCircuit{Error: &schemas.BifrostError{ - Type: bifrost.Ptr(string(DecisionMCPToolBlocked)), - StatusCode: bifrost.Ptr(403), - Error: &schemas.ErrorField{ - Message: fmt.Sprintf("no MCP tools are configured for virtual key '%s'", vk.Name), - }, - }}, nil - } - if !isMCPToolAllowedByVK(vk, toolName) { + if !p.isMCPToolAllowedByVK(vk, toolName) { ctx.SetValue(governanceRejectedContextKey, true) return req, &schemas.MCPPluginShortCircuit{Error: &schemas.BifrostError{ Type: bifrost.Ptr(string(DecisionMCPToolBlocked)), diff --git a/transports/bifrost-http/handlers/mcp.go b/transports/bifrost-http/handlers/mcp.go index 34e8ddfd02..46decbddaf 100644 --- a/transports/bifrost-http/handlers/mcp.go +++ b/transports/bifrost-http/handlers/mcp.go @@ -232,20 +232,22 @@ func (h *MCPHandler) getMCPClientsPaginated(ctx *fasthttp.RequestCtx, limitStr, isPingAvailable = *dbClient.IsPingAvailable } clientConfig := &schemas.MCPClientConfig{ - ID: dbClient.ClientID, - Name: dbClient.Name, - IsCodeModeClient: dbClient.IsCodeModeClient, - ConnectionType: schemas.MCPConnectionType(dbClient.ConnectionType), - ConnectionString: dbClient.ConnectionString, - StdioConfig: dbClient.StdioConfig, - AuthType: schemas.MCPAuthType(dbClient.AuthType), - OauthConfigID: dbClient.OauthConfigID, - ToolsToExecute: dbClient.ToolsToExecute, - ToolsToAutoExecute: dbClient.ToolsToAutoExecute, - Headers: dbClient.Headers, - IsPingAvailable: &isPingAvailable, - ToolSyncInterval: time.Duration(dbClient.ToolSyncInterval) * time.Minute, - ToolPricing: dbClient.ToolPricing, + ID: dbClient.ClientID, + Name: dbClient.Name, + IsCodeModeClient: dbClient.IsCodeModeClient, + ConnectionType: schemas.MCPConnectionType(dbClient.ConnectionType), + ConnectionString: dbClient.ConnectionString, + StdioConfig: dbClient.StdioConfig, + AuthType: schemas.MCPAuthType(dbClient.AuthType), + OauthConfigID: dbClient.OauthConfigID, + ToolsToExecute: dbClient.ToolsToExecute, + ToolsToAutoExecute: dbClient.ToolsToAutoExecute, + Headers: dbClient.Headers, + AllowedExtraHeaders: dbClient.AllowedExtraHeaders, + IsPingAvailable: &isPingAvailable, + ToolSyncInterval: time.Duration(dbClient.ToolSyncInterval) * time.Minute, + ToolPricing: dbClient.ToolPricing, + AllowOnAllVirtualKeys: dbClient.AllowOnAllVirtualKeys, } // Enrich VK assignments using the pre-fetched batch result (no extra DB call per client) vkConfigs := []MCPVKConfigResponse{} @@ -439,20 +441,22 @@ func (h *MCPHandler) addMCPClient(ctx *fasthttp.RequestCtx) { // Store MCP client config in OAuth provider memory (not in database) // It will be stored in database only after OAuth completion pendingConfig := schemas.MCPClientConfig{ - ID: req.ClientID, - Name: req.Name, - IsCodeModeClient: req.IsCodeModeClient, - IsPingAvailable: req.IsPingAvailable, - ToolSyncInterval: toolSyncInterval, - ConnectionType: schemas.MCPConnectionType(req.ConnectionType), - ConnectionString: req.ConnectionString, - StdioConfig: req.StdioConfig, - AuthType: schemas.MCPAuthType(req.AuthType), - OauthConfigID: &flowInitiation.OauthConfigID, - ToolsToExecute: req.ToolsToExecute, - ToolsToAutoExecute: req.ToolsToAutoExecute, - Headers: req.Headers, - AllowedExtraHeaders: req.AllowedExtraHeaders, + ID: req.ClientID, + Name: req.Name, + IsCodeModeClient: req.IsCodeModeClient, + IsPingAvailable: req.IsPingAvailable, + ToolSyncInterval: toolSyncInterval, + ConnectionType: schemas.MCPConnectionType(req.ConnectionType), + ConnectionString: req.ConnectionString, + StdioConfig: req.StdioConfig, + AuthType: schemas.MCPAuthType(req.AuthType), + OauthConfigID: &flowInitiation.OauthConfigID, + ToolsToExecute: req.ToolsToExecute, + ToolsToAutoExecute: req.ToolsToAutoExecute, + Headers: req.Headers, + AllowedExtraHeaders: req.AllowedExtraHeaders, + ToolPricing: req.ToolPricing, + AllowOnAllVirtualKeys: req.AllowOnAllVirtualKeys, } // Store pending config in database (associated with oauth_config_id for multi-instance support) @@ -490,21 +494,22 @@ func (h *MCPHandler) addMCPClient(ctx *fasthttp.RequestCtx) { // Convert to schemas.MCPClientConfig for runtime bifrost client (without tool_pricing) schemasConfig := &schemas.MCPClientConfig{ - ID: req.ClientID, - Name: req.Name, - IsCodeModeClient: req.IsCodeModeClient, - ConnectionType: schemas.MCPConnectionType(req.ConnectionType), - ConnectionString: req.ConnectionString, - StdioConfig: req.StdioConfig, - ToolsToExecute: req.ToolsToExecute, - ToolsToAutoExecute: req.ToolsToAutoExecute, - Headers: req.Headers, - AllowedExtraHeaders: req.AllowedExtraHeaders, - AuthType: schemas.MCPAuthType(req.AuthType), - OauthConfigID: req.OauthConfigID, - IsPingAvailable: req.IsPingAvailable, - ToolSyncInterval: toolSyncInterval, - ToolPricing: req.ToolPricing, + ID: req.ClientID, + Name: req.Name, + IsCodeModeClient: req.IsCodeModeClient, + ConnectionType: schemas.MCPConnectionType(req.ConnectionType), + ConnectionString: req.ConnectionString, + StdioConfig: req.StdioConfig, + ToolsToExecute: req.ToolsToExecute, + ToolsToAutoExecute: req.ToolsToAutoExecute, + Headers: req.Headers, + AllowedExtraHeaders: req.AllowedExtraHeaders, + AuthType: schemas.MCPAuthType(req.AuthType), + OauthConfigID: req.OauthConfigID, + IsPingAvailable: req.IsPingAvailable, + ToolSyncInterval: toolSyncInterval, + ToolPricing: req.ToolPricing, + AllowOnAllVirtualKeys: req.AllowOnAllVirtualKeys, } // Creating MCP client config in config store @@ -625,21 +630,22 @@ func (h *MCPHandler) updateMCPClient(ctx *fasthttp.RequestCtx) { } // Convert to schemas.MCPClientConfig for runtime bifrost client (without tool_pricing) schemasConfig := &schemas.MCPClientConfig{ - ID: req.ClientID, - Name: req.Name, - IsCodeModeClient: req.IsCodeModeClient, - ConnectionType: existingConfig.ConnectionType, - ConnectionString: existingConfig.ConnectionString, - StdioConfig: existingConfig.StdioConfig, - ToolsToExecute: req.ToolsToExecute, - ToolsToAutoExecute: req.ToolsToAutoExecute, - Headers: req.Headers, - AllowedExtraHeaders: req.AllowedExtraHeaders, - AuthType: existingConfig.AuthType, - OauthConfigID: existingConfig.OauthConfigID, - IsPingAvailable: req.IsPingAvailable, - ToolSyncInterval: toolSyncInterval, - ToolPricing: req.ToolPricing, + ID: req.ClientID, + Name: req.Name, + IsCodeModeClient: req.IsCodeModeClient, + ConnectionType: existingConfig.ConnectionType, + ConnectionString: existingConfig.ConnectionString, + StdioConfig: existingConfig.StdioConfig, + ToolsToExecute: req.ToolsToExecute, + ToolsToAutoExecute: req.ToolsToAutoExecute, + Headers: req.Headers, + AllowedExtraHeaders: req.AllowedExtraHeaders, + AuthType: existingConfig.AuthType, + OauthConfigID: existingConfig.OauthConfigID, + IsPingAvailable: req.IsPingAvailable, + ToolSyncInterval: toolSyncInterval, + ToolPricing: req.ToolPricing, + AllowOnAllVirtualKeys: req.AllowOnAllVirtualKeys, } // Update MCP client in memory if err := h.mcpManager.UpdateMCPClient(ctx, id, schemasConfig); err != nil { diff --git a/transports/bifrost-http/handlers/mcpserver.go b/transports/bifrost-http/handlers/mcpserver.go index 47a9b67161..e784763df8 100644 --- a/transports/bifrost-http/handlers/mcpserver.go +++ b/transports/bifrost-http/handlers/mcpserver.go @@ -322,34 +322,47 @@ func (h *MCPServerHandler) fetchToolsForVK(vk *tables.TableVirtualKey) ([]schema ctx := context.Background() var toolFilter []string - // Empty MCPConfigs means no MCP tools are allowed (deny-by-default) executeOnlyTools := make([]string, 0) - if len(vk.MCPConfigs) > 0 { - for _, vkMcpConfig := range vk.MCPConfigs { - if vkMcpConfig.ToolsToExecute.IsEmpty() { - // No tools specified in virtual key config - skip this client entirely - continue - } + // Build a lookup of AllowOnAllVirtualKeys clients: clientID -> clientName. + // Explicit VK MCPConfigs always take precedence over AllowOnAllVirtualKeys. + allowAllVKsClients := h.config.GetAllowOnAllVirtualKeysClients() + if allowAllVKsClients == nil { + allowAllVKsClients = make(map[string]string) + } - // Handle wildcard in virtual key config - allow all tools from this client - if vkMcpConfig.ToolsToExecute.IsUnrestricted() { - // Virtual key uses wildcard - use client-specific wildcard - executeOnlyTools = append(executeOnlyTools, fmt.Sprintf("%s-*", vkMcpConfig.MCPClient.Name)) - continue + // Process explicit VK MCPConfigs first. + handledClients := make(map[string]bool) + for _, vkMcpConfig := range vk.MCPConfigs { + clientID := vkMcpConfig.MCPClient.ClientID + if _, isAllowAll := allowAllVKsClients[clientID]; isAllowAll { + // Explicit config exists — it takes precedence; mark handled regardless of tool list. + handledClients[clientID] = true + } + if vkMcpConfig.ToolsToExecute.IsEmpty() { + continue + } + if vkMcpConfig.ToolsToExecute.IsUnrestricted() { + executeOnlyTools = append(executeOnlyTools, fmt.Sprintf("%s-*", vkMcpConfig.MCPClient.Name)) + continue + } + for _, tool := range vkMcpConfig.ToolsToExecute { + if tool != "" { + // Add the tool - client config filtering will be handled by mcp.go + // Note: Use '-' separator for individual tools (wildcard uses '-*' after client name, e.g., "client-*") + executeOnlyTools = append(executeOnlyTools, fmt.Sprintf("%s-%s", vkMcpConfig.MCPClient.Name, tool)) } + } + } - for _, tool := range vkMcpConfig.ToolsToExecute { - if tool != "" { - // Add the tool - client config filtering will be handled by mcp.go - // Note: Use '-' separator for individual tools (wildcard uses '-*' after client name, e.g., "client-*") - executeOnlyTools = append(executeOnlyTools, fmt.Sprintf("%s-%s", vkMcpConfig.MCPClient.Name, tool)) - } - } + // For AllowOnAllVirtualKeys clients with no explicit VK config, allow all their tools. + for clientID, clientName := range allowAllVKsClients { + if !handledClients[clientID] { + executeOnlyTools = append(executeOnlyTools, fmt.Sprintf("%s-*", clientName)) } } - // Always set the include-tools filter (empty = deny-all when no MCPConfigs) + // Always set the include-tools filter (empty = deny-all when no MCPConfigs and no AllowOnAllVirtualKeys clients) ctx = context.WithValue(ctx, schemas.MCPContextKeyIncludeTools, executeOnlyTools) toolFilter = executeOnlyTools diff --git a/transports/bifrost-http/lib/config.go b/transports/bifrost-http/lib/config.go index 91bdc87629..016a5e44ca 100644 --- a/transports/bifrost-http/lib/config.go +++ b/transports/bifrost-http/lib/config.go @@ -2371,6 +2371,24 @@ func (c *Config) GetMCPHeaderCombinedAllowlist() schemas.WhiteList { return allowlist } +// GetAllowOnAllVirtualKeysClients returns a map of clientID -> clientName for all MCP clients +// that have AllowOnAllVirtualKeys enabled. The returned map is a copy, safe for concurrent use. +func (c *Config) GetAllowOnAllVirtualKeysClients() map[string]string { + c.muMCP.RLock() + defer c.muMCP.RUnlock() + + if c.MCPConfig == nil { + return nil + } + result := make(map[string]string) + for _, client := range c.MCPConfig.ClientConfigs { + if client != nil && client.AllowOnAllVirtualKeys { + result[client.ID] = client.Name + } + } + return result +} + // GetPluginOrder returns the names of all base plugins in their sorted placement order. // This method is lock-free and safe for concurrent access from hot paths. // Do not modify the returned slice; it is a shared snapshot and must be treated read-only. @@ -3257,6 +3275,7 @@ func (c *Config) UpdateMCPClient(ctx context.Context, id string, updatedConfig * c.MCPConfig.ClientConfigs[configIndex].ToolPricing = updatedConfig.ToolPricing c.MCPConfig.ClientConfigs[configIndex].IsPingAvailable = updatedConfig.IsPingAvailable c.MCPConfig.ClientConfigs[configIndex].ToolSyncInterval = updatedConfig.ToolSyncInterval + c.MCPConfig.ClientConfigs[configIndex].AllowOnAllVirtualKeys = updatedConfig.AllowOnAllVirtualKeys return nil } diff --git a/transports/bifrost-http/lib/config_test.go b/transports/bifrost-http/lib/config_test.go index a8a829a2f2..1547d52a41 100644 --- a/transports/bifrost-http/lib/config_test.go +++ b/transports/bifrost-http/lib/config_test.go @@ -706,6 +706,10 @@ func (m *MockConfigStore) GetVirtualKeyByValue(ctx context.Context, value string return nil, nil } +func (m *MockConfigStore) GetVirtualKeyMCPConfigsByMCPClientID(ctx context.Context, mcpClientID uint) ([]tables.TableVirtualKeyMCPConfig, error) { + return nil, nil +} + // Virtual key provider config func (m *MockConfigStore) GetVirtualKeyProviderConfigs(ctx context.Context, virtualKeyID string) ([]tables.TableVirtualKeyProviderConfig, error) { return nil, nil diff --git a/transports/bifrost-http/server/server.go b/transports/bifrost-http/server/server.go index 719e926b5e..1e710cf9ac 100644 --- a/transports/bifrost-http/server/server.go +++ b/transports/bifrost-http/server/server.go @@ -163,6 +163,10 @@ func (s *GovernanceInMemoryStore) GetConfiguredProviders() map[schemas.ModelProv return s.Config.Providers } +func (s *GovernanceInMemoryStore) GetMCPClientsAllowingAllVirtualKeys() map[string]string { + return s.Config.GetAllowOnAllVirtualKeysClients() +} + // AddMCPClient adds a new MCP client to the in-memory store func (s *BifrostHTTPServer) AddMCPClient(ctx context.Context, clientConfig *schemas.MCPClientConfig) error { if err := s.Config.AddMCPClient(ctx, clientConfig); err != nil { diff --git a/transports/changelog.md b/transports/changelog.md index 00d4155acc..f37e99f06c 100644 --- a/transports/changelog.md +++ b/transports/changelog.md @@ -7,3 +7,4 @@ - refactor: parallelize model listing for providers to speed up startup time. - fix: send back accumulated usage in MCP agent mode. - feat: MCP edit UI now supports assigning virtual keys with per-tool access control directly from the MCP server edit sheet. +- feat: adds option to allow MCP clients to run on all virtual keys without explicit assignment. \ No newline at end of file diff --git a/transports/config.schema.json b/transports/config.schema.json index 4d30cd610e..4bfc8b9efe 100644 --- a/transports/config.schema.json +++ b/transports/config.schema.json @@ -2241,6 +2241,11 @@ "type": "number", "minimum": 0 } + }, + "allow_on_all_virtual_keys": { + "type": "boolean", + "description": "When true, this MCP server is accessible to all virtual keys without requiring explicit per-key assignment. All tools are allowed by default. If a virtual key has an explicit MCP config for this server, that config takes precedence and overrides this behaviour.", + "default": false } }, "required": [ diff --git a/ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx b/ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx index f43e78ed46..5a34ea3190 100644 --- a/ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx +++ b/ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx @@ -136,6 +136,7 @@ export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess }: name: mcpClient.config.name, is_code_mode_client: mcpClient.config.is_code_mode_client || false, is_ping_available: mcpClient.config.is_ping_available === true || mcpClient.config.is_ping_available === undefined, + allow_on_all_virtual_keys: mcpClient.config.allow_on_all_virtual_keys || false, headers: mcpClient.config.headers, tools_to_execute: mcpClient.config.tools_to_execute || [], tools_to_auto_execute: mcpClient.config.tools_to_auto_execute || [], @@ -151,6 +152,7 @@ export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess }: name: mcpClient.config.name, is_code_mode_client: mcpClient.config.is_code_mode_client || false, is_ping_available: mcpClient.config.is_ping_available === true || mcpClient.config.is_ping_available === undefined, + allow_on_all_virtual_keys: mcpClient.config.allow_on_all_virtual_keys || false, headers: mcpClient.config.headers, tools_to_execute: mcpClient.config.tools_to_execute || [], tools_to_auto_execute: mcpClient.config.tools_to_auto_execute || [], @@ -168,6 +170,7 @@ export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess }: name: data.name, is_code_mode_client: data.is_code_mode_client, is_ping_available: data.is_ping_available, + allow_on_all_virtual_keys: data.allow_on_all_virtual_keys, headers: data.headers ?? {}, tools_to_execute: data.tools_to_execute, tools_to_auto_execute: data.tools_to_auto_execute, @@ -391,6 +394,38 @@ export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess }: )} /> + ( + +
+ Allow on All Virtual Keys + + + + + + +

+ When enabled, this MCP server is accessible to all virtual keys without requiring explicit + per-key assignment. All tools are allowed by default. If a virtual key has an explicit MCP + config for this server, that config takes precedence and overrides this behaviour. +

+
+
+
+
+ + + +
+ )} + /> 0 && (
-
-
-
Virtual Key Access
- - - - - - -

Control which virtual keys can use this MCP server and which specific tools they can call.

-
-
-
+
+
+
+
Virtual Key Access
+ + + + + + +

Control which virtual keys can use this MCP server and which specific tools they can call.

+
+
+
+
+ {vkOptions.length > 0 && ( + setVKSearch(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} + className="h-7 text-sm" + /> +
+ {vkOptions.length > 0 ? vkOptions.map((opt) => ( + + {opt.label} + + )) : ( +
No virtual keys found
+ )} + + + )}
- setVKSearch(e.target.value)} - onKeyDown={(e) => e.stopPropagation()} - className="h-7 text-sm" - /> -
- {vkOptions.length > 0 ? vkOptions.map((opt) => ( - - {opt.label} - - )) : ( -
No virtual keys found
- )} - - + {form.watch("allow_on_all_virtual_keys") && ( +

+ + Configuring access for a virtual key here overrides the{" "} + Allow on All Virtual Keys setting for that key. +

+ )}
{vkConfigs.length > 0 ? ( @@ -877,6 +923,10 @@ export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess }:
+ ) : form.watch("allow_on_all_virtual_keys") ? ( +
+

All virtual keys can access this MCP server unless a key has an explicit override.

+
) : (

No virtual keys have access to this MCP server

diff --git a/ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx b/ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx index 46f6b2880a..d531c304e5 100644 --- a/ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx +++ b/ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx @@ -971,6 +971,29 @@ export default function VirtualKeySheet({ virtualKey, teams, customers, onSave,
+ {/* MCP servers available on all virtual keys by default, excluding explicitly overridden ones */} + {(() => { + const defaultMCPClients = mcpClientsData.filter( + (client) => + client.config.allow_on_all_virtual_keys && + !mcpConfigs.some((config) => config.mcp_client_name === client.config.name), + ); + return defaultMCPClients.length > 0 ? ( +
+
+ + + The following MCP servers are available to this key by default with all tools enabled on that client:{" "} + + {defaultMCPClients.map((c) => c.config.name).join(", ")} + + . Adding an explicit config for any of them below will override the all-tools default for this key. + +
+
+ ) : null; + })()} + {/* Add MCP Client Dropdown */} {mcpClientsData && mcpClientsData.length > 0 && (
diff --git a/ui/lib/types/mcp.ts b/ui/lib/types/mcp.ts index 18dd1e2bdf..01c84e0a71 100644 --- a/ui/lib/types/mcp.ts +++ b/ui/lib/types/mcp.ts @@ -41,6 +41,7 @@ export interface MCPClientConfig { tool_pricing?: Record; tool_sync_interval?: number; // Per-client override in minutes (0 = use global, -1 = disabled) allowed_extra_headers?: string[]; // Allowlist of x-bf-eh-* headers forwarded to this MCP server. ["*"] = allow all. + allow_on_all_virtual_keys?: boolean; // When true, available to all VKs with all tools allowed by default; explicit VK config overrides this } export interface MCPVKConfigResponse { @@ -104,6 +105,7 @@ export interface UpdateMCPClientRequest { tool_pricing?: Record; tool_sync_interval?: number; // Per-client override in minutes (0 = use global, -1 = disabled) allowed_extra_headers?: string[]; // Allowlist of x-bf-eh-* headers forwarded to this MCP server. ["*"] = allow all. + allow_on_all_virtual_keys?: boolean; // When true, available to all VKs with all tools allowed by default; explicit VK config overrides this vk_configs?: MCPVKConfig[]; // When provided, replaces all VK assignments for this MCP client } diff --git a/ui/lib/types/schemas.ts b/ui/lib/types/schemas.ts index ffe5f699c1..7aad1a5ebc 100644 --- a/ui/lib/types/schemas.ts +++ b/ui/lib/types/schemas.ts @@ -821,6 +821,7 @@ export const prometheusFormSchema = z export const mcpClientUpdateSchema = z.object({ is_code_mode_client: z.boolean().optional(), is_ping_available: z.boolean().optional(), + allow_on_all_virtual_keys: z.boolean().optional(), name: z .string() .min(1, "Name is required")