diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index 21eaeb3f5b..18b95d7b9d 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -143154,6 +143154,31 @@ "error" ], "description": "Connection state of an MCP client" + }, + "vk_configs": { + "type": "array", + "items": { + "type": "object", + "description": "Per-virtual-key tool access configuration as returned in list/get responses", + "properties": { + "virtual_key_id": { + "type": "string", + "description": "ID of the virtual key" + }, + "virtual_key_name": { + "type": "string", + "description": "Display name of the virtual key" + }, + "tools_to_execute": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tools this virtual key is allowed to call on this MCP client.\n[\"*\"] => all tools allowed\n[\"tool1\", \"tool2\"] => only the specified tools\n" + } + } + }, + "description": "Virtual key assignments for this MCP client" } } } @@ -143865,7 +143890,7 @@ "put": { "operationId": "editMCPClient", "summary": "Edit MCP client", - "description": "Updates an existing MCP client's configuration.\nUnlike client creation, tool_pricing can be included to set per-tool execution costs since tools are already fetched.\n", + "description": "Updates an existing MCP client's configuration.\nUnlike client creation, tool_pricing can be included to set per-tool execution costs since tools are already fetched.\nOptionally provide vk_configs to manage which virtual keys have access to this MCP server and with which tools. When provided, this fully replaces all existing VK assignments in a single atomic transaction.\n", "tags": [ "MCP" ], @@ -143979,6 +144004,31 @@ "format": "double" }, "description": "Per-tool cost in USD for execution.\nKey is the tool name, value is the cost per execution.\nExample: {\"read_file\": 0.001, \"write_file\": 0.002}\nNote: Only available when updating an existing client after tools have been fetched.\n" + }, + "vk_configs": { + "type": "array", + "items": { + "type": "object", + "description": "Per-virtual-key tool access configuration for an MCP client", + "required": [ + "virtual_key_id", + "tools_to_execute" + ], + "properties": { + "virtual_key_id": { + "type": "string", + "description": "ID of the virtual key" + }, + "tools_to_execute": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tools this virtual key is allowed to call on this MCP server.\n[\"*\"] => all tools allowed\n[\"tool1\", \"tool2\"] => only the specified tools\n" + } + } + }, + "description": "When provided, replaces all virtual key assignments for this MCP client.\nEach entry specifies a virtual key and the tools it is allowed to call.\nTo remove all VK access, provide an empty array [].\nOmit this field to leave existing VK assignments unchanged.\n" } } } @@ -162922,7 +162972,7 @@ "application/json": { "schema": { "type": "object", - "description": "Request body for creating or updating a pricing override.", + "description": "Request body for creating a pricing override.", "required": [ "name", "scope_kind", @@ -163629,7 +163679,7 @@ "put": { "operationId": "updatePricingOverride", "summary": "Update pricing override", - "description": "Replaces an existing pricing override's configuration.", + "description": "Updates an existing pricing override. Omitted fields are merged from the existing record. The `patch` field is always replaced in full when provided.", "tags": [ "Governance" ], @@ -163650,14 +163700,7 @@ "application/json": { "schema": { "type": "object", - "description": "Request body for creating or updating a pricing override.", - "required": [ - "name", - "scope_kind", - "match_type", - "pattern", - "request_types" - ], + "description": "Request body for updating a pricing override. All fields are optional — omitted fields are merged from the existing record. The `patch` field is always replaced in full when provided.\n", "properties": { "name": { "type": "string", @@ -163718,7 +163761,7 @@ "video_remix" ] }, - "description": "Request types this override applies to. At least one value is required." + "description": "Request types this override applies to." }, "patch": { "type": "object", @@ -200722,6 +200765,31 @@ "error" ], "description": "Connection state of an MCP client" + }, + "vk_configs": { + "type": "array", + "items": { + "type": "object", + "description": "Per-virtual-key tool access configuration as returned in list/get responses", + "properties": { + "virtual_key_id": { + "type": "string", + "description": "ID of the virtual key" + }, + "virtual_key_name": { + "type": "string", + "description": "Display name of the virtual key" + }, + "tools_to_execute": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tools this virtual key is allowed to call on this MCP client.\n[\"*\"] => all tools allowed\n[\"tool1\", \"tool2\"] => only the specified tools\n" + } + } + }, + "description": "Virtual key assignments for this MCP client" } } }, @@ -211709,7 +211777,7 @@ }, "CreatePricingOverrideRequest": { "type": "object", - "description": "Request body for creating or updating a pricing override.", + "description": "Request body for creating a pricing override.", "required": [ "name", "scope_kind", @@ -211955,6 +212023,247 @@ } } }, + "UpdatePricingOverrideRequest": { + "type": "object", + "description": "Request body for updating a pricing override. All fields are optional — omitted fields are merged from the existing record. The `patch` field is always replaced in full when provided.\n", + "properties": { + "name": { + "type": "string", + "description": "Human-readable label" + }, + "scope_kind": { + "type": "string", + "enum": [ + "global", + "provider", + "provider_key", + "virtual_key", + "virtual_key_provider", + "virtual_key_provider_key" + ] + }, + "virtual_key_id": { + "type": "string", + "description": "Required for virtual_key* scopes" + }, + "provider_id": { + "type": "string", + "description": "Required for provider and virtual_key_provider scopes" + }, + "provider_key_id": { + "type": "string", + "description": "Required for provider_key and virtual_key_provider_key scopes" + }, + "match_type": { + "type": "string", + "enum": [ + "exact", + "wildcard" + ] + }, + "pattern": { + "type": "string", + "description": "Model name or wildcard prefix ending with * (e.g. \"claude-3*\")" + }, + "request_types": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "description": "Request type for pricing override filtering. Stream variants are treated identically to their base type — specifying `chat_completion` covers both streaming and non-streaming chat requests.\n", + "enum": [ + "chat_completion", + "text_completion", + "responses", + "embedding", + "rerank", + "speech", + "transcription", + "image_generation", + "image_variation", + "image_edit", + "video_generation", + "video_remix" + ] + }, + "description": "Request types this override applies to." + }, + "patch": { + "type": "object", + "description": "Pricing fields to override. Only non-zero/non-null fields are applied. All values are cost per unit in USD.\n", + "properties": { + "input_cost_per_token": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_token": { + "type": "number", + "minimum": 0 + }, + "input_cost_per_token_batches": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_token_batches": { + "type": "number", + "minimum": 0 + }, + "input_cost_per_token_priority": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_token_priority": { + "type": "number", + "minimum": 0 + }, + "input_cost_per_character": { + "type": "number", + "minimum": 0 + }, + "input_cost_per_token_above_128k_tokens": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_token_above_128k_tokens": { + "type": "number", + "minimum": 0 + }, + "input_cost_per_token_above_200k_tokens": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_token_above_200k_tokens": { + "type": "number", + "minimum": 0 + }, + "cache_creation_input_token_cost": { + "type": "number", + "minimum": 0 + }, + "cache_read_input_token_cost": { + "type": "number", + "minimum": 0 + }, + "cache_creation_input_token_cost_above_200k_tokens": { + "type": "number", + "minimum": 0 + }, + "cache_read_input_token_cost_above_200k_tokens": { + "type": "number", + "minimum": 0 + }, + "cache_read_input_token_cost_priority": { + "type": "number", + "minimum": 0 + }, + "cache_read_input_image_token_cost": { + "type": "number", + "minimum": 0 + }, + "cache_creation_input_audio_token_cost": { + "type": "number", + "minimum": 0 + }, + "input_cost_per_image": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image": { + "type": "number", + "minimum": 0 + }, + "input_cost_per_pixel": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_pixel": { + "type": "number", + "minimum": 0 + }, + "input_cost_per_image_token": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_token": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_low_quality": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_medium_quality": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_high_quality": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_auto_quality": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_premium_image": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_above_512_and_512_pixels": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_above_1024_and_1024_pixels": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_above_2048_and_2048_pixels": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_above_4096_and_4096_pixels": { + "type": "number", + "minimum": 0 + }, + "input_cost_per_audio_token": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_audio_token": { + "type": "number", + "minimum": 0 + }, + "input_cost_per_audio_per_second": { + "type": "number", + "minimum": 0 + }, + "input_cost_per_second": { + "type": "number", + "minimum": 0 + }, + "input_cost_per_video_per_second": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_video_per_second": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_second": { + "type": "number", + "minimum": 0 + }, + "search_context_cost_per_query": { + "type": "number", + "minimum": 0 + }, + "code_interpreter_cost_per_session": { + "type": "number", + "minimum": 0 + } + } + } + } + }, "PricingOverrideResponse": { "type": "object", "properties": { diff --git a/docs/openapi/paths/management/mcp.yaml b/docs/openapi/paths/management/mcp.yaml index 8f1a5aea52..34ee37c9bc 100644 --- a/docs/openapi/paths/management/mcp.yaml +++ b/docs/openapi/paths/management/mcp.yaml @@ -120,6 +120,7 @@ client-by-id: description: | Updates an existing MCP client's configuration. Unlike client creation, tool_pricing can be included to set per-tool execution costs since tools are already fetched. + Optionally provide vk_configs to manage which virtual keys have access to this MCP server and with which tools. When provided, this fully replaces all existing VK assignments in a single atomic transaction. tags: - MCP parameters: diff --git a/docs/openapi/schemas/management/mcp.yaml b/docs/openapi/schemas/management/mcp.yaml index 02eb308733..97f0c9f9ea 100644 --- a/docs/openapi/schemas/management/mcp.yaml +++ b/docs/openapi/schemas/management/mcp.yaml @@ -120,7 +120,6 @@ MCPClientCreateRequestBase: ["*"] => all executable tools can be auto-executed [] => no tools are auto-executed ["tool1", "tool2"] => only specified tools can be auto-executed - MCPClientCreateRequestHTTP: allOf: - $ref: '#/MCPClientCreateRequestBase' @@ -228,6 +227,34 @@ 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. + vk_configs: + type: array + items: + $ref: '#/MCPVKConfig' + description: | + When provided, replaces all virtual key assignments for this MCP client. + Each entry specifies a virtual key and the tools it is allowed to call. + To remove all VK access, provide an empty array []. + Omit this field to leave existing VK assignments unchanged. + +MCPVKConfig: + type: object + description: Per-virtual-key tool access configuration for an MCP client + required: + - virtual_key_id + - tools_to_execute + properties: + virtual_key_id: + type: string + description: ID of the virtual key + tools_to_execute: + type: array + items: + type: string + description: | + Tools this virtual key is allowed to call on this MCP server. + ["*"] => all tools allowed + ["tool1", "tool2"] => only the specified tools MCPClientConfig: type: object @@ -308,6 +335,25 @@ ChatToolFunction: strict: type: boolean +MCPVKConfigResponse: + type: object + description: Per-virtual-key tool access configuration as returned in list/get responses + properties: + virtual_key_id: + type: string + description: ID of the virtual key + virtual_key_name: + type: string + description: Display name of the virtual key + tools_to_execute: + type: array + items: + type: string + description: | + Tools this virtual key is allowed to call on this MCP client. + ["*"] => all tools allowed + ["tool1", "tool2"] => only the specified tools + MCPClient: type: object description: Connected MCP client with its tools @@ -320,6 +366,11 @@ MCPClient: $ref: '#/ChatToolFunction' state: $ref: '#/MCPConnectionState' + vk_configs: + type: array + items: + $ref: '#/MCPVKConfigResponse' + description: Virtual key assignments for this MCP client ExecuteToolRequest: oneOf: diff --git a/docs/quickstart/go-sdk/context-keys.mdx b/docs/quickstart/go-sdk/context-keys.mdx index fdf072ce47..9622e23293 100644 --- a/docs/quickstart/go-sdk/context-keys.mdx +++ b/docs/quickstart/go-sdk/context-keys.mdx @@ -4,26 +4,42 @@ description: "Use context keys to configure request behavior, pass metadata, and icon: "key" --- -Bifrost uses Go's `context.Context` to pass configuration and metadata through the request lifecycle. Context keys allow you to customize request behavior, pass request-specific settings, and read metadata set by Bifrost. +Bifrost uses `BifrostContext` — a custom `context.Context` — to pass configuration and metadata through the request lifecycle. Context keys allow you to customize request behavior, pass request-specific settings, and read metadata set by Bifrost. + +The idiomatic pattern is to create a `BifrostContext` and call `SetValue` (or the chainable `WithValue`) directly on it: + +```go +bfCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) +bfCtx.SetValue(schemas.BifrostContextKeyRequestID, "req-001") + +response, err := client.ChatCompletionRequest(bfCtx, &schemas.BifrostChatRequest{...}) +``` ## Request Configuration Keys These keys can be set before making a request to customize behavior. +### Virtual Key + +Pass a virtual key identifier to the governance plugin for budget and rate-limit enforcement. + +```go +bfCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) +bfCtx.SetValue(schemas.BifrostContextKeyVirtualKey, "vk-my-team") +``` + ### Extra Headers Pass custom headers with individual requests. Headers are automatically propagated to the provider. ```go -ctx := context.Background() - -extraHeaders := map[string][]string{ +bfCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) +bfCtx.SetValue(schemas.BifrostContextKeyExtraHeaders, map[string][]string{ "user-id": {"user-123"}, "session-id": {"session-abc"}, -} -ctx = context.WithValue(ctx, schemas.BifrostContextKeyExtraHeaders, extraHeaders) +}) -response, err := client.ChatCompletionRequest(schemas.NewBifrostContext(ctx, schemas.NoDeadline), &schemas.BifrostChatRequest{ +response, err := client.ChatCompletionRequest(bfCtx, &schemas.BifrostChatRequest{ Provider: schemas.OpenAI, Model: "gpt-4o-mini", Input: messages, @@ -43,7 +59,7 @@ Bifrost supports selecting a specific key by **ID** or **name**. When both are p Explicitly select a key by its unique ID. ```go -ctx := context.WithValue(ctx, schemas.BifrostContextKeyAPIKeyID, "key-uuid-1234") +bfCtx.SetValue(schemas.BifrostContextKeyAPIKeyID, "key-uuid-1234") ``` #### By Name @@ -51,28 +67,27 @@ ctx := context.WithValue(ctx, schemas.BifrostContextKeyAPIKeyID, "key-uuid-1234" Explicitly select a named API key from your configured keys. ```go -ctx := context.WithValue(ctx, schemas.BifrostContextKeyAPIKeyName, "premium-key") +bfCtx.SetValue(schemas.BifrostContextKeyAPIKeyName, "premium-key") ``` ### Direct Key -Bypass key selection and provide credentials directly. Useful for dynamic key scenarios. +Provide credentials directly, bypassing Bifrost's key selection entirely. Useful for dynamic or per-request key scenarios. ```go -directKey := schemas.Key{ +bfCtx.SetValue(schemas.BifrostContextKeyDirectKey, schemas.Key{ Value: "sk-direct-api-key", Models: []string{"gpt-4o"}, Weight: 1.0, -} -ctx := context.WithValue(ctx, schemas.BifrostContextKeyDirectKey, directKey) +}) ``` ### Skip Key Selection -Skip the key selection process entirely and pass an empty key to the provider. Useful for providers that don't require authentication or when using ambient credentials. +Skip key selection entirely and pass an empty key to the provider. Useful for providers that don't require authentication or when using ambient credentials (e.g., IAM roles). ```go -ctx := context.WithValue(ctx, schemas.BifrostContextKeySkipKeySelection, true) +bfCtx.SetValue(schemas.BifrostContextKeySkipKeySelection, true) ``` ### Session Stickiness (Session ID) @@ -81,16 +96,20 @@ Bind a session to a specific API key so that requests with the same session ID c On the first request for a session ID, Bifrost selects a key (via weighted random) and caches the binding in the KV store. Subsequent requests with the same session ID reuse the cached key as long as it remains valid. If the cached key is no longer in the supported set (disabled, removed, or model support changed), Bifrost re-selects and overwrites the cache. + +Session stickiness requires a `KVStore` to be configured in `BifrostConfig`. + + ```go -ctx := context.WithValue(ctx, schemas.BifrostContextKeySessionID, "user-123-session-abc") +bfCtx.SetValue(schemas.BifrostContextKeySessionID, "user-123-session-abc") ``` ### Session TTL -Optional. Controls how long the session-to-key binding is cached. If not set, Bifrost uses `DefaultSessionStickyTTL` (1 hour). The TTL is refreshed on each request so active sessions do not expire. +Controls how long the session-to-key binding is cached. If not set, Bifrost uses a default TTL of 1 hour. The TTL is refreshed on each request so active sessions do not expire. ```go -ctx := context.WithValue(ctx, schemas.BifrostContextKeySessionTTL, 30*time.Minute) +bfCtx.SetValue(schemas.BifrostContextKeySessionTTL, 30*time.Minute) ``` ### Request ID @@ -98,7 +117,7 @@ ctx := context.WithValue(ctx, schemas.BifrostContextKeySessionTTL, 30*time.Minut Set a custom request ID for tracking and correlation. ```go -ctx := context.WithValue(ctx, schemas.BifrostContextKeyRequestID, "req-12345-abc") +bfCtx.SetValue(schemas.BifrostContextKeyRequestID, "req-12345-abc") ``` ### Custom URL Path @@ -106,52 +125,52 @@ ctx := context.WithValue(ctx, schemas.BifrostContextKeyRequestID, "req-12345-abc Append a custom path to the provider's base URL. Useful for accessing provider-specific endpoints. ```go -ctx := context.WithValue(ctx, schemas.BifrostContextKeyURLPath, "/custom/endpoint") +bfCtx.SetValue(schemas.BifrostContextKeyURLPath, "/custom/endpoint") +``` + +### Stream Idle Timeout + +Set a per-chunk idle timeout for streaming responses. If no chunk arrives within this duration, the stream is considered stalled and cancelled. + +```go +bfCtx.SetValue(schemas.BifrostContextKeyStreamIdleTimeout, 10*time.Second) ``` ### Raw Request Body -Send a raw request body instead of Bifrost's standardized format. The provider receives your payload as-is. You must both enable the context key AND set the `RawRequestBody` field on your request. +Send a raw request body instead of Bifrost's standardized format. The provider receives your payload as-is. You must both set the context key AND populate `RawRequestBody` on the request. ```go -// Prepare your raw JSON payload rawPayload := []byte(`{ "model": "gpt-4o", "messages": [{"role": "user", "content": "Hello!"}], "custom_field": "provider-specific-value" }`) -// Enable raw request body mode -ctx := context.WithValue(ctx, schemas.BifrostContextKeyUseRawRequestBody, true) +bfCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) +bfCtx.SetValue(schemas.BifrostContextKeyUseRawRequestBody, true) -// Set the raw body on the request -response, err := client.ChatCompletionRequest(schemas.NewBifrostContext(ctx, schemas.NoDeadline), &schemas.BifrostChatRequest{ +response, err := client.ChatCompletionRequest(bfCtx, &schemas.BifrostChatRequest{ Provider: schemas.OpenAI, Model: "gpt-4o", - RawRequestBody: rawPayload, // This will be sent directly to the provider + RawRequestBody: rawPayload, }) ``` -When using raw request body, Bifrost bypasses its request conversion and sends your payload directly to the provider. You're responsible for ensuring the payload matches the provider's expected format. +When using raw request body, Bifrost bypasses its request conversion and sends your payload directly to the provider. You are responsible for ensuring the payload matches the provider's expected format. ### Send Back Raw Request/Response -Include the original request or response in the `ExtraFields` for debugging. +Include the original request or response bytes in `ExtraFields` for debugging. ```go -// Include raw request in response -ctx := context.WithValue(ctx, schemas.BifrostContextKeySendBackRawRequest, true) - -// Include raw provider response -ctx := context.WithValue(ctx, schemas.BifrostContextKeySendBackRawResponse, true) -``` - -Access in response: +bfCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) +bfCtx.SetValue(schemas.BifrostContextKeySendBackRawRequest, true) +bfCtx.SetValue(schemas.BifrostContextKeySendBackRawResponse, true) -```go -response, _ := client.ChatCompletionRequest(schemas.NewBifrostContext(ctx, schemas.NoDeadline), request) +response, _ := client.ChatCompletionRequest(bfCtx, request) if response.ChatResponse != nil { rawReq := response.ChatResponse.ExtraFields.RawRequest rawResp := response.ChatResponse.ExtraFields.RawResponse @@ -160,12 +179,13 @@ if response.ChatResponse != nil { ### Passthrough Extra Parameters -Enable passthrough mode for extra parameters. When enabled, any parameters in `ExtraParams` will be merged directly into the request sent to the provider, bypassing Bifrost's parameter filtering. +When enabled, any parameters in `ExtraParams` are merged directly into the JSON body sent to the provider, bypassing Bifrost's parameter filtering. Useful for provider-specific parameters that Bifrost doesn't natively support. ```go -ctx := context.WithValue(ctx, schemas.BifrostContextKeyPassthroughExtraParams, true) +bfCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) +bfCtx.SetValue(schemas.BifrostContextKeyPassthroughExtraParams, true) -response, err := client.ChatCompletionRequest(schemas.NewBifrostContext(ctx, schemas.NoDeadline), &schemas.BifrostChatRequest{ +response, err := client.ChatCompletionRequest(bfCtx, &schemas.BifrostChatRequest{ Provider: schemas.OpenAI, Model: "gpt-4o-mini", Input: messages, @@ -181,27 +201,57 @@ response, err := client.ChatCompletionRequest(schemas.NewBifrostContext(ctx, sch }) ``` -When enabled, the extra parameters are merged into the JSON request body sent to the provider. This allows you to pass provider-specific parameters that Bifrost doesn't natively support. - - This feature only works for JSON requests, not multipart/form-data requests -- Parameters already handled by Bifrost (like `addWatermark`, `enhancePrompt`) are not duplicated - they appear in their proper location -- Nested parameters (e.g., `parameters.custom_field`) are merged recursively with existing nested structures +- Parameters already handled by Bifrost are not duplicated — they appear in their proper location +- Nested parameters are merged recursively with existing nested structures +## MCP Context Keys + +These keys control MCP tool execution behavior on a per-request basis. Request-level filtering takes priority over client-level configuration. + +### Include Clients + +Restrict which MCP clients can provide tools for this request. Pass `[]string{"*"}` to include all clients, or an empty slice to exclude all. + +```go +bfCtx.SetValue(schemas.MCPContextKeyIncludeClients, []string{"github", "filesystem"}) +``` + +### Include Tools + +Restrict which tools are available for this request. Use `"clientName-toolName"` format for individual tools or `"clientName-*"` as a wildcard for all tools from a client. + +```go +// Allow only the search tool from the github client +bfCtx.SetValue(schemas.MCPContextKeyIncludeTools, []string{"github-search_repositories"}) + +// Allow all tools from filesystem client +bfCtx.SetValue(schemas.MCPContextKeyIncludeTools, []string{"filesystem-*"}) +``` + +### MCP Extra Headers + +Forward additional headers to MCP servers during tool execution. Only headers present in the MCP client's configured allowlist are forwarded. + +```go +bfCtx.SetValue(schemas.BifrostContextKeyMCPExtraHeaders, map[string][]string{ + "x-user-id": {"user-123"}, + "x-session-id": {"session-abc"}, +}) +``` + ## Response Metadata Keys -These keys are set by Bifrost and can be read from the context after a request completes. They're particularly useful in plugins and hooks. +These keys are set by Bifrost and can be read from the context after a request completes. They are particularly useful in plugins and post-hooks. ### Selected Key Information After Bifrost selects an API key, it stores the selection details in the context. ```go -// Get the selected key's ID keyID := ctx.Value(schemas.BifrostContextKeySelectedKeyID).(string) - -// Get the selected key's name keyName := ctx.Value(schemas.BifrostContextKeySelectedKeyName).(string) ``` @@ -216,25 +266,25 @@ retries := ctx.Value(schemas.BifrostContextKeyNumberOfRetries).(int) // Fallback index (0 = primary, 1 = first fallback, etc.) fallbackIdx := ctx.Value(schemas.BifrostContextKeyFallbackIndex).(int) -// Fallback request ID (set when using a fallback provider) +// Request ID used for the fallback attempt fallbackReqID := ctx.Value(schemas.BifrostContextKeyFallbackRequestID).(string) ``` ### Stream End Indicator -For streaming responses, indicates when the stream has completed. +For streaming responses, indicates when the stream has completed. Set by Bifrost automatically. ```go isStreamEnd := ctx.Value(schemas.BifrostContextKeyStreamEndIndicator).(bool) ``` -Plugin developers: When implementing custom streaming in PreLLMHook or PostLLMHook, make sure to mark `BifrostContextKeyStreamEndIndicator` as `true` at the end of the stream for proper cleanup. +Plugin developers: When implementing a short-circuit streaming response in `PreLLMHook` or `PostLLMHook`, set `BifrostContextKeyStreamEndIndicator` to `true` on the last chunk to trigger proper cleanup. ### Integration Type -Identifies which integration format is being used (useful in gateway scenarios). +Identifies which SDK integration format is in use (useful in gateway plugins). ```go integrationType := ctx.Value(schemas.BifrostContextKeyIntegrationType).(string) @@ -243,8 +293,6 @@ integrationType := ctx.Value(schemas.BifrostContextKeyIntegrationType).(string) ## Complete Example -Here's a comprehensive example showing multiple context keys in use: - ```go package main @@ -252,52 +300,50 @@ import ( "context" "fmt" "log" + "time" "github.com/maximhq/bifrost" "github.com/maximhq/bifrost/core/schemas" ) func makeRequest(client *bifrost.Bifrost) { - // Start with background context - ctx := context.Background() + bfCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) - // Add request tracking - ctx = context.WithValue(ctx, schemas.BifrostContextKeyRequestID, "req-001") + // Request tracking + bfCtx.SetValue(schemas.BifrostContextKeyRequestID, "req-001") - // Add custom headers for the provider - extraHeaders := map[string][]string{ + // Custom headers forwarded to the provider + bfCtx.SetValue(schemas.BifrostContextKeyExtraHeaders, map[string][]string{ "x-correlation-id": {"corr-12345"}, "x-tenant-id": {"tenant-abc"}, - } - ctx = context.WithValue(ctx, schemas.BifrostContextKeyExtraHeaders, extraHeaders) + }) - // Request raw response for debugging - ctx = context.WithValue(ctx, schemas.BifrostContextKeySendBackRawResponse, true) + // Include raw provider response for debugging + bfCtx.SetValue(schemas.BifrostContextKeySendBackRawResponse, true) + + // Restrict MCP tools to a specific client + bfCtx.SetValue(schemas.MCPContextKeyIncludeClients, []string{"filesystem"}) - // Make the request messages := []schemas.BifrostMessage{ {Role: "user", Content: &schemas.BifrostMessageContent{Text: bifrost.Ptr("Hello!")}}, } - response, err := client.ChatCompletionRequest(schemas.NewBifrostContext(ctx, schemas.NoDeadline), &schemas.BifrostChatRequest{ + response, err := client.ChatCompletionRequest(bfCtx, &schemas.BifrostChatRequest{ Provider: schemas.OpenAI, Model: "gpt-4o-mini", Input: messages, }) - if err != nil { log.Printf("Request failed: %v", err) return } - // Access response metadata if response.ChatResponse != nil { extra := response.ChatResponse.ExtraFields fmt.Printf("Provider: %s\n", extra.Provider) fmt.Printf("Latency: %dms\n", extra.Latency) - if extra.RawResponse != nil { - fmt.Printf("Raw response available for debugging\n") + fmt.Println("Raw response captured for debugging") } } } @@ -307,28 +353,32 @@ func makeRequest(client *bifrost.Bifrost) { | Key | Type | Direction | Description | |-----|------|-----------|-------------| -| `BifrostContextKeyVirtualKey` | `string` | Set | Virtual key identifier | +| `BifrostContextKeyVirtualKey` | `string` | Set | Virtual key identifier for governance | | `BifrostContextKeyAPIKeyName` | `string` | Set | Explicit API key name selection | | `BifrostContextKeyAPIKeyID` | `string` | Set | Explicit API key ID selection (priority over name) | | `BifrostContextKeyRequestID` | `string` | Set | Custom request ID for tracking | -| `BifrostContextKeyFallbackRequestID` | `string` | Read | Request ID when using fallback | -| `BifrostContextKeyDirectKey` | `schemas.Key` | Set | Direct key credentials | -| `BifrostContextKeySelectedKeyID` | `string` | Read | Selected key's ID | -| `BifrostContextKeySelectedKeyName` | `string` | Read | Selected key's name | -| `BifrostContextKeyNumberOfRetries` | `int` | Read | Number of retry attempts | -| `BifrostContextKeyFallbackIndex` | `int` | Read | Current fallback index | -| `BifrostContextKeyStreamEndIndicator` | `bool` | Read | Stream completion flag | -| `BifrostContextKeySkipKeySelection` | `bool` | Set | Skip key selection | +| `BifrostContextKeyFallbackRequestID` | `string` | Read | Request ID used for fallback attempt | +| `BifrostContextKeyDirectKey` | `schemas.Key` | Set | Provide credentials directly, bypassing key selection | +| `BifrostContextKeySkipKeySelection` | `bool` | Set | Skip key selection entirely | | `BifrostContextKeySessionID` | `string` | Set | Session ID for key stickiness (requires KV store) | | `BifrostContextKeySessionTTL` | `time.Duration` | Set | TTL for session-to-key cache (default: 1 hour) | -| `BifrostContextKeyExtraHeaders` | `map[string][]string` | Set | Custom request headers | -| `BifrostContextKeyURLPath` | `string` | Set | Custom URL path suffix | -| `BifrostContextKeyUseRawRequestBody` | `bool` | Set | Use raw request body | -| `BifrostContextKeySendBackRawRequest` | `bool` | Set | Include raw request in response | -| `BifrostContextKeySendBackRawResponse` | `bool` | Set | Include raw response | -| `BifrostContextKeyPassthroughExtraParams` | `bool` | Set | Enable passthrough for extra parameters | -| `BifrostContextKeyIntegrationType` | `string` | Read | Integration format type | -| `BifrostContextKeyUserAgent` | `string` | Read | Request user agent | +| `BifrostContextKeyExtraHeaders` | `map[string][]string` | Set | Custom headers forwarded to the provider | +| `BifrostContextKeyURLPath` | `string` | Set | Custom URL path appended to provider base URL | +| `BifrostContextKeyStreamIdleTimeout` | `time.Duration` | Set | Per-chunk idle timeout for streaming responses | +| `BifrostContextKeyUseRawRequestBody` | `bool` | Set | Send raw request body directly to provider | +| `BifrostContextKeySendBackRawRequest` | `bool` | Set | Include raw request in `ExtraFields` | +| `BifrostContextKeySendBackRawResponse` | `bool` | Set | Include raw provider response in `ExtraFields` | +| `BifrostContextKeyPassthroughExtraParams` | `bool` | Set | Merge `ExtraParams` directly into provider request | +| `MCPContextKeyIncludeClients` | `[]string` | Set | Allowlist of MCP client names for this request | +| `MCPContextKeyIncludeTools` | `[]string` | Set | Allowlist of MCP tools (`"client-tool"` or `"client-*"`) | +| `BifrostContextKeyMCPExtraHeaders` | `map[string][]string` | Set | Extra headers forwarded to MCP servers during tool execution | +| `BifrostContextKeySelectedKeyID` | `string` | Read | ID of the key selected by Bifrost | +| `BifrostContextKeySelectedKeyName` | `string` | Read | Name of the key selected by Bifrost | +| `BifrostContextKeyNumberOfRetries` | `int` | Read | Number of retry attempts made | +| `BifrostContextKeyFallbackIndex` | `int` | Read | Current fallback index (0 = primary) | +| `BifrostContextKeyStreamEndIndicator` | `bool` | Read | Whether the stream has completed | +| `BifrostContextKeyIntegrationType` | `string` | Read | SDK integration format in use (e.g. `"openai"`) | +| `BifrostContextKeyUserAgent` | `string` | Read | User agent of the incoming request | ## Next Steps diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index 5ae4bacd99..a3cad08f76 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -2165,6 +2165,27 @@ func (s *RDBConfigStore) GetVirtualKeyMCPConfigs(ctx context.Context, virtualKey return mcpConfigs, nil } +// GetVirtualKeyMCPConfigsByMCPClientID retrieves all VK MCP configs for a given MCP client. +func (s *RDBConfigStore) GetVirtualKeyMCPConfigsByMCPClientID(ctx context.Context, mcpClientID uint) ([]tables.TableVirtualKeyMCPConfig, error) { + var configs []tables.TableVirtualKeyMCPConfig + if err := s.db.WithContext(ctx).Where("mcp_client_id = ?", mcpClientID).Find(&configs).Error; err != nil { + return nil, err + } + return configs, nil +} + +// GetVirtualKeyMCPConfigsByMCPClientIDs retrieves all VK MCP configs for a set of MCP client IDs in one query. +func (s *RDBConfigStore) GetVirtualKeyMCPConfigsByMCPClientIDs(ctx context.Context, mcpClientIDs []uint) ([]tables.TableVirtualKeyMCPConfig, error) { + if len(mcpClientIDs) == 0 { + return nil, nil + } + var configs []tables.TableVirtualKeyMCPConfig + if err := s.db.WithContext(ctx).Where("mcp_client_id IN ?", mcpClientIDs).Find(&configs).Error; err != nil { + return nil, err + } + return configs, nil +} + // CreateVirtualKeyMCPConfig creates a new virtual key MCP config in the database. func (s *RDBConfigStore) CreateVirtualKeyMCPConfig(ctx context.Context, virtualKeyMCPConfig *tables.TableVirtualKeyMCPConfig, tx ...*gorm.DB) error { var txDB *gorm.DB diff --git a/framework/configstore/store.go b/framework/configstore/store.go index 11d6a6a899..43aa6db7a0 100644 --- a/framework/configstore/store.go +++ b/framework/configstore/store.go @@ -152,6 +152,8 @@ type ConfigStore interface { // Virtual key MCP config CRUD GetVirtualKeyMCPConfigs(ctx context.Context, virtualKeyID string) ([]tables.TableVirtualKeyMCPConfig, error) + GetVirtualKeyMCPConfigsByMCPClientID(ctx context.Context, mcpClientID uint) ([]tables.TableVirtualKeyMCPConfig, error) + GetVirtualKeyMCPConfigsByMCPClientIDs(ctx context.Context, mcpClientIDs []uint) ([]tables.TableVirtualKeyMCPConfig, error) CreateVirtualKeyMCPConfig(ctx context.Context, virtualKeyMCPConfig *tables.TableVirtualKeyMCPConfig, tx ...*gorm.DB) error UpdateVirtualKeyMCPConfig(ctx context.Context, virtualKeyMCPConfig *tables.TableVirtualKeyMCPConfig, tx ...*gorm.DB) error DeleteVirtualKeyMCPConfig(ctx context.Context, id uint, tx ...*gorm.DB) error diff --git a/transports/bifrost-http/handlers/mcp.go b/transports/bifrost-http/handlers/mcp.go index d31f03a631..34e8ddfd02 100644 --- a/transports/bifrost-http/handlers/mcp.go +++ b/transports/bifrost-http/handlers/mcp.go @@ -19,6 +19,7 @@ import ( configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" "github.com/maximhq/bifrost/transports/bifrost-http/lib" "github.com/valyala/fasthttp" + "gorm.io/gorm" ) type MCPManager interface { @@ -30,19 +31,21 @@ type MCPManager interface { // MCPHandler manages HTTP requests for MCP tool operations type MCPHandler struct { - client *bifrost.Bifrost - store *lib.Config - mcpManager MCPManager - oauthHandler *OAuthHandler + client *bifrost.Bifrost + store *lib.Config + mcpManager MCPManager + governanceManager GovernanceManager + oauthHandler *OAuthHandler } // NewMCPHandler creates a new MCP handler instance -func NewMCPHandler(mcpManager MCPManager, client *bifrost.Bifrost, store *lib.Config, oauthHandler *OAuthHandler) *MCPHandler { +func NewMCPHandler(mcpManager MCPManager, governanceManager GovernanceManager, client *bifrost.Bifrost, store *lib.Config, oauthHandler *OAuthHandler) *MCPHandler { return &MCPHandler{ - client: client, - store: store, - mcpManager: mcpManager, - oauthHandler: oauthHandler, + client: client, + store: store, + mcpManager: mcpManager, + governanceManager: governanceManager, + oauthHandler: oauthHandler, } } @@ -56,11 +59,19 @@ func (h *MCPHandler) RegisterRoutes(r *router.Router, middlewares ...schemas.Bif r.POST("/api/mcp/client/{id}/complete-oauth", lib.ChainMiddlewares(h.completeMCPClientOAuth, middlewares...)) } +// MCPVKConfigResponse is a VK assignment enriched with the VK's display name. +type MCPVKConfigResponse struct { + VirtualKeyID string `json:"virtual_key_id"` + VirtualKeyName string `json:"virtual_key_name"` + ToolsToExecute schemas.WhiteList `json:"tools_to_execute"` +} + // MCPClientResponse represents the response structure for MCP clients type MCPClientResponse struct { - Config *schemas.MCPClientConfig `json:"config"` - Tools []schemas.ChatToolFunction `json:"tools"` - State schemas.MCPConnectionState `json:"state"` + Config *schemas.MCPClientConfig `json:"config"` + Tools []schemas.ChatToolFunction `json:"tools"` + State schemas.MCPConnectionState `json:"state"` + VKConfigs []MCPVKConfigResponse `json:"vk_configs"` } // getMCPClients handles GET /api/mcp/clients - Get all MCP clients @@ -189,6 +200,30 @@ func (h *MCPHandler) getMCPClientsPaginated(ctx *fasthttp.RequestCtx, limitStr, connectedClientsMap[client.Config.ID] = client } + // Build VK id→name lookup from in-memory governance data (no extra DB queries) + vkNameByID := make(map[string]string) + if h.governanceManager != nil { + if gd := h.governanceManager.GetGovernanceData(); gd != nil { + for _, vk := range gd.VirtualKeys { + vkNameByID[vk.ID] = vk.Name + } + } + } + + // Batch-fetch all VK assignments for this page in a single query, then group by client ID. + assignmentsByClientID := make(map[uint][]configstoreTables.TableVirtualKeyMCPConfig) + if h.store.ConfigStore != nil { + dbClientIDs := make([]uint, 0, len(dbClients)) + for _, c := range dbClients { + dbClientIDs = append(dbClientIDs, c.ID) + } + if allAssignments, err := h.store.ConfigStore.GetVirtualKeyMCPConfigsByMCPClientIDs(ctx, dbClientIDs); err == nil { + for _, a := range allAssignments { + assignmentsByClientID[a.MCPClientID] = append(assignmentsByClientID[a.MCPClientID], a) + } + } + } + // Convert DB rows to MCPClientConfig and merge with engine state clients := make([]MCPClientResponse, 0, len(dbClients)) for _, dbClient := range dbClients { @@ -212,6 +247,15 @@ func (h *MCPHandler) getMCPClientsPaginated(ctx *fasthttp.RequestCtx, limitStr, ToolSyncInterval: time.Duration(dbClient.ToolSyncInterval) * time.Minute, ToolPricing: dbClient.ToolPricing, } + // Enrich VK assignments using the pre-fetched batch result (no extra DB call per client) + vkConfigs := []MCPVKConfigResponse{} + for _, a := range assignmentsByClientID[dbClient.ID] { + vkConfigs = append(vkConfigs, MCPVKConfigResponse{ + VirtualKeyID: a.VirtualKeyID, + VirtualKeyName: vkNameByID[a.VirtualKeyID], + ToolsToExecute: a.ToolsToExecute, + }) + } redactedConfig := h.store.RedactMCPClientConfig(clientConfig) if connectedClient, exists := connectedClientsMap[clientConfig.ID]; exists { sortedTools := make([]schemas.ChatToolFunction, len(connectedClient.Tools)) @@ -220,15 +264,17 @@ func (h *MCPHandler) getMCPClientsPaginated(ctx *fasthttp.RequestCtx, limitStr, return sortedTools[i].Name < sortedTools[j].Name }) clients = append(clients, MCPClientResponse{ - Config: redactedConfig, - Tools: sortedTools, - State: connectedClient.State, + Config: redactedConfig, + Tools: sortedTools, + State: connectedClient.State, + VKConfigs: vkConfigs, }) } else { clients = append(clients, MCPClientResponse{ - Config: redactedConfig, - Tools: []schemas.ChatToolFunction{}, - State: schemas.MCPConnectionStateError, + Config: redactedConfig, + Tools: []schemas.ChatToolFunction{}, + State: schemas.MCPConnectionStateError, + VKConfigs: vkConfigs, }) } } @@ -279,6 +325,18 @@ type MCPClientRequest struct { OauthConfig *OAuthConfigRequest `json:"oauth_config,omitempty"` } +// MCPVKConfigRequest represents a per-VK tool access config for an MCP client +type MCPVKConfigRequest struct { + VirtualKeyID string `json:"virtual_key_id"` + ToolsToExecute schemas.WhiteList `json:"tools_to_execute"` +} + +// MCPClientUpdateRequest wraps TableMCPClient and adds optional VK assignment management +type MCPClientUpdateRequest struct { + configstoreTables.TableMCPClient + VKConfigs *[]MCPVKConfigRequest `json:"vk_configs,omitempty"` +} + // addMCPClient handles POST /api/mcp/client - Add a new MCP client func (h *MCPHandler) addMCPClient(ctx *fasthttp.RequestCtx) { if h.store.ConfigStore == nil { @@ -486,8 +544,8 @@ func (h *MCPHandler) updateMCPClient(ctx *fasthttp.RequestCtx) { SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid id: %v", err)) return } - // Accept the full table client config to support tool_pricing - var req *configstoreTables.TableMCPClient + // Accept the full table client config to support tool_pricing, plus optional vk_configs + var req MCPClientUpdateRequest if err := json.Unmarshal(ctx.PostBody(), &req); err != nil { SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid request format: %v", err)) return @@ -533,7 +591,8 @@ func (h *MCPHandler) updateMCPClient(ctx *fasthttp.RequestCtx) { } // Merge redacted values - preserve old values if incoming values are redacted and unchanged - req = mergeMCPRedactedValues(req, existingConfig, h.store.RedactMCPClientConfig(existingConfig)) + merged := mergeMCPRedactedValues(&req.TableMCPClient, existingConfig, h.store.RedactMCPClientConfig(existingConfig)) + req.TableMCPClient = *merged // Save existing DB config before update so we can rollback if memory update fails var oldDBConfig *configstoreTables.TableMCPClient if h.store.ConfigStore != nil { @@ -546,7 +605,7 @@ func (h *MCPHandler) updateMCPClient(ctx *fasthttp.RequestCtx) { } // Persist changes to config store if h.store.ConfigStore != nil { - if err := h.store.ConfigStore.UpdateMCPClientConfig(ctx, id, req); err != nil { + if err := h.store.ConfigStore.UpdateMCPClientConfig(ctx, id, &req.TableMCPClient); err != nil { SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to update mcp client config in store: %v", err)) return } @@ -595,6 +654,100 @@ func (h *MCPHandler) updateMCPClient(ctx *fasthttp.RequestCtx) { return } + // Manage VK assignments if vk_configs was provided + if req.VKConfigs != nil && h.store.ConfigStore != nil { + current, err := h.store.ConfigStore.GetVirtualKeyMCPConfigsByMCPClientID(ctx, oldDBConfig.ID) + if err != nil { + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to get current VK MCP configs: %v", err)) + return + } + // Index current assignments by VK ID for diffing + currentByVKID := make(map[string]*configstoreTables.TableVirtualKeyMCPConfig, len(current)) + for i := range current { + currentByVKID[current[i].VirtualKeyID] = ¤t[i] + } + // Validate and reject empty/duplicate virtual_key_id entries + seen := make(map[string]struct{}, len(*req.VKConfigs)) + for _, vc := range *req.VKConfigs { + if vc.VirtualKeyID == "" { + SendError(ctx, fasthttp.StatusBadRequest, "virtual_key_id must not be empty") + return + } + if _, exists := seen[vc.VirtualKeyID]; exists { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("duplicate virtual_key_id in vk_configs: %s", vc.VirtualKeyID)) + return + } + seen[vc.VirtualKeyID] = struct{}{} + } + // Validate tools_to_execute before entering the transaction so failures return 400 + for _, vc := range *req.VKConfigs { + if err := vc.ToolsToExecute.Validate(); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("invalid tools_to_execute for virtual key %s: %v", vc.VirtualKeyID, err)) + return + } + } + // Index requested assignments by VK ID + requestedByVKID := make(map[string]MCPVKConfigRequest, len(*req.VKConfigs)) + for _, vc := range *req.VKConfigs { + requestedByVKID[vc.VirtualKeyID] = vc + } + if err := h.store.ConfigStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error { + // Create or update + for _, vc := range *req.VKConfigs { + if existing, ok := currentByVKID[vc.VirtualKeyID]; ok { + existing.ToolsToExecute = vc.ToolsToExecute + if err := h.store.ConfigStore.UpdateVirtualKeyMCPConfig(ctx, existing, tx); err != nil { + return fmt.Errorf("failed to update VK MCP config for %s: %w", vc.VirtualKeyID, err) + } + } else { + if err := h.store.ConfigStore.CreateVirtualKeyMCPConfig(ctx, &configstoreTables.TableVirtualKeyMCPConfig{ + VirtualKeyID: vc.VirtualKeyID, + MCPClientID: oldDBConfig.ID, + ToolsToExecute: vc.ToolsToExecute, + }, tx); err != nil { + return fmt.Errorf("failed to create VK MCP config for %s: %w", vc.VirtualKeyID, err) + } + } + } + // Delete removed assignments + for vkID, existing := range currentByVKID { + if _, ok := requestedByVKID[vkID]; !ok { + if err := h.store.ConfigStore.DeleteVirtualKeyMCPConfig(ctx, existing.ID, tx); err != nil { + return fmt.Errorf("failed to remove VK MCP config for %s: %w", vkID, err) + } + } + } + return nil + }); err != nil { + // NOTE: Partial success — the MCP client config was already updated in DB and memory above. + // Only the VK assignment changes failed. The VK assignments remain unchanged in DB. + // The MCP client update is idempotent, so retrying the full request is safe. + logger.Error(fmt.Sprintf( + "[PARTIAL SUCCESS] MCP client %s was updated successfully but VK assignment update failed: %v. "+ + "VK assignments remain unchanged. Retry the request to apply VK changes.", + id, err, + )) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("MCP client was updated but VK assignment update failed: %v", err)) + return + } + // Reload all affected VKs in memory so governance enforcement reflects the new MCP assignments. + // requestedByVKID and currentByVKID together cover the full affected set (no duplicates since both are maps). + if h.governanceManager != nil { + for vkID := range requestedByVKID { + if _, err := h.governanceManager.ReloadVirtualKey(ctx, vkID); err != nil { + logger.Error(fmt.Sprintf("failed to reload virtual key %s in memory after MCP VK assignment update: %v", vkID, err)) + } + } + for vkID := range currentByVKID { + if _, alreadyReloaded := requestedByVKID[vkID]; !alreadyReloaded { + if _, err := h.governanceManager.ReloadVirtualKey(ctx, vkID); err != nil { + logger.Error(fmt.Sprintf("failed to reload virtual key %s in memory after MCP VK assignment update: %v", vkID, err)) + } + } + } + } + } + SendJSON(ctx, map[string]any{ "status": "success", "message": "MCP client edited successfully", diff --git a/transports/bifrost-http/server/server.go b/transports/bifrost-http/server/server.go index b873c57092..719e926b5e 100644 --- a/transports/bifrost-http/server/server.go +++ b/transports/bifrost-http/server/server.go @@ -514,11 +514,11 @@ func (s *BifrostHTTPServer) ReloadProvider(ctx context.Context, provider schemas // Run filtered and unfiltered model listing concurrently var ( - allModels *schemas.BifrostListModelsResponse - bifrostErr *schemas.BifrostError - unfilteredModels *schemas.BifrostListModelsResponse - listModelsErr *schemas.BifrostError - listWg sync.WaitGroup + allModels *schemas.BifrostListModelsResponse + bifrostErr *schemas.BifrostError + unfilteredModels *schemas.BifrostListModelsResponse + listModelsErr *schemas.BifrostError + listWg sync.WaitGroup ) listWg.Add(2) go func() { @@ -1038,7 +1038,7 @@ func (s *BifrostHTTPServer) RegisterAPIRoutes(ctx context.Context, callbacks Ser healthHandler := handlers.NewHealthHandler(s.Config) providerHandler := handlers.NewProviderHandler(callbacks, s.Config, s.Client) oauthHandler := handlers.NewOAuthHandler(s.Config.OAuthProvider, s.Client, s.Config) - mcpHandler := handlers.NewMCPHandler(callbacks, s.Client, s.Config, oauthHandler) + mcpHandler := handlers.NewMCPHandler(callbacks, callbacks, s.Client, s.Config, oauthHandler) configHandler := handlers.NewConfigHandler(callbacks, s.Config) pluginsHandler := handlers.NewPluginsHandler(callbacks, s.Config.ConfigStore) sessionHandler := handlers.NewSessionHandler(s.Config.ConfigStore, s.WSTicketStore) diff --git a/transports/changelog.md b/transports/changelog.md index 194e178e18..00d4155acc 100644 --- a/transports/changelog.md +++ b/transports/changelog.md @@ -5,4 +5,5 @@ - feat: add support for request level extra headers in MCP tool execution. - fix: add support for `x-bf-mcp-include-clients` and `x-bf-mcp-include-tools` request headers to filter MCP tools/list response when using bifrost as an MCP gateway. - refactor: parallelize model listing for providers to speed up startup time. -- fix: send back accumulated usage in MCP agent mode. \ No newline at end of file +- 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. diff --git a/ui/app/workspace/mcp-registry/views/mcpClientForm.tsx b/ui/app/workspace/mcp-registry/views/mcpClientForm.tsx index e25fa07c63..991bf13870 100644 --- a/ui/app/workspace/mcp-registry/views/mcpClientForm.tsx +++ b/ui/app/workspace/mcp-registry/views/mcpClientForm.tsx @@ -541,6 +541,7 @@ const ClientForm: React.FC = ({ open, onClose, onSaved }) => { )} + {/* Form Footer */}
diff --git a/ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx b/ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx index 8ea6a99ad6..f43e78ed46 100644 --- a/ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx +++ b/ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx @@ -7,6 +7,8 @@ import { Button } from "@/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { HeadersTable } from "@/components/ui/headersTable"; import { Input } from "@/components/ui/input"; +import { MultiSelect } from "@/components/ui/multiSelect"; +import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Switch } from "@/components/ui/switch"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; @@ -14,13 +16,14 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { TriStateCheckbox } from "@/components/ui/tristateCheckbox"; import { useToast } from "@/hooks/use-toast"; import { MCP_STATUS_COLORS } from "@/lib/constants/config"; -import { getErrorMessage, useGetCoreConfigQuery, useUpdateMCPClientMutation } from "@/lib/store"; -import { MCPClient } from "@/lib/types/mcp"; +import { getErrorMessage, useGetCoreConfigQuery, useGetVirtualKeysQuery, useUpdateMCPClientMutation } from "@/lib/store"; +import { MCPClient, MCPVKConfig } from "@/lib/types/mcp"; import { mcpClientUpdateSchema, type MCPClientUpdateSchema } from "@/lib/types/schemas"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; import { zodResolver } from "@hookform/resolvers/zod"; -import { ChevronDown, ChevronRight, Info } from "lucide-react"; -import { useEffect, useState } from "react"; +import { ChevronDown, ChevronRight, Info, Plus, Trash2 } from "lucide-react"; +import { useDebouncedValue } from "@/hooks/useDebounce"; +import { useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { CodeEditor } from "@/components/ui/codeEditor"; @@ -47,6 +50,73 @@ export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess }: const { toast } = useToast(); const [expandedTools, setExpandedTools] = useState>(new Set()); + // VK access management — search-based dropdown (limit 20), no pagination issue + const [vkSearch, setVKSearch] = useState(""); + const [vkSelectValue, setVKSelectValue] = useState(""); + const debouncedVkSearch = useDebouncedValue(vkSearch, 300); + const { data: vksData } = useGetVirtualKeysQuery({ limit: 20, search: debouncedVkSearch || undefined }); + const allToolNames = useMemo(() => mcpClient.tools?.map((t) => t.name) ?? [], [mcpClient.tools]); + + // Initial VK configs come directly from the MCP client response — always complete, no pagination issue. + const initialVKConfigs = useMemo( + () => (mcpClient.vk_configs ?? []).map((vc) => ({ virtual_key_id: vc.virtual_key_id, tools_to_execute: vc.tools_to_execute })), + [mcpClient.vk_configs], + ); + + const [vkConfigs, setVKConfigs] = useState([]); + const [vkConfigsDirty, setVKConfigsDirty] = useState(false); + // Persists names for newly added VKs so they survive search result changes + const [localVKNames, setLocalVKNames] = useState>({}); + + // Sync vkConfigs when mcpClient changes + useEffect(() => { + setVKConfigs(initialVKConfigs); + setVKConfigsDirty(false); + setLocalVKNames({}); + }, [initialVKConfigs]); + + // Name lookup: server response names → search results → locally cached names (highest priority) + const vkNameByID = useMemo>(() => { + const m: Record = {}; + for (const vc of mcpClient.vk_configs ?? []) m[vc.virtual_key_id] = vc.virtual_key_name; + for (const vk of vksData?.virtual_keys ?? []) m[vk.id] = vk.name; + Object.assign(m, localVKNames); + return m; + }, [mcpClient.vk_configs, vksData, localVKNames]); + + const vkOptions = useMemo( + () => + (vksData?.virtual_keys ?? []) + .filter((vk) => !vkConfigs.some((vc) => vc.virtual_key_id === vk.id)) + .map((vk) => ({ value: vk.id, label: vk.name })), + [vksData, vkConfigs], + ); + + const toolOptions = useMemo( + () => [ + { value: "*", label: "Allow All Tools", description: "Allow all current and future tools" }, + ...allToolNames.map((n) => ({ value: n, label: n })), + ], + [allToolNames], + ); + + const addVKConfig = (vkId: string) => { + const name = vksData?.virtual_keys?.find((vk) => vk.id === vkId)?.name; + if (name) setLocalVKNames((prev) => ({ ...prev, [vkId]: name })); + setVKConfigs((prev) => [...prev, { virtual_key_id: vkId, tools_to_execute: ["*"] }]); + setVKConfigsDirty(true); + }; + + const removeVKConfig = (vkId: string) => { + setVKConfigs((prev) => prev.filter((vc) => vc.virtual_key_id !== vkId)); + setVKConfigsDirty(true); + }; + + const updateVKConfigTools = (vkId: string, tools: string[]) => { + setVKConfigs((prev) => prev.map((vc) => (vc.virtual_key_id === vkId ? { ...vc, tools_to_execute: tools } : vc))); + setVKConfigsDirty(true); + }; + const toggleToolExpanded = (toolName: string) => { setExpandedTools((prev) => { const next = new Set(prev); @@ -104,6 +174,7 @@ export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess }: tool_pricing: data.tool_pricing, tool_sync_interval: data.tool_sync_interval ?? 0, allowed_extra_headers: data.allowed_extra_headers, + vk_configs: vkConfigsDirty ? vkConfigs : undefined, }, }).unwrap(); @@ -238,7 +309,7 @@ export default function MCPClientSheet({ mcpClient, onClose, onSubmitSuccess }:
)} + + {mcpClient.tools && mcpClient.tools.length > 0 && ( +
+
+
+
Virtual Key Access
+ + + + + + +

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

+
+
+
+
+ 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
+ )} + + +
+ + {vkConfigs.length > 0 ? ( +
+ + + + Virtual Key + Allowed Tools + + + + + {vkConfigs.map((vc) => ( + + {vkNameByID[vc.virtual_key_id] ?? vc.virtual_key_id} + + { + const hadStar = vc.tools_to_execute.includes("*"); + const hasStar = tools.includes("*"); + let next: string[]; + if (!hadStar && hasStar) { + next = ["*"]; + } else if (hadStar && hasStar && tools.length > 1) { + next = tools.filter((t) => t !== "*"); + } else { + next = tools; + } + updateVKConfigTools(vc.virtual_key_id, next); + }} + placeholder={vc.tools_to_execute.includes("*") ? "All tools allowed" : vc.tools_to_execute.length === 0 ? "No tools allowed" : "Select tools..."} + maxCount={3} + className="bg-background dark:bg-input/30 border-input rounded-sm text-foreground hover:bg-accent hover:text-accent-foreground font-normal" + /> + + + + + + ))} + +
+
+ ) : ( +
+

No virtual keys have access to this MCP server

+
+ )} + + )} - + ); } diff --git a/ui/lib/types/mcp.ts b/ui/lib/types/mcp.ts index 77288daff4..18dd1e2bdf 100644 --- a/ui/lib/types/mcp.ts +++ b/ui/lib/types/mcp.ts @@ -43,10 +43,17 @@ export interface MCPClientConfig { allowed_extra_headers?: string[]; // Allowlist of x-bf-eh-* headers forwarded to this MCP server. ["*"] = allow all. } +export interface MCPVKConfigResponse { + virtual_key_id: string; + virtual_key_name: string; + tools_to_execute: string[]; +} + export interface MCPClient { config: MCPClientConfig; tools: ToolFunction[]; state: MCPConnectionState; + vk_configs: MCPVKConfigResponse[]; } export interface CreateMCPClientRequest { @@ -82,6 +89,11 @@ export interface OAuthStatusResponse { token_scopes?: string; } +export interface MCPVKConfig { + virtual_key_id: string; + tools_to_execute: string[]; +} + export interface UpdateMCPClientRequest { name?: string; is_code_mode_client?: boolean; @@ -92,6 +104,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. + vk_configs?: MCPVKConfig[]; // When provided, replaces all VK assignments for this MCP client } // Pagination params for MCP clients list