feat: add OAuth 2.0 support for MCP clients with discovery and PKCE#1430
feat: add OAuth 2.0 support for MCP clients with discovery and PKCE#1430Pratham-Mishra04 wants to merge 5 commits intographite-base/1430from
Conversation
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
|
Caution Review failedThe pull request is closed. 📝 WalkthroughSummary by CodeRabbit
✏️ Tip: You can customize this high-level summary in your review settings. WalkthroughThis PR introduces OAuth2 authentication support for MCP clients and replaces the TypeScript/Goja code execution engine with a Starlark/Python-based system. Changes include new OAuth2 framework with token management and PKCE support, extended MCP client configuration with auth types, database migrations for OAuth tables, client tool synchronization infrastructure, and corresponding API/UI updates. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant HTTPServer
participant OAuthHandler
participant OAuth2Provider
participant ConfigStore
participant MCP as MCPManager
Client->>HTTPServer: POST /api/mcp/client (auth_type: oauth)
HTTPServer->>OAuthHandler: InitiateOAuthFlow()
OAuthHandler->>OAuth2Provider: InitiateOAuthFlow(config)
OAuth2Provider->>ConfigStore: CreateOauthConfig()
OAuth2Provider->>OAuth2Provider: GeneratePKCEChallenge()
OAuth2Provider->>ConfigStore: StorePendingMCPClient()
OAuth2Provider-->>OAuthHandler: OAuth2FlowInitiation{authorize_url, state, oauth_config_id}
OAuthHandler-->>Client: pending_oauth response
Client->>OAuthHandler: GET /api/oauth/callback?code=X&state=Y
OAuthHandler->>OAuth2Provider: CompleteOAuthFlow(state, code)
OAuth2Provider->>OAuth2Provider: exchangeCodeForTokensWithPKCE()
OAuth2Provider->>ConfigStore: CreateOauthToken()
OAuth2Provider->>ConfigStore: UpdateOauthConfig(status: authorized)
Client->>HTTPServer: POST /api/mcp/client/{id}/complete-oauth
HTTPServer->>OAuth2Provider: GetPendingMCPClient()
HTTPServer->>MCP: AddMCPClient(config)
MCP->>ConfigStore: CreateMCPClientConfig()
OAuth2Provider->>ConfigStore: RemovePendingMCPClient()
HTTPServer-->>Client: success
sequenceDiagram
participant Agent
participant CodeMode as StarlarkCodeMode
participant Starlark as Starlark Runtime
participant MCP as MCPManager
participant Tool as MCP Tool
Agent->>CodeMode: ExecuteTool(executeToolCode)
CodeMode->>CodeMode: executeCode(python_code)
CodeMode->>Starlark: thread.Execute()
Starlark->>Starlark: Bind MCP server structs
Agent->>Starlark: server.tool_name(args)
Starlark->>CodeMode: callMCPTool()
CodeMode->>MCP: ExecuteToolCall()
MCP->>Tool: call_tool()
Tool-->>MCP: result
MCP-->>CodeMode: response
CodeMode->>Starlark: return result
Starlark-->>CodeMode: execution_result
CodeMode-->>Agent: ChatMessage{result, logs, errors}
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 12
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
transports/bifrost-http/lib/config.go (1)
1450-1483: Prevent empty auth/state values from overriding defaults.If
AuthTypeorStateare empty (legacy rows or omitted payloads), these conversions persist/return empty strings and bypass DB defaults, which can regress existing MCP clients. Consider defaulting to"headers"and"disconnected"on both conversions.🔧 Suggested fix
func convertSchemasMCPClientConfigToTable(clientConfig schemas.MCPClientConfig) configstoreTables.TableMCPClient { + authType := string(clientConfig.AuthType) + if authType == "" { + authType = "headers" + } + state := clientConfig.State + if state == "" { + state = "disconnected" + } return configstoreTables.TableMCPClient{ ClientID: clientConfig.ID, Name: clientConfig.Name, IsCodeModeClient: clientConfig.IsCodeModeClient, @@ - AuthType: string(clientConfig.AuthType), + AuthType: authType, OauthConfigID: clientConfig.OauthConfigID, - State: clientConfig.State, + State: state, } } func convertTablesMCPClientToSchemas(mcpClient configstoreTables.TableMCPClient) schemas.MCPClientConfig { + authType := mcpClient.AuthType + if authType == "" { + authType = "headers" + } + state := mcpClient.State + if state == "" { + state = "disconnected" + } return schemas.MCPClientConfig{ ID: mcpClient.ClientID, Name: mcpClient.Name, @@ - AuthType: schemas.MCPAuthType(mcpClient.AuthType), + AuthType: schemas.MCPAuthType(authType), OauthConfigID: mcpClient.OauthConfigID, - State: mcpClient.State, + State: state, } }ui/app/workspace/mcp-gateway/views/mcpClientForm.tsx (1)
124-135: Don’t validate/send headers when auth_type isn’t"headers".Hidden headers still affect validation and payload, which can block OAuth submissions without any visible error. Gate validation/payload by
auth_type(or clear headers on auth type change).🔧 Suggested fix
- if ((form.connection_type === "http" || form.connection_type === "sse") && form.headers) { + if ((form.connection_type === "http" || form.connection_type === "sse") && form.auth_type === "headers" && form.headers) { // Ensure all EnvVar values have either a value or env_var for (const [key, envVar] of Object.entries(form.headers)) { if (!envVar.value && !envVar.env_var) { return `Header "${key}" must have a value`; } } } @@ - headers: form.headers && Object.keys(form.headers).length > 0 ? form.headers : undefined, + headers: form.auth_type === "headers" && form.headers && Object.keys(form.headers).length > 0 ? form.headers : undefined,Also applies to: 239-239, 356-368
🤖 Fix all issues with AI agents
In `@core/schemas/oauth.go`:
- Around line 56-64: Add the missing code_verifier field to the
TokenExchangeRequest struct so the schema reflects PKCE token exchanges; update
TokenExchangeRequest by adding a CodeVerifier string
`json:"code_verifier,omitempty"` field (matching how
exchangeCodeForTokensWithPKCE() sets data.Set("code_verifier", codeVerifier)) to
ensure the struct models the actual request payload.
In `@framework/configstore/tables/oauth.go`:
- Around line 12-55: The OAuth secrets (ClientSecret, CodeVerifier in
TableOauthConfig; AccessToken, RefreshToken in TableOauthToken) are stored
plaintext—call the existing encryption helper before persisting: update the code
paths that call CreateOauthConfig (e.g., framework/oauth/main.go around the
oauthConfigRecord creation at line ~342) and CreateOauthToken (around
tokenRecord at ~427) to run Encrypt() on those fields and replace the plaintext
values with the ciphertext, and ensure the BeforeSave hook
(TableOauthConfig.BeforeSave or TableOauthToken equivalent) does not overwrite
encrypted fields; use the encryption functions from framework/encrypt/encrypt.go
and handle/return errors from Encrypt() so CreateOauthConfig/CreateOauthToken
fail on encryption errors.
In `@framework/oauth/main.go`:
- Around line 324-425: The models TableOauthConfig and TableOauthToken currently
only set defaults in their BeforeSave hooks but do not encrypt sensitive fields
nor provide AfterFind decryption; implement symmetric behavior like rdb.go by
calling encrypt.Encrypt(...) in BeforeSave to replace plaintext ClientSecret,
CodeVerifier (and any other sensitive fields on TableOauthConfig) and
AccessToken, RefreshToken (and TokenType if desired) on TableOauthToken, and add
corresponding AfterFind hooks that call encrypt.Decrypt(...) and restore
plaintext values, handling nil/empty cases and returning errors from the hooks
on failure; reference the existing encrypt.Encrypt/Decrypt helpers and mirror
the error handling pattern used in rdb.go so stored values are encrypted at
Create/Update and decrypted when read.
- Around line 88-131: RefreshAccessToken currently attempts a refresh even if
the stored token has no refresh_token and calls exchangeRefreshToken
unconditionally with clientSecret; update RefreshAccessToken to check that
token.RefreshToken is non-empty and return a clear error if missing, and modify
exchangeRefreshToken to only include client_secret in the token request when
clientSecret != "" (matching exchangeCodeForTokens/exchangeCodeForTokensWithPKCE
behavior) so public clients won’t send an empty secret; ensure you keep the same
call sites (RefreshAccessToken, exchangeRefreshToken) and preserve existing
error wrapping and UpdateOauthToken logic.
In `@framework/oauth/sync.go`:
- Around line 10-44: The Stop() method currently closes stopCh directly which
will panic if called more than once; modify TokenRefreshWorker to include a
sync.Once field (e.g., closeOnce), initialize it in NewTokenRefreshWorker, and
change Stop() to call w.closeOnce.Do(func(){ close(w.stopCh) }) so closing is
idempotent while preserving the logger.Info("Token refresh worker stopped")
call; reference TokenRefreshWorker, NewTokenRefreshWorker, Stop(), stopCh, and
add closeOnce to the struct.
In `@transports/bifrost-http/handlers/mcp.go`:
- Around line 220-226: The current code calls req.ConnectionString.GetValue()
without ensuring req.ConnectionString is non-nil, which can panic when
connection_string is omitted; update the check in the block that tests
req.OauthConfig.ClientID == "" to first guard req.ConnectionString != nil and
only call req.ConnectionString.GetValue() after that check, returning a 400 via
SendError(ctx, fasthttp.StatusBadRequest, "...") if ConnectionString is nil or
its GetValue() returns an empty string; keep references to
req.OauthConfig.ClientID, req.ConnectionString.GetValue(), and SendError(...)
when locating and modifying the code.
- Around line 600-603: Replace the direct call to h.store.UpdateOAuthClient with
the MCPManager abstraction by calling h.mcpManager.UpdateOAuthClient(ctx, id,
*mcpClient) so the higher-level BifrostHTTPServer implementation can perform the
config update and then run its SyncAllMCPServers orchestration; keep existing
logging (logger.Debug and logger.Error) and error handling intact but invoke
h.mcpManager.UpdateOAuthClient instead of h.store.UpdateOAuthClient (references:
h.mcpManager, UpdateOAuthClient, h.store.UpdateOAuthClient, MCPManager,
BifrostHTTPServer, SyncAllMCPServers, mcpClient, id).
In `@transports/bifrost-http/handlers/oauth.go`:
- Around line 108-137: The OAuth error strings (errorParam and errorDescription)
are injected raw into the HTML/JS response in the handler, allowing reflected
XSS; update the code that builds the response in oauth.go to properly escape
both values for their respective contexts: HTML-escape the error message used
inside the <p> tag (e.g., via html.EscapeString) and safely encode/escape the
value embedded in the inline script (preferably by JSON-encoding the string or
using a JS-escaping routine) so the window.opener.postMessage call cannot be
abused; ensure you replace direct fmt.Sprintf injections of
errorParam/errorDescription with the escaped/encoded variables when calling
ctx.SetBodyString.
In `@transports/bifrost-http/lib/config.go`:
- Around line 1560-1562: The OAuth provider is being initialized unconditionally
which passes nil when config.ConfigStore is disabled; change the initialization
in the constructor where config.OAuthProvider is set so it only calls
oauth.NewOauthProvider(config.ConfigStore, logger) when config.ConfigStore !=
nil (guard both occurrences noted around the OAuth initialization), otherwise
leave config.OAuthProvider nil and update any OAuth-related handlers (the code
paths that reference config.OAuthProvider) to fail fast with a clear error when
OAuth is requested but config.OAuthProvider is nil.
- Around line 2848-2882: In UpdateOAuthClient, avoid appending a stale or
duplicate entry: set mcpClient.State = "connected" before adding it to
c.MCPConfig.ClientConfigs, and perform an upsert (replace existing entry
matching ClientID/Name) instead of blind append so duplicates are not created;
when calling c.client.AddMCPClient and c.ConfigStore.UpdateMCPClientConfig,
ensure you roll back the in-memory change on error (remove or restore the
previous entry) to keep c.MCPConfig consistent; keep MCP catalog updates
(MCPCatalog.UpdatePricingData) after the successful store update.
In `@ui/app/workspace/mcp-gateway/views/oauthAuthorizer.tsx`:
- Around line 142-158: The postMessage handler (handleMessage) currently trusts
any oauth_success message; update it to validate messages come from the actual
OAuth popup before calling stopPolling() / checkOAuthStatus(): inside
handleMessage, first ensure popupRef?.current exists, then verify event.source
=== popupRef.current; next validate event.origin matches the popup's origin
(obtain via popupRef.current.location.origin inside a try/catch in case of
cross-origin access) and only proceed if both checks pass; otherwise ignore the
message. Keep these checks inside the same useEffect that declares
handleMessage.
In `@ui/next.config.ts`:
- Around line 23-31: The rewrites() function is incompatible with output:
"export" and will break static exports; either remove the rewrites() block or
make rewrites() conditional so it returns an empty array during export builds
(e.g., check a build-time env like process.env.NEXT_EXPORT === '1' or
process.env.NEXT_PUBLIC_OUTPUT === 'export' and return [] when exporting),
otherwise keep the dev proxy destination; alternatively move the proxy routing
to your deployment/server layer (Nginx/Vercel) and remove the rewrites()
function entirely.
🧹 Nitpick comments (9)
core/schemas/mcp.go (1)
83-86: Consider usingMCPConnectionStatetype for theStatefield.The
Statefield is typed asstring, but there's anMCPConnectionStatetype defined at line 167 with specific constants. However, the existing constants (connected,disconnected,error) don't includepending_oauth.♻️ Proposed fix - Add pending_oauth state and use typed field
Add the missing constant:
const ( MCPConnectionStateConnected MCPConnectionState = "connected" // Client is connected and ready to use MCPConnectionStateDisconnected MCPConnectionState = "disconnected" // Client is not connected MCPConnectionStateError MCPConnectionState = "error" // Client is in an error state, and cannot be used + MCPConnectionStatePendingOAuth MCPConnectionState = "pending_oauth" // Client is awaiting OAuth authorization )Then update the field type:
- State string `json:"state,omitempty"` // Connection state (connected, disconnected, error, pending_oauth) + State MCPConnectionState `json:"state,omitempty"` // Connection state (connected, disconnected, error, pending_oauth)core/mcp/mcp.go (1)
104-127: Consider using constants for MCP client states to improve maintainability.The string literal
"pending_oauth"is used for state comparison. The codebase already follows a pattern of defining constants for similar enumerations (e.g.,MCPAuthType), but state values are currently hard-coded strings across multiple files. Defining state constants in the schemas package would provide consistency and reduce the risk of typos.💡 Suggested improvement
// In core/schemas/mcp.go const ( MCPClientStateConnected = "connected" MCPClientStateDisconnected = "disconnected" MCPClientStateError = "error" MCPClientStatePendingOAuth = "pending_oauth" ) // Then in core/mcp/mcp.go (lines 109) if clientConfig.State != schemas.MCPClientStatePendingOAuth { clientsToLoad = append(clientsToLoad, clientConfig) } // And update transports/bifrost-http/handlers/mcp.go (lines 262, 289) req.State = schemas.MCPClientStatePendingOAuthframework/oauth/discovery_test.go (1)
61-68: Preferbifrost.Ptr(...)for request pointers.
This keeps pointer creation consistent with the rest of the codebase.♻️ Suggested refactor
import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" + + bifrost "github.com/maximhq/bifrost/core" ) ... - req := &DynamicClientRegistrationRequest{ + req := bifrost.Ptr(DynamicClientRegistrationRequest{ ClientName: "Bifrost MCP Gateway", RedirectURIs: []string{"https://bifrost.example.com/api/oauth/callback"}, GrantTypes: []string{"authorization_code", "refresh_token"}, ResponseTypes: []string{"code"}, TokenEndpointAuthMethod: "none", Scope: "read write", - } + }) ... - req := &DynamicClientRegistrationRequest{ + req := bifrost.Ptr(DynamicClientRegistrationRequest{ ClientName: "Bifrost MCP Gateway", RedirectURIs: []string{"https://bifrost.example.com/api/oauth/callback"}, GrantTypes: []string{"authorization_code"}, ResponseTypes: []string{"code"}, TokenEndpointAuthMethod: "none", - } + })Based on learnings, please prefer bifrost.Ptr() for pointer creation.
Also applies to: 95-101
transports/bifrost-http/handlers/oauth.go (2)
61-63: Use the request context instead ofcontext.Background().Passing
ctxpreserves cancellation, deadlines, and trace propagation across handler calls. Based on learnings, please pass*fasthttp.RequestCtxdirectly where acontext.Contextis expected.[details]
🔧 Suggested update
- if err := h.oauthProvider.CompleteOAuthFlow(context.Background(), state, code); err != nil { + if err := h.oauthProvider.CompleteOAuthFlow(ctx, state, code); err != nil { SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("OAuth flow completion failed: %v", err)) return } @@ - oauthConfig, err := h.store.ConfigStore.GetOauthConfigByState(context.Background(), state) + oauthConfig, err := h.store.ConfigStore.GetOauthConfigByState(ctx, state) @@ - oauthConfig, err := h.store.ConfigStore.GetOauthConfigByID(context.Background(), configID) + oauthConfig, err := h.store.ConfigStore.GetOauthConfigByID(ctx, configID) @@ - token, err := h.store.ConfigStore.GetOauthTokenByID(context.Background(), *oauthConfig.TokenID) + token, err := h.store.ConfigStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) @@ - if err := h.oauthProvider.RevokeToken(context.Background(), configID); err != nil { + if err := h.oauthProvider.RevokeToken(ctx, configID); err != nil { SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to revoke OAuth token: %v", err)) return }Also applies to: 100-104, 145-171, 182-183
207-210: Preferbifrost.Ptrfor optionalRegistrationURL.Keeps pointer creation consistent across the codebase. Based on learnings, please use the project helper.
[details]
🔧 Suggested update
var registrationURL *string if req.RegistrationURL != "" { - registrationURL = &req.RegistrationURL + registrationURL = bifrost.Ptr(req.RegistrationURL) }
framework/oauth/discovery.go (1)
53-134: Ensure logger is always initialized (or guard calls).Several
logger.Debug/Info/Warncalls are unguarded; ifDiscoverOAuthMetadataruns beforeSetLogger, this can nil‑panic. Consider a default logger or nil checks.Also applies to: 216-243
transports/bifrost-http/handlers/mcp.go (2)
261-262: Usebifrost.PtrforOauthConfigID.Keeps pointer creation consistent across the repo. Based on learnings, please use the helper.
[details]
🔧 Suggested update
- req.OauthConfigID = &flowInitiation.OauthConfigID + req.OauthConfigID = bifrost.Ptr(flowInitiation.OauthConfigID)
561-562: Pass the request context to ConfigStore.
Use ctx instead of context.Background() to keep cancellations and tracing intact. Based on learnings, pass *fasthttp.RequestCtx directly where a context.Context is expected.
[details]
- mcpClient, err := h.store.ConfigStore.GetMCPClientByID(context.Background(), id)
+ mcpClient, err := h.store.ConfigStore.GetMCPClientByID(ctx, id)framework/oauth/main.go (1)
431-433: Preferbifrost.Ptrfor token ID pointers.Keeps pointer creation consistent across the codebase. Based on learnings, please use the helper.
[details]
🔧 Suggested update
- oauthConfig.TokenID = &tokenID + oauthConfig.TokenID = bifrost.Ptr(tokenID)
| ID string `gorm:"type:varchar(255);primaryKey" json:"id"` // UUID | ||
| ClientID string `gorm:"type:varchar(512)" json:"client_id"` // OAuth provider's client ID (optional for public clients) | ||
| ClientSecret string `gorm:"type:text" json:"-"` // Encrypted OAuth client secret (optional for public clients) | ||
| AuthorizeURL string `gorm:"type:text" json:"authorize_url"` // Provider's authorization endpoint (optional, can be discovered) | ||
| TokenURL string `gorm:"type:text" json:"token_url"` // Provider's token endpoint (optional, can be discovered) | ||
| RegistrationURL *string `gorm:"type:text" json:"registration_url,omitempty"` // Provider's dynamic registration endpoint (optional, can be discovered) | ||
| RedirectURI string `gorm:"type:text;not null" json:"redirect_uri"` // Callback URL | ||
| Scopes string `gorm:"type:text" json:"scopes"` // JSON array of scopes (optional, can be discovered) | ||
| State string `gorm:"type:varchar(255);uniqueIndex;not null" json:"-"` // CSRF state token | ||
| CodeVerifier string `gorm:"type:varchar(255)" json:"-"` // PKCE code verifier (generated, kept secret) | ||
| CodeChallenge string `gorm:"type:varchar(255)" json:"code_challenge"` // PKCE code challenge (sent to provider) | ||
| Status string `gorm:"type:varchar(50);not null;index" json:"status"` // "pending", "authorized", "failed", "expired", "revoked" | ||
| TokenID *string `gorm:"type:varchar(255);index" json:"token_id"` // Foreign key to oauth_tokens.ID (set after callback) | ||
| ServerURL string `gorm:"type:text" json:"server_url"` // MCP server URL for OAuth discovery | ||
| UseDiscovery bool `gorm:"default:false" json:"use_discovery"` // Flag to enable OAuth discovery | ||
| CreatedAt time.Time `gorm:"index;not null" json:"created_at"` | ||
| UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"` | ||
| ExpiresAt time.Time `gorm:"index;not null" json:"expires_at"` // State expiry (15 min) | ||
| } | ||
|
|
||
| // TableName sets the table name | ||
| func (TableOauthConfig) TableName() string { | ||
| return "oauth_configs" | ||
| } | ||
|
|
||
| // BeforeSave hook | ||
| func (c *TableOauthConfig) BeforeSave(tx *gorm.DB) error { | ||
| // Ensure status is valid | ||
| if c.Status == "" { | ||
| c.Status = "pending" | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // TableOauthToken represents an OAuth token in the database | ||
| // This stores the actual access and refresh tokens | ||
| type TableOauthToken struct { | ||
| ID string `gorm:"type:varchar(255);primaryKey" json:"id"` // UUID | ||
| AccessToken string `gorm:"type:text;not null" json:"-"` // Encrypted access token | ||
| RefreshToken string `gorm:"type:text" json:"-"` // Encrypted refresh token (optional) | ||
| TokenType string `gorm:"type:varchar(50);not null" json:"token_type"` // "Bearer" | ||
| ExpiresAt time.Time `gorm:"index;not null" json:"expires_at"` // Token expiration | ||
| Scopes string `gorm:"type:text" json:"scopes"` // JSON array of granted scopes | ||
| LastRefreshedAt *time.Time `gorm:"index" json:"last_refreshed_at,omitempty"` // Track when token was last refreshed |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Search for OAuth table writes, encryption/decryption logic, and gorm hooks
echo "=== OAuth Config/Token references ==="
rg -n "TableOauthConfig|TableOauthToken" -g'*.go' --max-count=20
echo -e "\n=== Create/Save operations on OAuth tables ==="
rg -n "\.Create\(|\.Save\(|\.Create\(|\.Updates\(" -g'*.go' -B2 -A2 | grep -i oauth -A3 -B3
echo -e "\n=== Encryption/Decryption functions ==="
rg -n "func.*encrypt|func.*decrypt|func.*cipher|func.*crypto" -g'*.go'
echo -e "\n=== Imports related to crypto ==="
rg -n "crypto|cipher|encrypt" -g'*.go' | grep import -A10Repository: maximhq/bifrost
Length of output: 7326
🏁 Script executed:
#!/bin/bash
# Look for BeforeSave, BeforeCreate hooks and encryption middleware
echo "=== GORM hooks for OAuth ==="
rg -n "BeforeSave|BeforeCreate|AfterSave|BeforeUpdate" -g'*.go' -A5 | grep -i oauth -A5 -B5
echo -e "\n=== Database layer or utility functions for encryption ==="
find framework -name "*.go" -type f | xargs grep -l "encrypt\|decrypt" | head -10
echo -e "\n=== Check oauth.go file completely ==="
wc -l framework/configstore/tables/oauth.goRepository: maximhq/bifrost
Length of output: 1882
🏁 Script executed:
#!/bin/bash
# Get full context of oauth.go file to understand encryption approach
cat framework/configstore/tables/oauth.goRepository: maximhq/bifrost
Length of output: 4112
🏁 Script executed:
#!/bin/bash
# Check oauth/main.go around the table creation points (lines 324, 418)
echo "=== OAuth table creation in oauth/main.go ==="
sed -n '310,360p' framework/oauth/main.go
echo -e "\n=== OAuth token creation context ==="
sed -n '400,440p' framework/oauth/main.go
echo -e "\n=== Encrypt function (if exists) ==="
rg -n "^func Encrypt|^func encrypt" -g'*.go' -A3
echo -e "\n=== Full encrypt.go file ==="
cat framework/encrypt/encrypt.goRepository: maximhq/bifrost
Length of output: 8040
🏁 Script executed:
#!/bin/bash
# Check where CreateOauthConfig and CreateOauthToken are called
echo "=== Calls to CreateOauthConfig ==="
rg -n "CreateOauthConfig|CreateOauthToken" -g'*.go' -B3 -A1 | head -50
echo -e "\n=== Check if encryption is done on fields before store calls ==="
rg -n "ClientSecret|AccessToken|RefreshToken|CodeVerifier" -g'*.go' -B1 -A1 | grep -i "encrypt\|=\s" | head -30Repository: maximhq/bifrost
Length of output: 5608
Critical: OAuth sensitive fields are stored in plaintext—encryption infrastructure exists but is deliberately not integrated.
The codebase has full AES-256-GCM encryption infrastructure (framework/encrypt/encrypt.go), but it is explicitly not being used. The encrypt.go header states: "We are not using it anywhere yet—we will introduce encryption for all the sensitive data in one go to avoid breaking changes."
Raw OAuth credentials (ClientSecret, AccessToken, RefreshToken, CodeVerifier) are being stored directly in the database without encryption:
framework/oauth/main.go:342passes unencryptedoauthConfigRecordtoCreateOauthConfig()framework/oauth/main.go:427passes unencryptedtokenRecordtoCreateOauthToken()BeforeSavehooks only set defaults; they do not encrypt
Integrate the existing Encrypt() calls into the OAuth creation path before these objects reach the database, or clarify the security model if plaintext storage is intentional.
🤖 Prompt for AI Agents
In `@framework/configstore/tables/oauth.go` around lines 12 - 55, The OAuth
secrets (ClientSecret, CodeVerifier in TableOauthConfig; AccessToken,
RefreshToken in TableOauthToken) are stored plaintext—call the existing
encryption helper before persisting: update the code paths that call
CreateOauthConfig (e.g., framework/oauth/main.go around the oauthConfigRecord
creation at line ~342) and CreateOauthToken (around tokenRecord at ~427) to run
Encrypt() on those fields and replace the plaintext values with the ciphertext,
and ensure the BeforeSave hook (TableOauthConfig.BeforeSave or TableOauthToken
equivalent) does not overwrite encrypted fields; use the encryption functions
from framework/encrypt/encrypt.go and handle/return errors from Encrypt() so
CreateOauthConfig/CreateOauthToken fail on encryption errors.
| // RefreshAccessToken refreshes the access token for a given oauth_config_id | ||
| func (p *OauthProvider) RefreshAccessToken(ctx context.Context, oauthConfigID string) error { | ||
| p.mu.Lock() | ||
| defer p.mu.Unlock() | ||
|
|
||
| // Load oauth_config | ||
| oauthConfig, err := p.configStore.GetOauthConfigByID(ctx, oauthConfigID) | ||
| if err != nil || oauthConfig == nil { | ||
| return fmt.Errorf("oauth config not found: %w", err) | ||
| } | ||
|
|
||
| if oauthConfig.TokenID == nil { | ||
| return fmt.Errorf("no token linked to oauth config") | ||
| } | ||
|
|
||
| // Load oauth_token | ||
| token, err := p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) | ||
| if err != nil || token == nil { | ||
| return fmt.Errorf("oauth token not found: %w", err) | ||
| } | ||
|
|
||
| // Call OAuth provider's token endpoint with refresh_token | ||
| newTokenResponse, err := p.exchangeRefreshToken( | ||
| oauthConfig.TokenURL, | ||
| oauthConfig.ClientID, | ||
| oauthConfig.ClientSecret, | ||
| token.RefreshToken, | ||
| ) | ||
| if err != nil { | ||
| return fmt.Errorf("token refresh failed: %w", err) | ||
| } | ||
|
|
||
| // Update token in database | ||
| now := time.Now() | ||
| token.AccessToken = newTokenResponse.AccessToken | ||
| if newTokenResponse.RefreshToken != "" { | ||
| token.RefreshToken = newTokenResponse.RefreshToken | ||
| } | ||
| token.ExpiresAt = now.Add(time.Duration(newTokenResponse.ExpiresIn) * time.Second) | ||
| token.LastRefreshedAt = &now | ||
|
|
||
| if err := p.configStore.UpdateOauthToken(ctx, token); err != nil { | ||
| return fmt.Errorf("failed to update token: %w", err) | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's find and examine the OAuth file
find . -path "*/framework/oauth/main.go" -type fRepository: maximhq/bifrost
Length of output: 84
🏁 Script executed:
# Also check what files exist in framework/oauth
find . -path "*/framework/oauth*" -type f | head -20Repository: maximhq/bifrost
Length of output: 203
🌐 Web query:
RFC 6749 OAuth 2.0 refresh token client_secret public clients specifications
💡 Result:
In RFC 6749, whether a client uses client_secret when redeeming a refresh token depends on whether it’s a confidential client (or otherwise issued client credentials) vs a public client.
- Public clients are defined as clients incapable of maintaining the confidentiality of their credentials (e.g., browser-based JS apps and installed/native apps where embedded credentials can be extracted). They are not assumed to be able to keep a
client_secretconfidential. [1] - The authorization server may establish some authentication method for public clients, but it MUST NOT rely on public client authentication for the purpose of identifying the client. [1]
Client authentication / client_secret usage at the token endpoint
- For token endpoint requests, confidential clients (or any client issued client credentials) MUST authenticate (e.g., HTTP Basic with a client password / secret, or sending
client_id+client_secretin the request body—body credentials are “NOT RECOMMENDED”). [1] - In the refresh token grant specifically, RFC 6749 states: if the client is confidential or was issued client credentials (or assigned other authentication requirements), it MUST authenticate when presenting the refresh token. [2]
What this means in practice (per RFC 6749)
- A public client generally must not use a
client_secretas a security mechanism (because it can’t be kept confidential), and the spec treats “client authentication not possible” as a real case. The authorization server must still maintain refresh-token-to-client binding and verify that binding whenever client identity can be authenticated; when client authentication is not possible, the server SHOULD use other means to detect refresh token abuse (e.g., rotation is suggested). [3]
Sources
[1] RFC 6749 (OAuth 2.0), Sections 2.1–2.3.1 (Client Types / Client Authentication / Client Password) https://www.rfc-editor.org/rfc/rfc6749
[2] RFC 6749, Section 6 (Refreshing an Access Token) https://www.rfc-editor.org/rfc/rfc6749
[3] RFC 6749, Section 10.4 (Refresh Tokens — binding and when authentication isn’t possible) https://www.rfc-editor.org/rfc/rfc6749
🏁 Script executed:
# Read the OAuth main.go file to see the actual implementation
cat -n ./framework/oauth/main.goRepository: maximhq/bifrost
Length of output: 22617
🏁 Script executed:
# Check for other files that might be part of a PR stack or related changes
git log --oneline -20 2>/dev/null || echo "Not a git repo or git not available"Repository: maximhq/bifrost
Length of output: 98
🏁 Script executed:
# Check if there are any recent changes to the OAuth files
git status 2>/dev/null || echo "Git not available"Repository: maximhq/bifrost
Length of output: 258
🏁 Script executed:
# Check for any tests that verify the refresh token behavior
find . -name "*oauth*test*" -o -name "*test*oauth*" | grep -i oauth | head -10Repository: maximhq/bifrost
Length of output: 41
Handle refresh flows for public clients and missing refresh tokens.
Per RFC 6749, exchangeRefreshToken must not send client_secret when empty—public clients cannot maintain secret confidentiality and some OAuth servers reject empty secrets. Additionally, RefreshAccessToken should guard against missing refresh_token to fail gracefully rather than attempting refresh with an empty token.
Note: exchangeCodeForTokens and exchangeCodeForTokensWithPKCE already correctly guard client_secret with if clientSecret != "" checks; exchangeRefreshToken should follow the same pattern.
🔧 Suggested fix
func (p *OauthProvider) RefreshAccessToken(ctx context.Context, oauthConfigID string) error {
// Load oauth_token
token, err := p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID)
if err != nil || token == nil {
return fmt.Errorf("oauth token not found: %w", err)
}
+ if token.RefreshToken == "" {
+ return fmt.Errorf("refresh token missing; re-authorize the OAuth flow")
+ }
func (p *OauthProvider) exchangeRefreshToken(tokenURL, clientID, clientSecret, refreshToken string) (*schemas.TokenExchangeResponse, error) {
data := url.Values{}
data.Set("grant_type", "refresh_token")
data.Set("refresh_token", refreshToken)
data.Set("client_id", clientID)
- data.Set("client_secret", clientSecret)
+ if clientSecret != "" {
+ data.Set("client_secret", clientSecret)
+ }Also applies to: lines 504-512
🤖 Prompt for AI Agents
In `@framework/oauth/main.go` around lines 88 - 131, RefreshAccessToken currently
attempts a refresh even if the stored token has no refresh_token and calls
exchangeRefreshToken unconditionally with clientSecret; update
RefreshAccessToken to check that token.RefreshToken is non-empty and return a
clear error if missing, and modify exchangeRefreshToken to only include
client_secret in the token request when clientSecret != "" (matching
exchangeCodeForTokens/exchangeCodeForTokensWithPKCE behavior) so public clients
won’t send an empty secret; ensure you keep the same call sites
(RefreshAccessToken, exchangeRefreshToken) and preserve existing error wrapping
and UpdateOauthToken logic.
| ClientID: clientID, // May be from dynamic registration | ||
| ClientSecret: clientSecret, | ||
| AuthorizeURL: authorizeURL, | ||
| TokenURL: tokenURL, | ||
| RegistrationURL: registrationURL, | ||
| RedirectURI: config.RedirectURI, | ||
| Scopes: string(scopesJSON), | ||
| State: state, | ||
| CodeVerifier: codeVerifier, | ||
| CodeChallenge: codeChallenge, | ||
| Status: "pending", | ||
| ServerURL: config.ServerURL, | ||
| UseDiscovery: config.UseDiscovery, | ||
| ExpiresAt: expiresAt, | ||
| } | ||
|
|
||
| if err := p.configStore.CreateOauthConfig(ctx, oauthConfigRecord); err != nil { | ||
| return nil, fmt.Errorf("failed to create oauth config: %w", err) | ||
| } | ||
|
|
||
| // Build authorize URL with PKCE (using dynamically registered or user-provided client_id) | ||
| authURL := p.buildAuthorizeURLWithPKCE( | ||
| authorizeURL, | ||
| clientID, // May be from dynamic registration | ||
| config.RedirectURI, | ||
| state, | ||
| codeChallenge, | ||
| scopes, | ||
| ) | ||
|
|
||
| return &schemas.OauthFlowInitiation{ | ||
| OauthConfigID: oauthConfigID, | ||
| AuthorizeURL: authURL, | ||
| State: state, | ||
| ExpiresAt: expiresAt, | ||
| }, nil | ||
| } | ||
|
|
||
| // CompleteOAuthFlow handles the OAuth callback and exchanges code for tokens | ||
| // Supports PKCE verification | ||
| func (p *OauthProvider) CompleteOAuthFlow(ctx context.Context, state, code string) error { | ||
| // Lookup oauth_config by state | ||
| oauthConfig, err := p.configStore.GetOauthConfigByState(ctx, state) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to lookup oauth config: %w", err) | ||
| } | ||
| if oauthConfig == nil { | ||
| return fmt.Errorf("invalid state token") | ||
| } | ||
|
|
||
| // Check expiry | ||
| if time.Now().After(oauthConfig.ExpiresAt) { | ||
| oauthConfig.Status = "expired" | ||
| p.configStore.UpdateOauthConfig(ctx, oauthConfig) | ||
| return fmt.Errorf("oauth flow expired") | ||
| } | ||
|
|
||
| // Log token exchange attempt for debugging | ||
| logger.Debug("Attempting token exchange", | ||
| "token_url", oauthConfig.TokenURL, | ||
| "client_id", oauthConfig.ClientID, | ||
| "has_client_secret", oauthConfig.ClientSecret != "", | ||
| "has_pkce_verifier", oauthConfig.CodeVerifier != "") | ||
|
|
||
| // Exchange code for tokens with PKCE verifier | ||
| tokenResponse, err := p.exchangeCodeForTokensWithPKCE( | ||
| oauthConfig.TokenURL, | ||
| code, | ||
| oauthConfig.ClientID, | ||
| oauthConfig.ClientSecret, | ||
| oauthConfig.RedirectURI, | ||
| oauthConfig.CodeVerifier, // PKCE verifier | ||
| ) | ||
| if err != nil { | ||
| oauthConfig.Status = "failed" | ||
| p.configStore.UpdateOauthConfig(ctx, oauthConfig) | ||
| logger.Error("Token exchange failed", | ||
| "error", err.Error(), | ||
| "client_id", oauthConfig.ClientID, | ||
| "token_url", oauthConfig.TokenURL) | ||
| return fmt.Errorf("token exchange failed: %w", err) | ||
| } | ||
|
|
||
| // Parse scopes | ||
| var scopes []string | ||
| if tokenResponse.Scope != "" { | ||
| scopes = strings.Split(tokenResponse.Scope, " ") | ||
| } | ||
| scopesJSON, _ := json.Marshal(scopes) | ||
|
|
||
| // Create oauth_token record | ||
| tokenID := uuid.New().String() | ||
| tokenRecord := &tables.TableOauthToken{ | ||
| ID: tokenID, | ||
| AccessToken: tokenResponse.AccessToken, | ||
| RefreshToken: tokenResponse.RefreshToken, | ||
| TokenType: tokenResponse.TokenType, | ||
| ExpiresAt: time.Now().Add(time.Duration(tokenResponse.ExpiresIn) * time.Second), | ||
| Scopes: string(scopesJSON), | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Check for TableOauth references and encryption/decryption usage
rg "TableOauth(Config|Token)" --type go -nRepository: maximhq/bifrost
Length of output: 4982
🏁 Script executed:
# Check for encryption hooks in the OAuth tables file
rg "BeforeSave|AfterFind|Encrypt|Decrypt" framework/configstore/tables/oauth.go -nRepository: maximhq/bifrost
Length of output: 620
🏁 Script executed:
# Broader search for any encryption/decryption in configstore
rg "Encrypt|Decrypt" framework/configstore -n --type goRepository: maximhq/bifrost
Length of output: 1018
🏁 Script executed:
# Check the structure of the oauth tables
find framework/configstore -name "*oauth*" -type fRepository: maximhq/bifrost
Length of output: 96
🏁 Script executed:
# View the BeforeSave hooks and surrounding code in oauth.go
cat -n framework/configstore/tables/oauth.goRepository: maximhq/bifrost
Length of output: 4616
🏁 Script executed:
# Check if there are AfterFind hooks or other decryption mechanisms
rg "AfterFind|func.*TableOauth" framework/configstore/tables/oauth.go -A 5 -nRepository: maximhq/bifrost
Length of output: 603
🏁 Script executed:
# Verify the encrypt package usage pattern
rg "encrypt.Encrypt|encrypt.Decrypt" framework/configstore -B 2 -A 2 -n --type goRepository: maximhq/bifrost
Length of output: 879
Implement encryption for OAuth sensitive fields.
The BeforeSave hooks in TableOauthConfig and TableOauthToken do not actually encrypt the stored secrets, despite comments claiming they are encrypted. Comments state fields like ClientSecret, AccessToken, and RefreshToken are encrypted, but the hooks only set defaults (status and token type). No AfterFind hooks exist to decrypt on read either. Secrets are stored in plaintext.
The codebase already has an encrypt.Encrypt/Decrypt pattern (used for proxy passwords in rdb.go). Apply the same pattern to encrypt these fields on write and decrypt on read.
🤖 Prompt for AI Agents
In `@framework/oauth/main.go` around lines 324 - 425, The models TableOauthConfig
and TableOauthToken currently only set defaults in their BeforeSave hooks but do
not encrypt sensitive fields nor provide AfterFind decryption; implement
symmetric behavior like rdb.go by calling encrypt.Encrypt(...) in BeforeSave to
replace plaintext ClientSecret, CodeVerifier (and any other sensitive fields on
TableOauthConfig) and AccessToken, RefreshToken (and TokenType if desired) on
TableOauthToken, and add corresponding AfterFind hooks that call
encrypt.Decrypt(...) and restore plaintext values, handling nil/empty cases and
returning errors from the hooks on failure; reference the existing
encrypt.Encrypt/Decrypt helpers and mirror the error handling pattern used in
rdb.go so stored values are encrypted at Create/Update and decrypted when read.
| // TokenRefreshWorker manages automatic token refresh for expiring OAuth tokens | ||
| type TokenRefreshWorker struct { | ||
| provider *OauthProvider | ||
| refreshInterval time.Duration | ||
| lookAheadWindow time.Duration // How far ahead to look for expiring tokens | ||
| stopCh chan struct{} | ||
| logger schemas.Logger | ||
| } | ||
|
|
||
| // NewTokenRefreshWorker creates a new token refresh worker | ||
| func NewTokenRefreshWorker(provider *OauthProvider, logger schemas.Logger) *TokenRefreshWorker { | ||
| return &TokenRefreshWorker{ | ||
| provider: provider, | ||
| refreshInterval: 5 * time.Minute, // Check every 5 minutes | ||
| lookAheadWindow: 5 * time.Minute, // Refresh tokens expiring in next 5 minutes | ||
| stopCh: make(chan struct{}), | ||
| logger: logger, | ||
| } | ||
| } | ||
|
|
||
| // Start begins the token refresh worker in a background goroutine | ||
| func (w *TokenRefreshWorker) Start(ctx context.Context) { | ||
| go w.run(ctx) | ||
| if w.logger != nil { | ||
| w.logger.Info("Token refresh worker started") | ||
| } | ||
| } | ||
|
|
||
| // Stop gracefully stops the token refresh worker | ||
| func (w *TokenRefreshWorker) Stop() { | ||
| close(w.stopCh) | ||
| if w.logger != nil { | ||
| w.logger.Info("Token refresh worker stopped") | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's examine the full sync.go file to understand the complete context
cat -n framework/oauth/sync.goRepository: maximhq/bifrost
Length of output: 4770
🏁 Script executed:
# Check if there are any tests for the TokenRefreshWorker
fd -e "_test.go" -e ".test.go" | xargs rg -l "TokenRefreshWorker" 2>/dev/null || echo "No test files found with TokenRefreshWorker"Repository: maximhq/bifrost
Length of output: 82
🏁 Script executed:
# Search for all Stop() method calls in the codebase to see if there's a pattern of multiple calls
rg "\.Stop\(\)" --type go -B2 -A2Repository: maximhq/bifrost
Length of output: 13105
🏁 Script executed:
# Check the overall structure and imports in the file
head -n 50 framework/oauth/sync.goRepository: maximhq/bifrost
Length of output: 1449
🏁 Script executed:
# Look for any existing usage of sync.Once in the codebase for similar patterns
rg "sync\.Once" --type go -B2 -A2Repository: maximhq/bifrost
Length of output: 3281
Make Stop() idempotent to prevent panic on multiple calls.
Closing a channel twice panics. Use sync.Once to guard the close operation, consistent with patterns already established in the codebase (e.g., framework/tracing/store.go).
🔧 Suggested fix
import (
"context"
+ "sync"
"time"
"github.com/maximhq/bifrost/core/schemas"
)
@@
type TokenRefreshWorker struct {
provider *OauthProvider
refreshInterval time.Duration
lookAheadWindow time.Duration // How far ahead to look for expiring tokens
stopCh chan struct{}
+ stopOnce sync.Once
logger schemas.Logger
}
@@
func (w *TokenRefreshWorker) Stop() {
- close(w.stopCh)
+ w.stopOnce.Do(func() { close(w.stopCh) })
if w.logger != nil {
w.logger.Info("Token refresh worker stopped")
}
}🤖 Prompt for AI Agents
In `@framework/oauth/sync.go` around lines 10 - 44, The Stop() method currently
closes stopCh directly which will panic if called more than once; modify
TokenRefreshWorker to include a sync.Once field (e.g., closeOnce), initialize it
in NewTokenRefreshWorker, and change Stop() to call w.closeOnce.Do(func(){
close(w.stopCh) }) so closing is idempotent while preserving the
logger.Info("Token refresh worker stopped") call; reference TokenRefreshWorker,
NewTokenRefreshWorker, Stop(), stopCh, and add closeOnce to the struct.
| ctx.SetStatusCode(fasthttp.StatusBadRequest) | ||
| ctx.SetContentType("text/html") | ||
| errorMsg := errorParam | ||
| if errorDescription != "" { | ||
| errorMsg = fmt.Sprintf("%s: %s", errorParam, errorDescription) | ||
| } | ||
| ctx.SetBodyString(fmt.Sprintf(` | ||
| <!DOCTYPE html> | ||
| <html> | ||
| <head> | ||
| <title>OAuth Failed</title> | ||
| <script> | ||
| // Notify parent window | ||
| if (window.opener) { | ||
| window.opener.postMessage({ type: 'oauth_failed', error: '%s' }, '*'); | ||
| window.close(); | ||
| } | ||
| </script> | ||
| </head> | ||
| <body> | ||
| <div style="display: flex; align-items: center; justify-content: center; height: 100vh; font-family: system-ui;"> | ||
| <div style="text-align: center;"> | ||
| <h1>✗ Authorization Failed</h1> | ||
| <p>%s</p> | ||
| <p style="color: #666;">You can close this window.</p> | ||
| </div> | ||
| </div> | ||
| </body> | ||
| </html> | ||
| `, errorMsg, errorMsg)) |
There was a problem hiding this comment.
Escape OAuth error strings before injecting into HTML/JS.
error and error_description are user-controlled. Rendering them raw in the HTML/JS response allows reflected XSS in the OAuth popup. Escape for both HTML and JS contexts.
[details]
🔒 Suggested fix
import (
"context"
"fmt"
+ "html"
+ "html/template"
@@
errorMsg := errorParam
if errorDescription != "" {
errorMsg = fmt.Sprintf("%s: %s", errorParam, errorDescription)
}
+ escapedHTML := html.EscapeString(errorMsg)
+ escapedJS := template.JSEscapeString(errorMsg)
ctx.SetBodyString(fmt.Sprintf(`
@@
- window.opener.postMessage({ type: 'oauth_failed', error: '%s' }, '*');
+ window.opener.postMessage({ type: 'oauth_failed', error: '%s' }, '*');
window.close();
}
</script>
@@
- <p>%s</p>
+ <p>%s</p>
<p style="color: `#666`;">You can close this window.</p>
</div>
</div>
@@
- `, errorMsg, errorMsg))
+ `, escapedJS, escapedHTML))📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ctx.SetStatusCode(fasthttp.StatusBadRequest) | |
| ctx.SetContentType("text/html") | |
| errorMsg := errorParam | |
| if errorDescription != "" { | |
| errorMsg = fmt.Sprintf("%s: %s", errorParam, errorDescription) | |
| } | |
| ctx.SetBodyString(fmt.Sprintf(` | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>OAuth Failed</title> | |
| <script> | |
| // Notify parent window | |
| if (window.opener) { | |
| window.opener.postMessage({ type: 'oauth_failed', error: '%s' }, '*'); | |
| window.close(); | |
| } | |
| </script> | |
| </head> | |
| <body> | |
| <div style="display: flex; align-items: center; justify-content: center; height: 100vh; font-family: system-ui;"> | |
| <div style="text-align: center;"> | |
| <h1>✗ Authorization Failed</h1> | |
| <p>%s</p> | |
| <p style="color: #666;">You can close this window.</p> | |
| </div> | |
| </div> | |
| </body> | |
| </html> | |
| `, errorMsg, errorMsg)) | |
| ctx.SetStatusCode(fasthttp.StatusBadRequest) | |
| ctx.SetContentType("text/html") | |
| errorMsg := errorParam | |
| if errorDescription != "" { | |
| errorMsg = fmt.Sprintf("%s: %s", errorParam, errorDescription) | |
| } | |
| escapedHTML := html.EscapeString(errorMsg) | |
| escapedJS := template.JSEscapeString(errorMsg) | |
| ctx.SetBodyString(fmt.Sprintf(` | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>OAuth Failed</title> | |
| <script> | |
| // Notify parent window | |
| if (window.opener) { | |
| window.opener.postMessage({ type: 'oauth_failed', error: '%s' }, '*'); | |
| window.close(); | |
| } | |
| </script> | |
| </head> | |
| <body> | |
| <div style="display: flex; align-items: center; justify-content: center; height: 100vh; font-family: system-ui;"> | |
| <div style="text-align: center;"> | |
| <h1>✗ Authorization Failed</h1> | |
| <p>%s</p> | |
| <p style="color: `#666`;">You can close this window.</p> | |
| </div> | |
| </div> | |
| </body> | |
| </html> | |
| `, escapedJS, escapedHTML)) |
🤖 Prompt for AI Agents
In `@transports/bifrost-http/handlers/oauth.go` around lines 108 - 137, The OAuth
error strings (errorParam and errorDescription) are injected raw into the
HTML/JS response in the handler, allowing reflected XSS; update the code that
builds the response in oauth.go to properly escape both values for their
respective contexts: HTML-escape the error message used inside the <p> tag
(e.g., via html.EscapeString) and safely encode/escape the value embedded in the
inline script (preferably by JSON-encoding the string or using a JS-escaping
routine) so the window.opener.postMessage call cannot be abused; ensure you
replace direct fmt.Sprintf injections of errorParam/errorDescription with the
escaped/encoded variables when calling ctx.SetBodyString.
| // Initialize OAuth provider | ||
| config.OAuthProvider = oauth.NewOauthProvider(config.ConfigStore, logger) | ||
|
|
There was a problem hiding this comment.
Guard OAuth provider initialization when ConfigStore is disabled.
oauth.NewOauthProvider receives nil when the config store is disabled, which can lead to nil derefs later. Consider initializing only when the store is available (and ensure handlers fail fast if OAuth is requested without a store).
🔧 Suggested fix
-// Initialize OAuth provider
-config.OAuthProvider = oauth.NewOauthProvider(config.ConfigStore, logger)
+if config.ConfigStore != nil {
+ // Initialize OAuth provider
+ config.OAuthProvider = oauth.NewOauthProvider(config.ConfigStore, logger)
+} else {
+ logger.Warn("config store disabled; OAuth provider not initialized")
+}Also applies to: 1916-1918
🤖 Prompt for AI Agents
In `@transports/bifrost-http/lib/config.go` around lines 1560 - 1562, The OAuth
provider is being initialized unconditionally which passes nil when
config.ConfigStore is disabled; change the initialization in the constructor
where config.OAuthProvider is set so it only calls
oauth.NewOauthProvider(config.ConfigStore, logger) when config.ConfigStore !=
nil (guard both occurrences noted around the OAuth initialization), otherwise
leave config.OAuthProvider nil and update any OAuth-related handlers (the code
paths that reference config.OAuthProvider) to fail fast with a clear error when
OAuth is requested but config.OAuthProvider is nil.
| // Listen for postMessage from OAuth callback popup | ||
| useEffect(() => { | ||
| const handleMessage = (event: MessageEvent) => { | ||
| // Verify message is from OAuth callback | ||
| if (event.data?.type === "oauth_success") { | ||
| // OAuth succeeded, stop polling and check status immediately | ||
| stopPolling(); | ||
| // Trigger immediate status check | ||
| checkOAuthStatus(); | ||
| } | ||
| }; | ||
|
|
||
| window.addEventListener("message", handleMessage); | ||
| return () => { | ||
| window.removeEventListener("message", handleMessage); | ||
| }; | ||
| }, [oauthConfigId, mcpClientId]); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cd ui/app/workspace/mcp-gateway/views && wc -l oauthAuthorizer.tsxRepository: maximhq/bifrost
Length of output: 82
🏁 Script executed:
cd ui/app/workspace/mcp-gateway/views && cat -n oauthAuthorizer.tsxRepository: maximhq/bifrost
Length of output: 11561
Add origin and source validation to postMessage handler.
The current handler accepts oauth_success messages from any window without validating origin or source. This allows any script or malicious iframe to trigger OAuth status checks and completion. The popupRef is available in scope and should be used to verify messages come from the specific OAuth popup.
Suggested fix
const handleMessage = (event: MessageEvent) => {
- // Verify message is from OAuth callback
- if (event.data?.type === "oauth_success") {
+ // Verify message origin/source before acting
+ if (event.origin !== window.location.origin) return;
+ if (popupRef.current && event.source !== popupRef.current) return;
+ if (event.data?.type === "oauth_success") {
// OAuth succeeded, stop polling and check status immediately
stopPolling();
// Trigger immediate status check
checkOAuthStatus();
}
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Listen for postMessage from OAuth callback popup | |
| useEffect(() => { | |
| const handleMessage = (event: MessageEvent) => { | |
| // Verify message is from OAuth callback | |
| if (event.data?.type === "oauth_success") { | |
| // OAuth succeeded, stop polling and check status immediately | |
| stopPolling(); | |
| // Trigger immediate status check | |
| checkOAuthStatus(); | |
| } | |
| }; | |
| window.addEventListener("message", handleMessage); | |
| return () => { | |
| window.removeEventListener("message", handleMessage); | |
| }; | |
| }, [oauthConfigId, mcpClientId]); | |
| // Listen for postMessage from OAuth callback popup | |
| useEffect(() => { | |
| const handleMessage = (event: MessageEvent) => { | |
| // Verify message origin/source before acting | |
| if (event.origin !== window.location.origin) return; | |
| if (popupRef.current && event.source !== popupRef.current) return; | |
| if (event.data?.type === "oauth_success") { | |
| // OAuth succeeded, stop polling and check status immediately | |
| stopPolling(); | |
| // Trigger immediate status check | |
| checkOAuthStatus(); | |
| } | |
| }; | |
| window.addEventListener("message", handleMessage); | |
| return () => { | |
| window.removeEventListener("message", handleMessage); | |
| }; | |
| }, [oauthConfigId, mcpClientId]); |
🤖 Prompt for AI Agents
In `@ui/app/workspace/mcp-gateway/views/oauthAuthorizer.tsx` around lines 142 -
158, The postMessage handler (handleMessage) currently trusts any oauth_success
message; update it to validate messages come from the actual OAuth popup before
calling stopPolling() / checkOAuthStatus(): inside handleMessage, first ensure
popupRef?.current exists, then verify event.source === popupRef.current; next
validate event.origin matches the popup's origin (obtain via
popupRef.current.location.origin inside a try/catch in case of cross-origin
access) and only proceed if both checks pass; otherwise ignore the message. Keep
these checks inside the same useEffect that declares handleMessage.
| // Proxy API requests to backend in development | ||
| async rewrites() { | ||
| return [ | ||
| { | ||
| source: "/api/:path*", | ||
| destination: "http://localhost:8080/api/:path*", | ||
| }, | ||
| ]; | ||
| }, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's examine the full Next.js config file
cat -n ui/next.config.tsRepository: maximhq/bifrost
Length of output: 1985
🌐 Web query:
Next.js 15 static export output export rewrites support documentation
💡 Result:
In Next.js 15, when you enable static export via output: 'export', Next.js does not support rewrites (nor redirects or headers) in next.config.js—they won’t be applied during export, and Next.js will error if you define them alongside output: 'export'. [1], [2]
- Static export docs (App Router) list Rewrites as an unsupported feature for
output: 'export'. [1] - The “Export Custom Routes” error page explicitly states that
rewrites/redirects/headersdo not apply withoutput: 'export'and suggests removing them or not using static export. [2] - If you still need “rewrites” behavior for a static export, the docs recommend doing it at the static host/web server layer (example shown with Nginx). [1]
Sources:
[1] Next.js Docs – Static Exports (App Router)
[2] Next.js Docs – Error: Export Custom Routes
🏁 Script executed:
# Check if there's any git context about this change (PR/stack)
git log --oneline -10 -- ui/next.config.ts 2>/dev/null | head -20Repository: maximhq/bifrost
Length of output: 98
🏁 Script executed:
# Check current git status to see if there are multiple related changes
git log --oneline --all 2>/dev/null | head -20Repository: maximhq/bifrost
Length of output: 257
Rewrites are incompatible with static export and must be removed or handled at the server layer.
output: "export" is enabled, and Next.js 15 does not support rewrites alongside static export—Next.js will error during the build. Either remove the rewrites entirely, conditionally exclude them from export builds, or move the proxy logic to your server/hosting layer (e.g., Nginx, Vercel routing).
🔧 Option 1: Remove rewrites entirely
- // Proxy API requests to backend in development
- async rewrites() {
- return [
- {
- source: "/api/:path*",
- destination: "http://localhost:8080/api/:path*",
- },
- ];
- },📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Proxy API requests to backend in development | |
| async rewrites() { | |
| return [ | |
| { | |
| source: "/api/:path*", | |
| destination: "http://localhost:8080/api/:path*", | |
| }, | |
| ]; | |
| }, | |
| // Option 1: Remove rewrites entirely | |
| // (Alternative: conditionally gate rewrites to development only, or move proxy logic to server/hosting layer) |
🤖 Prompt for AI Agents
In `@ui/next.config.ts` around lines 23 - 31, The rewrites() function is
incompatible with output: "export" and will break static exports; either remove
the rewrites() block or make rewrites() conditional so it returns an empty array
during export builds (e.g., check a build-time env like process.env.NEXT_EXPORT
=== '1' or process.env.NEXT_PUBLIC_OUTPUT === 'export' and return [] when
exporting), otherwise keep the dev proxy destination; alternatively move the
proxy routing to your deployment/server layer (Nginx/Vercel) and remove the
rewrites() function entirely.
712b0f4 to
2126c29
Compare
8bc9079 to
432b607
Compare
2126c29 to
1e5ab07
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@framework/oauth/discovery.go`:
- Around line 53-134: Add a nil-logger guard at the start of each exported
discovery-related function (DiscoverOAuthMetadata, GeneratePKCEChallenge,
RegisterDynamicClient) to ensure logger is initialized before any
logger.Debug/Info/Warn calls; follow the pattern used in NewOauthProvider and
add: if logger == nil { logger = bifrost.NewDefaultLogger(schemas.LogLevelInfo)
} at the top of each function so all subsequent logger calls cannot panic if
SetLogger() wasn't called yet.
In `@framework/oauth/main.go`:
- Around line 509-523: The current buildAuthorizeURLWithPKCE concatenates "?"
and params.Encode(), which breaks if authorizeURL already has query parameters;
update buildAuthorizeURLWithPKCE to parse authorizeURL (using url.Parse), merge
existing query values with the new params (from params variable) via url.Values
(preserving existing keys and appending or overriding as appropriate), set the
merged query back on the parsed URL, and return the reconstructed URL.String()
so existing query strings and fragments are preserved and the resulting URL is
valid.
♻️ Duplicate comments (2)
framework/configstore/tables/oauth.go (1)
14-14: Misleading comments: Fields are not actually encrypted.The comments state "Encrypted OAuth client secret", "Encrypted access token", and "Encrypted refresh token", but no encryption is applied in the
BeforeSavehooks or elsewhere in this file. These comments are misleading and should either be:
- Updated to reflect the actual state (plaintext storage), or
- Encryption should be implemented as described
The underlying security concern about storing these secrets in plaintext was already flagged in a previous review.
Also applies to: 21-21, 50-51
framework/oauth/main.go (1)
103-145: Guard refresh flows for public clients and missing refresh tokens.
exchangeRefreshTokenshould not send an emptyclient_secret, andRefreshAccessTokenshould fail fast ifrefresh_tokenis missing.🔧 Suggested fix
func (p *OauthProvider) RefreshAccessToken(ctx context.Context, oauthConfigID string) error { // Load oauth_token token, err := p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) if err != nil || token == nil { return fmt.Errorf("oauth token not found: %w", err) } + if token.RefreshToken == "" { + return fmt.Errorf("refresh token missing; re-authorize the OAuth flow") + } func (p *OauthProvider) exchangeRefreshToken(tokenURL, clientID, clientSecret, refreshToken string) (*schemas.TokenExchangeResponse, error) { data := url.Values{} data.Set("grant_type", "refresh_token") data.Set("refresh_token", refreshToken) data.Set("client_id", clientID) - data.Set("client_secret", clientSecret) + if clientSecret != "" { + data.Set("client_secret", clientSecret) + }Also applies to: 556-563
🧹 Nitpick comments (3)
core/mcp/clientmanager.go (1)
113-157: Consider extracting shared logic fromAddClientandAddClientInMemory.These two methods share ~90% of their implementation (validation, locking, placeholder creation, connection, cleanup). The only difference is that
AddClientpresumably persists to the database (though that's not visible in this file).This duplication increases maintenance burden. Consider extracting the common logic into a private helper method that both can call, with a flag or callback for the persistence step.
core/schemas/mcp.go (1)
83-86: Clarify the purpose of theStatefield inMCPClientConfig.The
MCPClientConfig.Statefield (line 85, typestring) appears to overlap withMCPClientState.State(line 184, typeMCPConnectionState).Based on the PR objectives mentioning "OAuth state parameter to mitigate CSRF," this
Statefield likely stores the OAuth state parameter for the authorization flow, not connection state. Consider renaming toOAuthStateor adding a comment to clarify its purpose and distinguish it from connection state.framework/oauth/main.go (1)
135-142: Usebifrost.Ptr(...)for pointer fields.This keeps pointer creation consistent with repo conventions.
Based on learnings, prefer bifrost.Ptr for pointer creation.♻️ Suggested tweak
- token.LastRefreshedAt = &now + token.LastRefreshedAt = bifrost.Ptr(now) - oauthConfig.TokenID = &tokenID + oauthConfig.TokenID = bifrost.Ptr(tokenID)Also applies to: 497-499
| func DiscoverOAuthMetadata(ctx context.Context, serverURL string) (*OAuthMetadata, error) { | ||
| if logger != nil { | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Starting discovery for server: %s", serverURL)) | ||
| } | ||
|
|
||
| // Step 1: Attempt to connect to MCP server, expect 401 with WWW-Authenticate header | ||
| client := &http.Client{ | ||
| Timeout: 10 * time.Second, | ||
| } | ||
|
|
||
| req, err := http.NewRequestWithContext(ctx, "GET", serverURL, nil) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to create request: %w", err) | ||
| } | ||
|
|
||
| resp, err := client.Do(req) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to connect to server: %w", err) | ||
| } | ||
| defer resp.Body.Close() | ||
|
|
||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Server responded with status: %d", resp.StatusCode)) | ||
|
|
||
| // Step 2: Parse WWW-Authenticate header | ||
| wwwAuth := resp.Header.Get("WWW-Authenticate") | ||
| if wwwAuth == "" { | ||
| wwwAuth = resp.Header.Get("www-authenticate") | ||
| } | ||
|
|
||
| resourceMetadataURL, scopesFromHeader := parseWWWAuthenticateHeader(wwwAuth) | ||
| if resourceMetadataURL != "" { | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Found resource_metadata URL: %s", resourceMetadataURL)) | ||
| } | ||
| if len(scopesFromHeader) > 0 { | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Found scopes in header: %v", scopesFromHeader)) | ||
| } | ||
|
|
||
| // Step 3: Fetch resource metadata if available | ||
| var authServers []string | ||
| var resourceScopes []string | ||
|
|
||
| if resourceMetadataURL != "" { | ||
| authServers, resourceScopes, err = fetchResourceMetadata(ctx, resourceMetadataURL) | ||
| if err != nil { | ||
| // Log but continue to well-known discovery | ||
| logger.Warn(fmt.Sprintf("[OAuth Discovery] Failed to fetch resource metadata: %v", err)) | ||
| } else { | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Found %d authorization servers from resource metadata", len(authServers))) | ||
| } | ||
| } | ||
|
|
||
| // Step 4: Try well-known discovery if no resource metadata | ||
| if len(authServers) == 0 { | ||
| logger.Debug("[OAuth Discovery] Attempting .well-known discovery") | ||
| authServers, resourceScopes, err = attemptWellKnownDiscovery(ctx, serverURL) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("OAuth discovery failed: %w", err) | ||
| } | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Found %d authorization servers from .well-known", len(authServers))) | ||
| } | ||
|
|
||
| // Step 5: Fetch authorization server metadata | ||
| metadata, err := fetchAuthorizationServerMetadata(ctx, authServers) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to fetch authorization server metadata: %w", err) | ||
| } | ||
|
|
||
| // Step 6: Merge scopes (priority: header > resource metadata > discovered) | ||
| if len(scopesFromHeader) > 0 { | ||
| metadata.ScopesSupported = scopesFromHeader | ||
| } else if len(resourceScopes) > 0 { | ||
| metadata.ScopesSupported = resourceScopes | ||
| } | ||
|
|
||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Successfully discovered OAuth metadata for %s", serverURL)) | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Authorization URL: %s", metadata.AuthorizationURL)) | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Token URL: %s", metadata.TokenURL)) | ||
| if metadata.RegistrationURL != nil { | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Registration URL: %s", *metadata.RegistrationURL)) | ||
| } | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Scopes: %v", metadata.ScopesSupported)) | ||
|
|
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's see the full discovery.go file to understand the logger setup
fd discovery.go --type fRepository: maximhq/bifrost
Length of output: 87
🏁 Script executed:
# Check for logger variable declaration and SetLogger function
rg "logger\s*:?=|func.*SetLogger" framework/oauth/discovery.go -A 3 -B 1Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Look for any logger initialization or package-level variables
rg "var\s+logger|^logger" framework/oauth/ -B 2 -A 2Repository: maximhq/bifrost
Length of output: 290
🏁 Script executed:
# Check how logger is used in other helper functions in the file
rg "func\s+\w+.*\(.*\)" framework/oauth/discovery.go | head -20Repository: maximhq/bifrost
Length of output: 988
🏁 Script executed:
# Search for SetLogger implementation across the codebase
rg "func\s+SetLogger" --type go -B 2 -A 5Repository: maximhq/bifrost
Length of output: 2174
🏁 Script executed:
# Read the full discovery.go file to understand logger usage
cat -n framework/oauth/discovery.goRepository: maximhq/bifrost
Length of output: 20168
🏁 Script executed:
# Check the init.go file for logger setup
cat -n framework/oauth/init.goRepository: maximhq/bifrost
Length of output: 262
🏁 Script executed:
# Check bifrost core package to understand NewDefaultLogger
rg "NewDefaultLogger|type Logger" core/schemas/ -B 2 -A 5Repository: maximhq/bifrost
Length of output: 1022
🏁 Script executed:
# Look for how SetLogger is typically called in the codebase
rg "SetLogger\(" --type go -B 2 -A 2 | head -50Repository: maximhq/bifrost
Length of output: 3508
🏁 Script executed:
# Check if NewDefaultLogger exists
rg "NewDefaultLogger" --type go -B 2 -A 5Repository: maximhq/bifrost
Length of output: 36882
🏁 Script executed:
# Check the core package structure
find core -name "*.go" -type f | head -20Repository: maximhq/bifrost
Length of output: 566
🏁 Script executed:
# Search for default logger implementations
rg "func.*Logger|type.*Logger" core/ --type go | grep -i defaultRepository: maximhq/bifrost
Length of output: 738
🏁 Script executed:
# Check how logger is typically initialized in the application
rg "logger\s*=" --type go -B 3 -A 1 | head -50Repository: maximhq/bifrost
Length of output: 2477
🏁 Script executed:
# Verify the specific unguarded logger calls in discovery.go
rg "logger\.(Debug|Warn|Error)" framework/oauth/discovery.go | wc -lRepository: maximhq/bifrost
Length of output: 61
🏁 Script executed:
# Check how framework/oauth/main.go uses NewOauthProvider and SetLogger
cat -n framework/oauth/main.go | head -50Repository: maximhq/bifrost
Length of output: 1782
Guard all logger calls to prevent nil panics in discovery functions.
The DiscoverOAuthMetadata function has inconsistent logger nil checks. Line 54 guards a logger call, but 28 other logger calls throughout the function and helper functions (lines 74, 84, 87, 98, 100, 106, 111, 127–133, 216, 219, 222, 228, 236, 239, 242, 274, 297, 352, 398–399, 431, 446, 448, 450) lack protection. If discovery is invoked before SetLogger() is called, this causes a panic. Initialize logger at the entry of exported functions following the pattern already used in NewOauthProvider:
if logger == nil {
logger = bifrost.NewDefaultLogger(schemas.LogLevelInfo)
}Apply this guard at the start of DiscoverOAuthMetadata, GeneratePKCEChallenge, and RegisterDynamicClient to ensure consistency and prevent runtime crashes.
🤖 Prompt for AI Agents
In `@framework/oauth/discovery.go` around lines 53 - 134, Add a nil-logger guard
at the start of each exported discovery-related function (DiscoverOAuthMetadata,
GeneratePKCEChallenge, RegisterDynamicClient) to ensure logger is initialized
before any logger.Debug/Info/Warn calls; follow the pattern used in
NewOauthProvider and add: if logger == nil { logger =
bifrost.NewDefaultLogger(schemas.LogLevelInfo) } at the top of each function so
all subsequent logger calls cannot panic if SetLogger() wasn't called yet.
| // buildAuthorizeURLWithPKCE constructs the OAuth authorization URL with PKCE parameters | ||
| func (p *OauthProvider) buildAuthorizeURLWithPKCE(authorizeURL, clientID, redirectURI, state, codeChallenge string, scopes []string) string { | ||
| params := url.Values{} | ||
| params.Set("response_type", "code") | ||
| params.Set("client_id", clientID) | ||
| params.Set("redirect_uri", redirectURI) | ||
| params.Set("state", state) | ||
| params.Set("code_challenge", codeChallenge) | ||
| params.Set("code_challenge_method", "S256") // SHA-256 hashing | ||
| if len(scopes) > 0 { | ||
| params.Set("scope", strings.Join(scopes, " ")) | ||
| } | ||
|
|
||
| return authorizeURL + "?" + params.Encode() | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, verify the file exists and check its location
fd -t f "main.go" | grep oauthRepository: maximhq/bifrost
Length of output: 82
🏁 Script executed:
# Check the content of the file around the specified lines
if [ -f "framework/oauth/main.go" ]; then
wc -l "framework/oauth/main.go"
fiRepository: maximhq/bifrost
Length of output: 86
🏁 Script executed:
# Look for the buildAuthorizeURLWithPKCE function to verify it exists at the specified location
rg -n "buildAuthorizeURLWithPKCE" framework/oauth/main.go -A 15Repository: maximhq/bifrost
Length of output: 1325
🏁 Script executed:
# Search for all usages of buildAuthorizeURLWithPKCE in the codebase
rg -n "buildAuthorizeURLWithPKCE" --type goRepository: maximhq/bifrost
Length of output: 414
🏁 Script executed:
# Check git status and context for PR stack
git log --oneline -10Repository: maximhq/bifrost
Length of output: 98
🏁 Script executed:
# Look at the context around line 411 to see where authorizeURL comes from
sed -n '390,430p' framework/oauth/main.go | cat -nRepository: maximhq/bifrost
Length of output: 1610
🏁 Script executed:
# Search for where authorizeURL is set or loaded
rg -n "authorizeURL" framework/oauth/main.go -B 2 -A 2Repository: maximhq/bifrost
Length of output: 1991
🏁 Script executed:
# Check what OauthConfig structure looks like and where it's initialized
rg -n "type OauthConfig\|AuthorizeURL" framework/ --type go -B 1 -A 3Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Look at the struct definitions to understand the data flow
rg -n "type.*Provider\|type.*Config" framework/oauth/main.go -B 1 -A 5 | head -80Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Look for tests related to buildAuthorizeURLWithPKCE
rg -n "buildAuthorizeURLWithPKCE\|TestBuildAuthorizeURL" --type goRepository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Check if there are any test files in oauth directory
fd -t f ".*test.*\.go" framework/oauth/Repository: maximhq/bifrost
Length of output: 92
🏁 Script executed:
# Look at how OAuth URLs are typically used in similar projects or documentation
rg -n "OAuth.*endpoint\|authorization.*url" framework/ --type go -i | head -20Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Check if url.Parse is already imported in the file
head -30 framework/oauth/main.go | grep -n "import\|url"Repository: maximhq/bifrost
Length of output: 83
🏁 Script executed:
# See the full imports section
sed -n '1,50p' framework/oauth/main.goRepository: maximhq/bifrost
Length of output: 1432
🏁 Script executed:
# Look at the test file to see how buildAuthorizeURLWithPKCE or similar functionality is tested
cat -n framework/oauth/discovery_test.go | head -100Repository: maximhq/bifrost
Length of output: 4165
🏁 Script executed:
# Check if there are any other tests that might show URL handling
find framework/oauth -name "*.go" -type f | xargs wc -lRepository: maximhq/bifrost
Length of output: 235
🏁 Script executed:
# Search for any tests that create or validate authorize URLs
rg -n "authorize.*url\|buildAuthorizeURL" framework/oauth/discovery_test.go -i -B 2 -A 5Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Check RFC or OAuth spec references in the codebase
rg -n "RFC\|oauth.*spec\|authorization.*endpoint" framework/oauth/ --type go -i | head -20Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Look at where authorizeURL values come from in practice
rg -n "AuthorizationURL\|metadata" framework/oauth/discovery.go -B 2 -A 2Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Verify the actual issue - check if there are any real-world cases where authorize URLs have query params
# by looking at how metadata is structured
sed -n '1,100p' framework/oauth/discovery.go | cat -nRepository: maximhq/bifrost
Length of output: 4529
🏁 Script executed:
# Check the complete metadata structure
rg -n "type.*Metadata\|Authorization" framework/oauth/discovery.go -B 1 -A 10 | head -50Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Check git status to see all changes in this PR
git statusRepository: maximhq/bifrost
Length of output: 124
🏁 Script executed:
# See if there's a PR description or commits showing the full context
git log --oneline --all | head -20Repository: maximhq/bifrost
Length of output: 1332
🏁 Script executed:
# Look at the complete diff for this PR to understand the full scope
git diff HEAD~1 framework/oauth/main.go | head -150Repository: maximhq/bifrost
Length of output: 245
🏁 Script executed:
# Look for other URL building patterns in the codebase
rg -n "\"?\"\s*\+\s*\"?" framework/oauth/ --type goRepository: maximhq/bifrost
Length of output: 131
🏁 Script executed:
# Check if there are similar URL building patterns elsewhere
rg -n "url.Values\|Encode()" framework/oauth/ --type go -B 2 -A 2Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Verify: do any OAuth servers actually include query parameters in their authorization URLs?
# Check if there are any comments or docs about URL format
rg -n "authorization.*endpoint\|authorize.*url" framework/oauth/ --type go -i -B 2 -A 2Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Simulate the concern - check what happens with the current code if authorizeURL has params
python3 << 'EOF'
# Test the issue
authorize_url_with_params = "https://example.com/oauth/authorize?custom=value"
params = {"response_type": "code", "client_id": "test"}
params_str = "&".join([f"{k}={v}" for k, v in params.items()])
# Current implementation (broken)
current_result = authorize_url_with_params + "?" + params_str
print("Current (broken):", current_result)
# Suggested fix
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
parsed = urlparse(authorize_url_with_params)
query_params = parse_qs(parsed.query, keep_blank_values=True)
# Flatten the query_params (parse_qs returns lists)
flat_params = {k: v[0] if len(v) == 1 else v for k, v in query_params.items()}
# Add new params
flat_params.update(params)
# Rebuild URL
new_query = urlencode(flat_params, doseq=True)
result = urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params, new_query, parsed.fragment))
print("Fixed version:", result)
EOFRepository: maximhq/bifrost
Length of output: 145
Handle authorize URLs that already include a query string.
authorizeURL + "?" + params.Encode() can produce invalid URLs if the base URL contains existing query parameters. While OAuth 2.0 authorization endpoints typically don't include query parameters per RFC 8414, this defensive approach improves robustness:
Suggested fix
func (p *OauthProvider) buildAuthorizeURLWithPKCE(authorizeURL, clientID, redirectURI, state, codeChallenge string, scopes []string) string {
params := url.Values{}
params.Set("response_type", "code")
params.Set("client_id", clientID)
params.Set("redirect_uri", redirectURI)
params.Set("state", state)
params.Set("code_challenge", codeChallenge)
params.Set("code_challenge_method", "S256")
if len(scopes) > 0 {
params.Set("scope", strings.Join(scopes, " "))
}
- return authorizeURL + "?" + params.Encode()
+ u, err := url.Parse(authorizeURL)
+ if err != nil {
+ return authorizeURL + "?" + params.Encode()
+ }
+ q := u.Query()
+ for k, v := range params {
+ q[k] = v
+ }
+ u.RawQuery = q.Encode()
+ return u.String()
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // buildAuthorizeURLWithPKCE constructs the OAuth authorization URL with PKCE parameters | |
| func (p *OauthProvider) buildAuthorizeURLWithPKCE(authorizeURL, clientID, redirectURI, state, codeChallenge string, scopes []string) string { | |
| params := url.Values{} | |
| params.Set("response_type", "code") | |
| params.Set("client_id", clientID) | |
| params.Set("redirect_uri", redirectURI) | |
| params.Set("state", state) | |
| params.Set("code_challenge", codeChallenge) | |
| params.Set("code_challenge_method", "S256") // SHA-256 hashing | |
| if len(scopes) > 0 { | |
| params.Set("scope", strings.Join(scopes, " ")) | |
| } | |
| return authorizeURL + "?" + params.Encode() | |
| } | |
| // buildAuthorizeURLWithPKCE constructs the OAuth authorization URL with PKCE parameters | |
| func (p *OauthProvider) buildAuthorizeURLWithPKCE(authorizeURL, clientID, redirectURI, state, codeChallenge string, scopes []string) string { | |
| params := url.Values{} | |
| params.Set("response_type", "code") | |
| params.Set("client_id", clientID) | |
| params.Set("redirect_uri", redirectURI) | |
| params.Set("state", state) | |
| params.Set("code_challenge", codeChallenge) | |
| params.Set("code_challenge_method", "S256") // SHA-256 hashing | |
| if len(scopes) > 0 { | |
| params.Set("scope", strings.Join(scopes, " ")) | |
| } | |
| u, err := url.Parse(authorizeURL) | |
| if err != nil { | |
| return authorizeURL + "?" + params.Encode() | |
| } | |
| q := u.Query() | |
| for k, v := range params { | |
| q[k] = v | |
| } | |
| u.RawQuery = q.Encode() | |
| return u.String() | |
| } |
🤖 Prompt for AI Agents
In `@framework/oauth/main.go` around lines 509 - 523, The current
buildAuthorizeURLWithPKCE concatenates "?" and params.Encode(), which breaks if
authorizeURL already has query parameters; update buildAuthorizeURLWithPKCE to
parse authorizeURL (using url.Parse), merge existing query values with the new
params (from params variable) via url.Values (preserving existing keys and
appending or overriding as appropriate), set the merged query back on the parsed
URL, and return the reconstructed URL.String() so existing query strings and
fragments are preserved and the resulting URL is valid.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In `@framework/configstore/migrations.go`:
- Around line 2986-3026: The migration currently assumes AutoMigrate will drop
NOT NULL on authorize_url and token_url, but triggerMigrations doesn't call
AutoMigrate, so explicitly ALTER those columns to nullable for supported
dialects: detect DB dialect (via migrator/DB or gorm.DB.Dialector.Name()) in the
same migration block that touches oauthConfig and run dialect-specific SQL
(e.g., ALTER TABLE ... ALTER COLUMN ... DROP NOT NULL for Postgres, MODIFY ...
NULL for MySQL, and a no-op or recreate table for SQLite) using migrator.DB.Exec
or gorm.DB.Exec; perform these ALTERs for both authorize_url and token_url just
before returning nil so discovery-only configs won’t fail on upgrade.
In `@framework/oauth/discovery.go`:
- Around line 53-134: DiscoverOAuthMetadata uses the package-level logger
without guarding later calls which can panic if SetLogger was never called; fix
by ensuring a default no-op logger is present at the start of the function (or
in package init) before any logger.* calls (e.g., in DiscoverOAuthMetadata check
if logger == nil then assign a noopLogger implementing Debug/Warn/Error
methods), or alternatively add logger != nil guards before every
logger.Debug/Warn call; reference logger and DiscoverOAuthMetadata (or
SetLogger/noopLogger) to locate where to apply the change.
In `@framework/oauth/main.go`:
- Around line 509-523: The buildAuthorizeURLWithPKCE function currently
concatenates "?" + params.Encode(), which breaks when authorizeURL already
contains query parameters; update it to parse authorizeURL with url.Parse, merge
existing query values with the new params (using url.Values and the
parsedURL.Query() method), set the merged query back on the parsed URL with
parsedURL.RawQuery = merged.Encode(), and return parsedURL.String(); ensure this
change is applied inside the buildAuthorizeURLWithPKCE method so existing query
params are preserved and new PKCE params are appended safely.
♻️ Duplicate comments (3)
framework/configstore/tables/oauth.go (1)
12-55: Encrypt OAuth secrets before persistence.
ClientSecret,CodeVerifier,AccessToken, andRefreshTokenare stored as plain strings here. Ensure these fields are encrypted before write (or via hooks) and decrypted on read to avoid plaintext storage of credentials.framework/oauth/sync.go (1)
11-43: MakeStop()idempotent to avoid panic on double-close.Closing
stopChtwice will panic; guard withsync.Once.🔧 Suggested fix
import ( "context" + "sync" "time" "github.com/maximhq/bifrost/core/schemas" ) @@ type TokenRefreshWorker struct { provider *OauthProvider refreshInterval time.Duration lookAheadWindow time.Duration // How far ahead to look for expiring tokens stopCh chan struct{} + stopOnce sync.Once logger schemas.Logger } @@ func (w *TokenRefreshWorker) Stop() { - close(w.stopCh) + w.stopOnce.Do(func() { close(w.stopCh) }) if w.logger != nil { w.logger.Info("Token refresh worker stopped") } }framework/oauth/main.go (1)
103-129: Handle refresh for public clients & missing refresh_token.Refresh currently always sends
client_secretand doesn’t guard emptyrefresh_token. For public clients (PKCE), empty secrets can be rejected and missing refresh tokens should fail fast.🔧 Suggested fix
func (p *OauthProvider) RefreshAccessToken(ctx context.Context, oauthConfigID string) error { // ... token, err := p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) if err != nil || token == nil { return fmt.Errorf("oauth token not found: %w", err) } + if token.RefreshToken == "" { + return fmt.Errorf("refresh token missing; re-authorize the OAuth flow") + } func (p *OauthProvider) exchangeRefreshToken(tokenURL, clientID, clientSecret, refreshToken string) (*schemas.TokenExchangeResponse, error) { data := url.Values{} data.Set("grant_type", "refresh_token") data.Set("refresh_token", refreshToken) data.Set("client_id", clientID) - data.Set("client_secret", clientSecret) + if clientSecret != "" { + data.Set("client_secret", clientSecret) + }Also applies to: 556-563
🧹 Nitpick comments (1)
framework/oauth/main.go (1)
482-499: Prefer bifrost.Ptr for pointer creation.Use
bifrost.Ptr(tokenID)instead of&tokenIDto match repo conventions (Line 498).♻️ Suggested change
- oauthConfig.TokenID = &tokenID + oauthConfig.TokenID = bifrost.Ptr(tokenID)Based on learnings, prefer
bifrost.Ptrfor pointer creation in this codebase.
framework/configstore/migrations.go
Outdated
| // Add registration_url column for dynamic client registration | ||
| if !migrator.HasColumn(oauthConfig, "registration_url") { | ||
| if err := migrator.AddColumn(oauthConfig, "registration_url"); err != nil { | ||
| return fmt.Errorf("failed to add registration_url column: %w", err) | ||
| } | ||
| } | ||
|
|
||
| // Add code_verifier column for PKCE | ||
| if !migrator.HasColumn(oauthConfig, "code_verifier") { | ||
| if err := migrator.AddColumn(oauthConfig, "code_verifier"); err != nil { | ||
| return fmt.Errorf("failed to add code_verifier column: %w", err) | ||
| } | ||
| } | ||
|
|
||
| // Add code_challenge column for PKCE | ||
| if !migrator.HasColumn(oauthConfig, "code_challenge") { | ||
| if err := migrator.AddColumn(oauthConfig, "code_challenge"); err != nil { | ||
| return fmt.Errorf("failed to add code_challenge column: %w", err) | ||
| } | ||
| } | ||
|
|
||
| // Add server_url column for OAuth discovery | ||
| if !migrator.HasColumn(oauthConfig, "server_url") { | ||
| if err := migrator.AddColumn(oauthConfig, "server_url"); err != nil { | ||
| return fmt.Errorf("failed to add server_url column: %w", err) | ||
| } | ||
| } | ||
|
|
||
| // Add use_discovery column | ||
| if !migrator.HasColumn(oauthConfig, "use_discovery") { | ||
| if err := migrator.AddColumn(oauthConfig, "use_discovery"); err != nil { | ||
| return fmt.Errorf("failed to add use_discovery column: %w", err) | ||
| } | ||
| } | ||
|
|
||
| // Make authorize_url and token_url nullable (can be discovered) | ||
| // GORM doesn't provide a direct way to alter column constraints, | ||
| // but since these fields don't have NOT NULL in the struct anymore, | ||
| // AutoMigrate will handle it on the next table sync | ||
|
|
||
| return nil |
There was a problem hiding this comment.
Make authorize_url/token_url nullable explicitly.
The migration relies on AutoMigrate to drop NOT NULL, but triggerMigrations doesn’t run AutoMigrate. If earlier schemas had NOT NULL, discovery-only configs will fail on upgrade. Add explicit ALTERs for supported dialects.
🛠️ Suggested fix
// Add use_discovery column
if !migrator.HasColumn(oauthConfig, "use_discovery") {
if err := migrator.AddColumn(oauthConfig, "use_discovery"); err != nil {
return fmt.Errorf("failed to add use_discovery column: %w", err)
}
}
- // Make authorize_url and token_url nullable (can be discovered)
- // GORM doesn't provide a direct way to alter column constraints,
- // but since these fields don't have NOT NULL in the struct anymore,
- // AutoMigrate will handle it on the next table sync
+ // Make authorize_url and token_url nullable (can be discovered)
+ switch tx.Dialector.Name() {
+ case "postgres":
+ if err := tx.Exec("ALTER TABLE oauth_configs ALTER COLUMN authorize_url DROP NOT NULL").Error; err != nil {
+ return fmt.Errorf("failed to drop NOT NULL on authorize_url: %w", err)
+ }
+ if err := tx.Exec("ALTER TABLE oauth_configs ALTER COLUMN token_url DROP NOT NULL").Error; err != nil {
+ return fmt.Errorf("failed to drop NOT NULL on token_url: %w", err)
+ }
+ case "mysql":
+ if err := tx.Exec("ALTER TABLE oauth_configs MODIFY authorize_url TEXT NULL").Error; err != nil {
+ return fmt.Errorf("failed to set authorize_url nullable: %w", err)
+ }
+ if err := tx.Exec("ALTER TABLE oauth_configs MODIFY token_url TEXT NULL").Error; err != nil {
+ return fmt.Errorf("failed to set token_url nullable: %w", err)
+ }
+ }🤖 Prompt for AI Agents
In `@framework/configstore/migrations.go` around lines 2986 - 3026, The migration
currently assumes AutoMigrate will drop NOT NULL on authorize_url and token_url,
but triggerMigrations doesn't call AutoMigrate, so explicitly ALTER those
columns to nullable for supported dialects: detect DB dialect (via migrator/DB
or gorm.DB.Dialector.Name()) in the same migration block that touches
oauthConfig and run dialect-specific SQL (e.g., ALTER TABLE ... ALTER COLUMN ...
DROP NOT NULL for Postgres, MODIFY ... NULL for MySQL, and a no-op or recreate
table for SQLite) using migrator.DB.Exec or gorm.DB.Exec; perform these ALTERs
for both authorize_url and token_url just before returning nil so discovery-only
configs won’t fail on upgrade.
| func DiscoverOAuthMetadata(ctx context.Context, serverURL string) (*OAuthMetadata, error) { | ||
| if logger != nil { | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Starting discovery for server: %s", serverURL)) | ||
| } | ||
|
|
||
| // Step 1: Attempt to connect to MCP server, expect 401 with WWW-Authenticate header | ||
| client := &http.Client{ | ||
| Timeout: 10 * time.Second, | ||
| } | ||
|
|
||
| req, err := http.NewRequestWithContext(ctx, "GET", serverURL, nil) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to create request: %w", err) | ||
| } | ||
|
|
||
| resp, err := client.Do(req) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to connect to server: %w", err) | ||
| } | ||
| defer resp.Body.Close() | ||
|
|
||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Server responded with status: %d", resp.StatusCode)) | ||
|
|
||
| // Step 2: Parse WWW-Authenticate header | ||
| wwwAuth := resp.Header.Get("WWW-Authenticate") | ||
| if wwwAuth == "" { | ||
| wwwAuth = resp.Header.Get("www-authenticate") | ||
| } | ||
|
|
||
| resourceMetadataURL, scopesFromHeader := parseWWWAuthenticateHeader(wwwAuth) | ||
| if resourceMetadataURL != "" { | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Found resource_metadata URL: %s", resourceMetadataURL)) | ||
| } | ||
| if len(scopesFromHeader) > 0 { | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Found scopes in header: %v", scopesFromHeader)) | ||
| } | ||
|
|
||
| // Step 3: Fetch resource metadata if available | ||
| var authServers []string | ||
| var resourceScopes []string | ||
|
|
||
| if resourceMetadataURL != "" { | ||
| authServers, resourceScopes, err = fetchResourceMetadata(ctx, resourceMetadataURL) | ||
| if err != nil { | ||
| // Log but continue to well-known discovery | ||
| logger.Warn(fmt.Sprintf("[OAuth Discovery] Failed to fetch resource metadata: %v", err)) | ||
| } else { | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Found %d authorization servers from resource metadata", len(authServers))) | ||
| } | ||
| } | ||
|
|
||
| // Step 4: Try well-known discovery if no resource metadata | ||
| if len(authServers) == 0 { | ||
| logger.Debug("[OAuth Discovery] Attempting .well-known discovery") | ||
| authServers, resourceScopes, err = attemptWellKnownDiscovery(ctx, serverURL) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("OAuth discovery failed: %w", err) | ||
| } | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Found %d authorization servers from .well-known", len(authServers))) | ||
| } | ||
|
|
||
| // Step 5: Fetch authorization server metadata | ||
| metadata, err := fetchAuthorizationServerMetadata(ctx, authServers) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to fetch authorization server metadata: %w", err) | ||
| } | ||
|
|
||
| // Step 6: Merge scopes (priority: header > resource metadata > discovered) | ||
| if len(scopesFromHeader) > 0 { | ||
| metadata.ScopesSupported = scopesFromHeader | ||
| } else if len(resourceScopes) > 0 { | ||
| metadata.ScopesSupported = resourceScopes | ||
| } | ||
|
|
||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Successfully discovered OAuth metadata for %s", serverURL)) | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Authorization URL: %s", metadata.AuthorizationURL)) | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Token URL: %s", metadata.TokenURL)) | ||
| if metadata.RegistrationURL != nil { | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Registration URL: %s", *metadata.RegistrationURL)) | ||
| } | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Scopes: %v", metadata.ScopesSupported)) | ||
|
|
There was a problem hiding this comment.
Guard logger usage to avoid nil dereference.
After the initial nil check, subsequent logger.* calls (e.g., Line 74+) are unconditional. If SetLogger isn’t called, discovery can panic. Consider guarding all log calls or ensuring a default/no-op logger is always set before any discovery/PKCE/registration function runs.
🛡️ Example guard (apply across this file)
- logger.Debug(fmt.Sprintf("[OAuth Discovery] Server responded with status: %d", resp.StatusCode))
+ if logger != nil {
+ logger.Debug(fmt.Sprintf("[OAuth Discovery] Server responded with status: %d", resp.StatusCode))
+ }🤖 Prompt for AI Agents
In `@framework/oauth/discovery.go` around lines 53 - 134, DiscoverOAuthMetadata
uses the package-level logger without guarding later calls which can panic if
SetLogger was never called; fix by ensuring a default no-op logger is present at
the start of the function (or in package init) before any logger.* calls (e.g.,
in DiscoverOAuthMetadata check if logger == nil then assign a noopLogger
implementing Debug/Warn/Error methods), or alternatively add logger != nil
guards before every logger.Debug/Warn call; reference logger and
DiscoverOAuthMetadata (or SetLogger/noopLogger) to locate where to apply the
change.
| // buildAuthorizeURLWithPKCE constructs the OAuth authorization URL with PKCE parameters | ||
| func (p *OauthProvider) buildAuthorizeURLWithPKCE(authorizeURL, clientID, redirectURI, state, codeChallenge string, scopes []string) string { | ||
| params := url.Values{} | ||
| params.Set("response_type", "code") | ||
| params.Set("client_id", clientID) | ||
| params.Set("redirect_uri", redirectURI) | ||
| params.Set("state", state) | ||
| params.Set("code_challenge", codeChallenge) | ||
| params.Set("code_challenge_method", "S256") // SHA-256 hashing | ||
| if len(scopes) > 0 { | ||
| params.Set("scope", strings.Join(scopes, " ")) | ||
| } | ||
|
|
||
| return authorizeURL + "?" + params.Encode() | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Check for PR stack context and recent changes
cd framework/oauth && git log --oneline -20 main.go 2>/dev/null || echo "Not a git repo or no history"Repository: maximhq/bifrost
Length of output: 98
🏁 Script executed:
# Find all usages of buildAuthorizeURLWithPKCE to understand calling patterns
rg "buildAuthorizeURLWithPKCE" --type go -B 3 -A 3Repository: maximhq/bifrost
Length of output: 986
🏁 Script executed:
# Check if authorizeURL parameter sources and what values they typically contain
rg "buildAuthorizeURLWithPKCE|authorizeURL" --type go -B 5 -A 5 | head -100Repository: maximhq/bifrost
Length of output: 5955
🏁 Script executed:
# Look at the call site and surrounding code to see if there's any URL validation
sed -n '470,490p' framework/oauth/main.goRepository: maximhq/bifrost
Length of output: 734
🏁 Script executed:
# Check if url.Parse is already imported in this file
rg "import|^import" framework/oauth/main.go -A 20 | head -30Repository: maximhq/bifrost
Length of output: 493
🏁 Script executed:
# Verify there's no existing handling for URLs with query params elsewhere in the file
rg "url\\.Parse|RawQuery|Query\(\)" framework/oauth/main.goRepository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Get exact call site context
sed -n '460,495p' framework/oauth/main.goRepository: maximhq/bifrost
Length of output: 1169
🏁 Script executed:
# Also verify the function definition one more time to ensure no other query param handling
sed -n '509,525p' framework/oauth/main.goRepository: maximhq/bifrost
Length of output: 766
Handle authorize URLs that already contain query parameters.
buildAuthorizeURLWithPKCE appends ? via simple string concatenation, which breaks URLs that already contain query parameters. This can occur with user-provided authorize URLs or certain OAuth providers with custom implementations. Use url.Parse to safely merge existing and new query parameters.
🔧 Suggested fix
func (p *OauthProvider) buildAuthorizeURLWithPKCE(authorizeURL, clientID, redirectURI, state, codeChallenge string, scopes []string) string {
params := url.Values{}
params.Set("response_type", "code")
params.Set("client_id", clientID)
params.Set("redirect_uri", redirectURI)
params.Set("state", state)
params.Set("code_challenge", codeChallenge)
params.Set("code_challenge_method", "S256")
if len(scopes) > 0 {
params.Set("scope", strings.Join(scopes, " "))
}
- return authorizeURL + "?" + params.Encode()
+ u, err := url.Parse(authorizeURL)
+ if err != nil {
+ return authorizeURL + "?" + params.Encode()
+ }
+ q := u.Query()
+ for k, vs := range params {
+ for _, v := range vs {
+ q.Add(k, v)
+ }
+ }
+ u.RawQuery = q.Encode()
+ return u.String()
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // buildAuthorizeURLWithPKCE constructs the OAuth authorization URL with PKCE parameters | |
| func (p *OauthProvider) buildAuthorizeURLWithPKCE(authorizeURL, clientID, redirectURI, state, codeChallenge string, scopes []string) string { | |
| params := url.Values{} | |
| params.Set("response_type", "code") | |
| params.Set("client_id", clientID) | |
| params.Set("redirect_uri", redirectURI) | |
| params.Set("state", state) | |
| params.Set("code_challenge", codeChallenge) | |
| params.Set("code_challenge_method", "S256") // SHA-256 hashing | |
| if len(scopes) > 0 { | |
| params.Set("scope", strings.Join(scopes, " ")) | |
| } | |
| return authorizeURL + "?" + params.Encode() | |
| } | |
| // buildAuthorizeURLWithPKCE constructs the OAuth authorization URL with PKCE parameters | |
| func (p *OauthProvider) buildAuthorizeURLWithPKCE(authorizeURL, clientID, redirectURI, state, codeChallenge string, scopes []string) string { | |
| params := url.Values{} | |
| params.Set("response_type", "code") | |
| params.Set("client_id", clientID) | |
| params.Set("redirect_uri", redirectURI) | |
| params.Set("state", state) | |
| params.Set("code_challenge", codeChallenge) | |
| params.Set("code_challenge_method", "S256") // SHA-256 hashing | |
| if len(scopes) > 0 { | |
| params.Set("scope", strings.Join(scopes, " ")) | |
| } | |
| u, err := url.Parse(authorizeURL) | |
| if err != nil { | |
| return authorizeURL + "?" + params.Encode() | |
| } | |
| q := u.Query() | |
| for k, vs := range params { | |
| for _, v := range vs { | |
| q.Add(k, v) | |
| } | |
| } | |
| u.RawQuery = q.Encode() | |
| return u.String() | |
| } |
🤖 Prompt for AI Agents
In `@framework/oauth/main.go` around lines 509 - 523, The
buildAuthorizeURLWithPKCE function currently concatenates "?" + params.Encode(),
which breaks when authorizeURL already contains query parameters; update it to
parse authorizeURL with url.Parse, merge existing query values with the new
params (using url.Values and the parsedURL.Query() method), set the merged query
back on the parsed URL with parsedURL.RawQuery = merged.Encode(), and return
parsedURL.String(); ensure this change is applied inside the
buildAuthorizeURLWithPKCE method so existing query params are preserved and new
PKCE params are appended safely.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@framework/oauth/discovery.go`:
- Around line 53-136: DiscoverOAuthMetadata uses the package-level logger
without nil checks which can cause panics when logger is nil; locate all calls
to logger.Debug and logger.Warn inside DiscoverOAuthMetadata (and similarly
named logging calls in related discovery functions if present) and either (A)
wrap each call with a nil check (if logger != nil) before invoking Debug/Warn,
or (B) add and use a small helper like safeLogDebug/safeLogWarn that checks
logger != nil then delegates to logger.Debug/Warn, and replace direct
logger.Debug/Warn calls with that helper to ensure safe, silent operation when
logger is nil.
In `@framework/oauth/main.go`:
- Around line 443-447: The code updates oauthConfig.Status to "expired" then
calls p.configStore.UpdateOauthConfig(ctx, oauthConfig) without checking its
error; modify the block in the logic that checks
time.Now().After(oauthConfig.ExpiresAt) so you capture the returned error from
p.configStore.UpdateOauthConfig, handle it (e.g., log via existing logger or
return the error), and propagate a meaningful error instead of ignoring it;
ensure you reference oauthConfig, ExpiresAt, Status, and
p.configStore.UpdateOauthConfig so the update failure is not silently dropped.
♻️ Duplicate comments (3)
framework/oauth/sync.go (1)
38-44: MakeStop()idempotent to prevent panic on multiple calls.Closing a channel twice causes a panic. Use
sync.Onceto guard the close operation, consistent with patterns established elsewhere in the codebase (e.g.,framework/tracing/store.go).🔧 Suggested fix
+import ( + "context" + "sync" + "time" + + "github.com/maximhq/bifrost/core/schemas" +) + type TokenRefreshWorker struct { provider *OauthProvider refreshInterval time.Duration lookAheadWindow time.Duration stopCh chan struct{} + stopOnce sync.Once logger schemas.Logger } func (w *TokenRefreshWorker) Stop() { - close(w.stopCh) + w.stopOnce.Do(func() { close(w.stopCh) }) if w.logger != nil { w.logger.Info("Token refresh worker stopped") } }framework/oauth/main.go (2)
102-132: Handle public clients and missing refresh tokens in RefreshAccessToken.Per RFC 6749, public clients cannot maintain secret confidentiality, and some OAuth servers reject empty secrets. Additionally, this method should guard against missing
refresh_tokento fail gracefully.🔧 Suggested fix
func (p *OauthProvider) RefreshAccessToken(ctx context.Context, oauthConfigID string) error { // ... existing code to load oauthConfig and token ... + // Guard against missing refresh token + if token.RefreshToken == "" { + return fmt.Errorf("refresh token missing; re-authorize the OAuth flow") + } + // Call OAuth provider's token endpoint with refresh_token newTokenResponse, err := p.exchangeRefreshToken( oauthConfig.TokenURL, oauthConfig.ClientID, oauthConfig.ClientSecret, token.RefreshToken, )
556-565: Handle public clients in exchangeRefreshToken.This method unconditionally sends
client_secret, butexchangeCodeForTokensandexchangeCodeForTokensWithPKCEcorrectly guard it withif clientSecret != "". Per RFC 6749, public clients should not send an empty secret.🔧 Suggested fix
func (p *OauthProvider) exchangeRefreshToken(tokenURL, clientID, clientSecret, refreshToken string) (*schemas.TokenExchangeResponse, error) { data := url.Values{} data.Set("grant_type", "refresh_token") data.Set("refresh_token", refreshToken) data.Set("client_id", clientID) - data.Set("client_secret", clientSecret) + if clientSecret != "" { + data.Set("client_secret", clientSecret) + } return p.callTokenEndpoint(tokenURL, data) }
🧹 Nitpick comments (8)
framework/oauth/discovery_test.go (1)
55-55: Consider handling the encoder error for test reliability.The
json.NewEncoder(w).Encode(resp)call ignores its return error. While unlikely to fail in tests, handling it would make test failures more debuggable.🔧 Suggested fix
- json.NewEncoder(w).Encode(resp) + if err := json.NewEncoder(w).Encode(resp); err != nil { + t.Errorf("Failed to encode response: %v", err) + }framework/oauth/sync.go (1)
126-134: Setters won't affect a running worker's ticker.
SetRefreshIntervalandSetLookAheadWindoware documented "for testing" but won't update an already-running ticker. This is acceptable for test setup beforeStart(), but consider documenting this limitation or warning if called after start.🔧 Optional: Add guard or documentation
// SetRefreshInterval updates the refresh check interval (for testing) +// Note: Must be called before Start() - changes won't affect an already-running worker func (w *TokenRefreshWorker) SetRefreshInterval(interval time.Duration) { w.refreshInterval = interval }framework/oauth/discovery.go (2)
330-355: Minor: Modulo bias in PKCE verifier generation.Using
randomBytes[i] % len(charset)introduces slight bias since 256 doesn't divide evenly by 66 (charset length). For cryptographic applications, consider rejection sampling. However, the bias is minimal (~2.3%) and the 128-character length provides sufficient entropy, so this is acceptable for PKCE.
407-408: Minor optimization: Avoid unnecessary string conversion.
strings.NewReaderaccepts a string, but you can usebytes.NewReaderdirectly withreqBodyto avoid the conversion.🔧 Suggested fix
+import "bytes" + // Create HTTP request -httpReq, err := http.NewRequestWithContext(ctx, "POST", registrationURL, strings.NewReader(string(reqBody))) +httpReq, err := http.NewRequestWithContext(ctx, "POST", registrationURL, bytes.NewReader(reqBody))framework/configstore/rdb.go (3)
2701-2712: Inconsistent error handling pattern for not-found cases.The OAuth methods return
nil, nilwhen a record is not found, whereas other methods in this file (e.g.,GetMCPClientByID,GetVirtualKey,GetBudget) returnnil, ErrNotFound. While the calling code inoauth/main.gohandles this by checking for nil, this creates an inconsistent API surface.Consider aligning with the existing pattern:
♻️ Suggested refactor
func (s *RDBConfigStore) GetOauthConfigByID(ctx context.Context, id string) (*tables.TableOauthConfig, error) { var config tables.TableOauthConfig result := s.db.WithContext(ctx).Where("id = ?", id).First(&config) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, nil + return nil, ErrNotFound } return nil, fmt.Errorf("failed to get oauth config: %w", result.Error) } return &config, nil }Apply the same pattern to
GetOauthConfigByState,GetOauthTokenByID, andGetOauthConfigByTokenID.
2741-2748: MissingparseGormErrorusage for user-friendly error messages.Other create/update methods in this file use
s.parseGormError(err)to provide user-friendly error messages for constraint violations. The OAuth methods wrap errors directly, which is inconsistent.♻️ Suggested refactor
func (s *RDBConfigStore) CreateOauthConfig(ctx context.Context, config *tables.TableOauthConfig) error { result := s.db.WithContext(ctx).Create(config) if result.Error != nil { - return fmt.Errorf("failed to create oauth config: %w", result.Error) + return s.parseGormError(result.Error) } return nil }
2786-2796: Consider filtering by token validity status.
GetExpiringOauthTokensretrieves tokens expiring before a given time but doesn't filter out already-revoked or failed OAuth configs. The token refresh worker might attempt to refresh tokens for configs that are no longer valid.Consider adding a join or subquery to exclude tokens whose associated
oauth_config.statusis not"authorized", or handle this filtering in the caller.framework/oauth/main.go (1)
38-53: Background goroutine lacks shutdown mechanism.The cleanup goroutine started at line 50 runs indefinitely with no way to stop it. If
OauthProvideris recreated (e.g., during tests or configuration reloads), this could lead to goroutine leaks.♻️ Suggested refactor
Add a context or done channel to allow graceful shutdown:
type OauthProvider struct { configStore configstore.ConfigStore mu sync.RWMutex pendingMCPClients map[string]*PendingMCPClient + done chan struct{} } func NewOauthProvider(configStore configstore.ConfigStore, logger schemas.Logger) *OauthProvider { // ... existing code ... p := &OauthProvider{ configStore: configStore, pendingMCPClients: make(map[string]*PendingMCPClient), + done: make(chan struct{}), } - go p.cleanupExpiredPendingClients() + go p.cleanupExpiredPendingClients(p.done) return p } +func (p *OauthProvider) Close() { + close(p.done) +} -func (p *OauthProvider) cleanupExpiredPendingClients() { +func (p *OauthProvider) cleanupExpiredPendingClients(done <-chan struct{}) { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() - for range ticker.C { + for { + select { + case <-done: + return + case <-ticker.C: + } // ... cleanup logic ... } }
| func DiscoverOAuthMetadata(ctx context.Context, serverURL string) (*OAuthMetadata, error) { | ||
| if logger != nil { | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Starting discovery for server: %s", serverURL)) | ||
| } | ||
|
|
||
| // Step 1: Attempt to connect to MCP server, expect 401 with WWW-Authenticate header | ||
| client := &http.Client{ | ||
| Timeout: 10 * time.Second, | ||
| } | ||
|
|
||
| req, err := http.NewRequestWithContext(ctx, "GET", serverURL, nil) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to create request: %w", err) | ||
| } | ||
|
|
||
| resp, err := client.Do(req) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to connect to server: %w", err) | ||
| } | ||
| defer resp.Body.Close() | ||
|
|
||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Server responded with status: %d", resp.StatusCode)) | ||
|
|
||
| // Step 2: Parse WWW-Authenticate header | ||
| wwwAuth := resp.Header.Get("WWW-Authenticate") | ||
| if wwwAuth == "" { | ||
| wwwAuth = resp.Header.Get("www-authenticate") | ||
| } | ||
|
|
||
| resourceMetadataURL, scopesFromHeader := parseWWWAuthenticateHeader(wwwAuth) | ||
| if resourceMetadataURL != "" { | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Found resource_metadata URL: %s", resourceMetadataURL)) | ||
| } | ||
| if len(scopesFromHeader) > 0 { | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Found scopes in header: %v", scopesFromHeader)) | ||
| } | ||
|
|
||
| // Step 3: Fetch resource metadata if available | ||
| var authServers []string | ||
| var resourceScopes []string | ||
|
|
||
| if resourceMetadataURL != "" { | ||
| authServers, resourceScopes, err = fetchResourceMetadata(ctx, resourceMetadataURL) | ||
| if err != nil { | ||
| // Log but continue to well-known discovery | ||
| logger.Warn(fmt.Sprintf("[OAuth Discovery] Failed to fetch resource metadata: %v", err)) | ||
| } else { | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Found %d authorization servers from resource metadata", len(authServers))) | ||
| } | ||
| } | ||
|
|
||
| // Step 4: Try well-known discovery if no resource metadata | ||
| if len(authServers) == 0 { | ||
| logger.Debug("[OAuth Discovery] Attempting .well-known discovery") | ||
| authServers, resourceScopes, err = attemptWellKnownDiscovery(ctx, serverURL) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("OAuth discovery failed: %w", err) | ||
| } | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Found %d authorization servers from .well-known", len(authServers))) | ||
| } | ||
|
|
||
| // Step 5: Fetch authorization server metadata | ||
| metadata, err := fetchAuthorizationServerMetadata(ctx, authServers) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to fetch authorization server metadata: %w", err) | ||
| } | ||
|
|
||
| // Step 6: Merge scopes (priority: header > resource metadata > discovered) | ||
| if len(scopesFromHeader) > 0 { | ||
| metadata.ScopesSupported = scopesFromHeader | ||
| } else if len(resourceScopes) > 0 { | ||
| metadata.ScopesSupported = resourceScopes | ||
| } | ||
|
|
||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Successfully discovered OAuth metadata for %s", serverURL)) | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Authorization URL: %s", metadata.AuthorizationURL)) | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Token URL: %s", metadata.TokenURL)) | ||
| if metadata.RegistrationURL != nil { | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Registration URL: %s", *metadata.RegistrationURL)) | ||
| } | ||
| logger.Debug(fmt.Sprintf("[OAuth Discovery] Scopes: %v", metadata.ScopesSupported)) | ||
|
|
||
| return metadata, nil | ||
| } |
There was a problem hiding this comment.
Nil pointer dereference: Logger is not checked before use.
The function comment states logger can be nil for "silent operation", but multiple logger.Debug/Warn calls don't check for nil. This will panic when logger is nil.
🔧 Suggested fix - use consistent nil checks
func DiscoverOAuthMetadata(ctx context.Context, serverURL string) (*OAuthMetadata, error) {
if logger != nil {
logger.Debug(fmt.Sprintf("[OAuth Discovery] Starting discovery for server: %s", serverURL))
}
// ... http client code ...
- logger.Debug(fmt.Sprintf("[OAuth Discovery] Server responded with status: %d", resp.StatusCode))
+ if logger != nil {
+ logger.Debug(fmt.Sprintf("[OAuth Discovery] Server responded with status: %d", resp.StatusCode))
+ }Apply similar nil checks to all logger calls in lines 84, 87, 98, 100, 106, 111, 127-133, 216, 219, 222, 228, 236, 239, 242, 274, 297, 352, 398-399, 431, 446-450.
🤖 Prompt for AI Agents
In `@framework/oauth/discovery.go` around lines 53 - 136, DiscoverOAuthMetadata
uses the package-level logger without nil checks which can cause panics when
logger is nil; locate all calls to logger.Debug and logger.Warn inside
DiscoverOAuthMetadata (and similarly named logging calls in related discovery
functions if present) and either (A) wrap each call with a nil check (if logger
!= nil) before invoking Debug/Warn, or (B) add and use a small helper like
safeLogDebug/safeLogWarn that checks logger != nil then delegates to
logger.Debug/Warn, and replace direct logger.Debug/Warn calls with that helper
to ensure safe, silent operation when logger is nil.
| if time.Now().After(oauthConfig.ExpiresAt) { | ||
| oauthConfig.Status = "expired" | ||
| p.configStore.UpdateOauthConfig(ctx, oauthConfig) | ||
| return fmt.Errorf("oauth flow expired") | ||
| } |
There was a problem hiding this comment.
Error from UpdateOauthConfig is silently ignored.
When the OAuth flow expires, the status update error is not checked. This could leave the database in an inconsistent state.
🔧 Suggested fix
// Check expiry
if time.Now().After(oauthConfig.ExpiresAt) {
oauthConfig.Status = "expired"
- p.configStore.UpdateOauthConfig(ctx, oauthConfig)
+ if err := p.configStore.UpdateOauthConfig(ctx, oauthConfig); err != nil {
+ logger.Error("Failed to update expired oauth config status", "error", err, "oauth_config_id", oauthConfig.ID)
+ }
return fmt.Errorf("oauth flow expired")
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if time.Now().After(oauthConfig.ExpiresAt) { | |
| oauthConfig.Status = "expired" | |
| p.configStore.UpdateOauthConfig(ctx, oauthConfig) | |
| return fmt.Errorf("oauth flow expired") | |
| } | |
| if time.Now().After(oauthConfig.ExpiresAt) { | |
| oauthConfig.Status = "expired" | |
| if err := p.configStore.UpdateOauthConfig(ctx, oauthConfig); err != nil { | |
| logger.Error("Failed to update expired oauth config status", "error", err, "oauth_config_id", oauthConfig.ID) | |
| } | |
| return fmt.Errorf("oauth flow expired") | |
| } |
🤖 Prompt for AI Agents
In `@framework/oauth/main.go` around lines 443 - 447, The code updates
oauthConfig.Status to "expired" then calls p.configStore.UpdateOauthConfig(ctx,
oauthConfig) without checking its error; modify the block in the logic that
checks time.Now().After(oauthConfig.ExpiresAt) so you capture the returned error
from p.configStore.UpdateOauthConfig, handle it (e.g., log via existing logger
or return the error), and propagate a meaningful error instead of ignoring it;
ensure you reference oauthConfig, ExpiresAt, Status, and
p.configStore.UpdateOauthConfig so the update failure is not silently dropped.
432b607 to
34d5d04
Compare
1e5ab07 to
8256d98
Compare
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Fix all issues with AI agents
In `@framework/configstore/tables/mcp.go`:
- Around line 34-37: The OnDelete:CASCADE on the OauthConfig relationship will
remove TableMCPClient rows when an oauth_config is deleted; change the
constraint on OauthConfigID and the OauthConfig relationship to use OnDelete:SET
NULL (or OnDelete:RESTRICT if you prefer preventing deletion) so the foreign key
is nulled (or deletion is blocked) instead of cascading, and remove the
redundant constraint declaration so only the relationship tag (OauthConfig or
OauthConfigID) carries the single desired OnDelete behavior; update the struct
fields AuthType, OauthConfigID, and OauthConfig accordingly.
In `@framework/oauth/main.go`:
- Around line 92-95: The error path that handles the result of
p.configStore.GetOauthTokenByID (checking token and err) currently returns
fmt.Errorf("failed to reload token after refresh: %w", err) even when err is
nil, producing a confusing "%!w(<nil>)"; change the logic in the block that
reads token, err = p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) so
that if err != nil you return fmt.Errorf("failed to reload token after refresh:
%w", err) but if err == nil and token == nil you return a clear error like
fmt.Errorf("failed to reload token after refresh: token is nil (no error
returned from GetOauthTokenByID)"); update the surrounding function (the caller
that uses oauthConfig.TokenID) accordingly.
- Around line 465-473: When token exchange fails in the block handling err !=
nil, call p.configStore.UpdateOauthConfig(ctx, oauthConfig) and check its
returned error; if UpdateOauthConfig returns an error, log it (e.g., with
logger.Error including the update error and context like oauthConfig.ClientID
and oauthConfig.TokenURL) and consider returning a combined or wrapped error so
the caller knows both the token-exchange failure and the config-update failure;
update the handling around oauthConfig, p.configStore.UpdateOauthConfig, and
logger.Error accordingly.
- Line 480: The json.Marshal call that assigns scopesJSON currently ignores its
error; update the call to capture the returned error (e.g., scopesJSON, err :=
json.Marshal(scopes)) and handle it the same way as the earlier marshal on line
382—check if err != nil and return or log an appropriate error (or propagate it)
before using scopesJSON, referencing the json.Marshal(scopes) call and the
scopesJSON variable to locate and fix the code path.
♻️ Duplicate comments (9)
framework/configstore/migrations.go (1)
3021-3026: Make authorize_url/token_url nullable explicitly.The comment acknowledges that GORM's AutoMigrate would handle nullable constraints, but
triggerMigrationsdoesn't invoke AutoMigrate. If earlier schemas had NOT NULL on these columns, discovery-only configs will fail on upgrade. Add explicit ALTER statements for supported dialects.🛠️ Suggested fix
// Add use_discovery column if !migrator.HasColumn(oauthConfig, "use_discovery") { if err := migrator.AddColumn(oauthConfig, "use_discovery"); err != nil { return fmt.Errorf("failed to add use_discovery column: %w", err) } } - // Make authorize_url and token_url nullable (can be discovered) - // GORM doesn't provide a direct way to alter column constraints, - // but since these fields don't have NOT NULL in the struct anymore, - // AutoMigrate will handle it on the next table sync + // Make authorize_url and token_url nullable (can be discovered) + switch tx.Dialector.Name() { + case "postgres": + _ = tx.Exec("ALTER TABLE oauth_configs ALTER COLUMN authorize_url DROP NOT NULL").Error + _ = tx.Exec("ALTER TABLE oauth_configs ALTER COLUMN token_url DROP NOT NULL").Error + case "mysql": + _ = tx.Exec("ALTER TABLE oauth_configs MODIFY authorize_url TEXT NULL").Error + _ = tx.Exec("ALTER TABLE oauth_configs MODIFY token_url TEXT NULL").Error + } return nilframework/oauth/discovery.go (2)
53-136: Guard all logger calls to prevent nil panics.Line 54 guards the first logger call, but subsequent calls (lines 74, 84, 87, 98, 100, 106, 111, 127-133) lack nil checks. If
DiscoverOAuthMetadatais invoked beforeSetLogger()is called, this will cause a nil pointer panic.🛠️ Suggested fix - initialize logger at function entry
func DiscoverOAuthMetadata(ctx context.Context, serverURL string) (*OAuthMetadata, error) { + // Ensure logger is initialized to prevent nil panics + if logger == nil { + logger = bifrost.NewDefaultLogger(schemas.LogLevelInfo) + } + if logger != nil { logger.Debug(fmt.Sprintf("[OAuth Discovery] Starting discovery for server: %s", serverURL)) }Apply similar guards at the start of
GeneratePKCEChallenge(line 330) andRegisterDynamicClient(line 397).
388-454: Guard logger calls in RegisterDynamicClient.Lines 398-399, 431, 446-450 call logger methods without nil checks, which will panic if
SetLogger()was never called.framework/oauth/sync.go (1)
38-43: Make Stop() idempotent to prevent channel-close panics.Closing
stopChmore than once will panic; guard the close withsync.Once.🔧 Suggested fix
import ( "context" + "sync" "time" "github.com/maximhq/bifrost/core/schemas" ) @@ type TokenRefreshWorker struct { provider *OauthProvider refreshInterval time.Duration lookAheadWindow time.Duration // How far ahead to look for expiring tokens stopCh chan struct{} + stopOnce sync.Once logger schemas.Logger } @@ func (w *TokenRefreshWorker) Stop() { - close(w.stopCh) + w.stopOnce.Do(func() { close(w.stopCh) }) if w.logger != nil { w.logger.Info("Token refresh worker stopped") } }framework/oauth/main.go (5)
102-150: Handle refresh flows for public clients and missing refresh tokens.The issues flagged in the previous review remain unaddressed:
RefreshAccessTokendoes not guard against an emptyrefresh_tokenbefore attempting refreshexchangeRefreshToken(line 562) unconditionally sendsclient_secret, which violates RFC 6749 for public clients
386-408: Encryption for sensitive OAuth fields is still missing.As flagged in the previous review, sensitive fields (
ClientSecret,CodeVerifier) are stored in plaintext. TheBeforeSavehooks inTableOauthConfigandTableOauthTokendo not actually encrypt these fields despite comments claiming they are encrypted.
443-447: Error fromUpdateOauthConfigis silently ignored.As flagged in the previous review, the error from updating the expired status is not checked. This could leave the database in an inconsistent state.
509-523: URL building doesn't handle existing query parameters.As flagged in previous reviews,
authorizeURL + "?" + params.Encode()breaks if the base URL already contains query parameters.
556-565:exchangeRefreshTokenunconditionally sendsclient_secret.Unlike
exchangeCodeForTokensandexchangeCodeForTokensWithPKCE, this function always includesclient_secreteven when empty. Per RFC 6749, public clients should not send an empty secret as some OAuth servers reject it.
🧹 Nitpick comments (7)
framework/oauth/discovery_test.go (1)
1-150: Consider adding test for logger nil safety.Given the logger nil-check issues in
discovery.go, consider adding a test that calls these functions without initializing the logger to verify they don't panic. This would help catch regressions if the nil guards are added.func TestDiscoveryFunctionsWithNilLogger(t *testing.T) { // Temporarily unset logger to test nil safety // (requires SetLogger to accept nil or a test helper) // These should not panic _, _, _ = GeneratePKCEChallenge() _ = ValidatePKCEChallenge("test", "test") }core/mcp/clientmanager.go (1)
113-157: Consider factoring AddClient/AddClientInMemory into a shared helper.The new method mirrors
AddClientclosely; a small internal helper would reduce drift over time.framework/configstore/rdb.go (1)
2701-2809: Consider usingbifrost.Ptr(...)for OAuth getters to match repo pointer style.The new getters return
&config/&token; switching tobifrost.Ptr(...)keeps pointer creation consistent with the established convention. Based on learnings, consider applying this inGetOauthConfigByID,GetOauthConfigByState,GetOauthTokenByID, andGetOauthConfigByTokenID.framework/oauth/main.go (4)
38-53: Background goroutine lacks shutdown mechanism.The
cleanupExpiredPendingClientsgoroutine runs indefinitely with no way to stop it when theOauthProvideris no longer needed. This can cause goroutine leaks, especially in tests or when multiple providers are created.Consider adding a context or done channel for graceful shutdown:
♻️ Suggested refactor
type OauthProvider struct { configStore configstore.ConfigStore mu sync.RWMutex pendingMCPClients map[string]*PendingMCPClient + done chan struct{} } func NewOauthProvider(configStore configstore.ConfigStore, logger schemas.Logger) *OauthProvider { if logger == nil { logger = bifrost.NewDefaultLogger(schemas.LogLevelInfo) } SetLogger(logger) p := &OauthProvider{ configStore: configStore, pendingMCPClients: make(map[string]*PendingMCPClient), + done: make(chan struct{}), } go p.cleanupExpiredPendingClients() return p } +// Close shuts down the OauthProvider and stops background goroutines +func (p *OauthProvider) Close() { + close(p.done) +}Then update
cleanupExpiredPendingClientsto listen onp.done.
152-170: Consider returning errors for better diagnostics.
ValidateTokenreturns(false, nil)for both "token invalid" and "database error" scenarios. Callers cannot distinguish between an invalid token and a transient failure. Consider returning errors for database failures while reserving(false, nil)for genuinely invalid/missing tokens.♻️ Suggested refactor
func (p *OauthProvider) ValidateToken(ctx context.Context, oauthConfigID string) (bool, error) { oauthConfig, err := p.configStore.GetOauthConfigByID(ctx, oauthConfigID) - if err != nil || oauthConfig == nil { + if err != nil { + return false, fmt.Errorf("failed to load oauth config: %w", err) + } + if oauthConfig == nil { return false, nil } if oauthConfig.TokenID == nil { return false, nil } token, err := p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) - if err != nil || token == nil { + if err != nil { + return false, fmt.Errorf("failed to load oauth token: %w", err) + } + if token == nil { return false, nil } return time.Now().Before(token.ExpiresAt), nil }
243-259: Extract magic numbers to named constants.The cleanup interval (1 minute) and expiry duration (5 minutes) are hardcoded. Consider extracting these as package-level constants for better maintainability.
♻️ Suggested refactor
+const ( + pendingClientCleanupInterval = 1 * time.Minute + pendingClientExpiry = 5 * time.Minute +) + func (p *OauthProvider) cleanupExpiredPendingClients() { - ticker := time.NewTicker(1 * time.Minute) + ticker := time.NewTicker(pendingClientCleanupInterval) defer ticker.Stop() for range ticker.C { p.mu.Lock() now := time.Now() for mcpClientID, pending := range p.pendingMCPClients { - if now.Sub(pending.CreatedAt) > 5*time.Minute { + if now.Sub(pending.CreatedAt) > pendingClientExpiry { delete(p.pendingMCPClients, mcpClientID)
567-599: Consider propagating context to HTTP requests.
callTokenEndpointcreates an HTTP request without context, relying solely on the 30-second timeout. If the parent context is cancelled (e.g., user cancels the request), this call will continue until the timeout expires. Consider accepting a context parameter and usinghttp.NewRequestWithContext.♻️ Suggested refactor
-func (p *OauthProvider) callTokenEndpoint(tokenURL string, data url.Values) (*schemas.TokenExchangeResponse, error) { - req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode())) +func (p *OauthProvider) callTokenEndpoint(ctx context.Context, tokenURL string, data url.Values) (*schemas.TokenExchangeResponse, error) { + req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(data.Encode()))This requires updating the callers (
exchangeCodeForTokens,exchangeCodeForTokensWithPKCE,exchangeRefreshToken) to accept and pass context.
| // OAuth authentication fields | ||
| AuthType string `gorm:"type:varchar(20);default:'headers'" json:"auth_type"` // "none", "headers", "oauth" | ||
| 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 |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Check OauthConfigID/oauth_config_id usage patterns
rg -n "OauthConfigID|oauth_config_id" --type=go -C3 | head -100Repository: maximhq/bifrost
Length of output: 6618
🏁 Script executed:
# Find and examine the oauth_configs table definition
fd -e go -path "*configstore/tables*" | xargs grep -l "TableOauthConfig\|type TableOauthConfig"Repository: maximhq/bifrost
Length of output: 229
🏁 Script executed:
# Look for any comments or documentation about the relationship
rg -n "OauthConfig|oauth_config" --type=go framework/configstore/tables/ | head -50Repository: maximhq/bifrost
Length of output: 881
🏁 Script executed:
# Check if there are other FK patterns in the same file for comparison
cat -n framework/configstore/tables/mcp.go | head -60Repository: maximhq/bifrost
Length of output: 4099
Reconsider the CASCADE delete constraint on the OAuth config relationship.
The OnDelete:CASCADE on OauthConfigID will delete all TableMCPClient rows when their referenced oauth_config is deleted. Since OauthConfigID is nullable and multiple MCP clients can reference the same OAuth config, this cascade will unintentionally destroy MCP client configurations whenever an OAuth config is removed.
Use OnDelete:SET NULL to null out the reference instead, or OnDelete:RESTRICT to prevent deletion if MCP clients depend on it. Also note the constraint is redundantly declared on both line 36 and the relationship on line 37.
🤖 Prompt for AI Agents
In `@framework/configstore/tables/mcp.go` around lines 34 - 37, The
OnDelete:CASCADE on the OauthConfig relationship will remove TableMCPClient rows
when an oauth_config is deleted; change the constraint on OauthConfigID and the
OauthConfig relationship to use OnDelete:SET NULL (or OnDelete:RESTRICT if you
prefer preventing deletion) so the foreign key is nulled (or deletion is
blocked) instead of cascading, and remove the redundant constraint declaration
so only the relationship tag (OauthConfig or OauthConfigID) carries the single
desired OnDelete behavior; update the struct fields AuthType, OauthConfigID, and
OauthConfig accordingly.
| token, err = p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) | ||
| if err != nil || token == nil { | ||
| return "", fmt.Errorf("failed to reload token after refresh: %w", err) | ||
| } |
There was a problem hiding this comment.
Improve error message when token is nil after refresh.
When token is nil but err is also nil, fmt.Errorf with %w on a nil error produces a confusing message ending with %!w(<nil>).
🔧 Suggested fix
token, err = p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID)
- if err != nil || token == nil {
- return "", fmt.Errorf("failed to reload token after refresh: %w", err)
+ if err != nil {
+ return "", fmt.Errorf("failed to reload token after refresh: %w", err)
+ }
+ if token == nil {
+ return "", fmt.Errorf("token not found after refresh")
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| token, err = p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) | |
| if err != nil || token == nil { | |
| return "", fmt.Errorf("failed to reload token after refresh: %w", err) | |
| } | |
| token, err = p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) | |
| if err != nil { | |
| return "", fmt.Errorf("failed to reload token after refresh: %w", err) | |
| } | |
| if token == nil { | |
| return "", fmt.Errorf("token not found after refresh") | |
| } |
🤖 Prompt for AI Agents
In `@framework/oauth/main.go` around lines 92 - 95, The error path that handles
the result of p.configStore.GetOauthTokenByID (checking token and err) currently
returns fmt.Errorf("failed to reload token after refresh: %w", err) even when
err is nil, producing a confusing "%!w(<nil>)"; change the logic in the block
that reads token, err = p.configStore.GetOauthTokenByID(ctx,
*oauthConfig.TokenID) so that if err != nil you return fmt.Errorf("failed to
reload token after refresh: %w", err) but if err == nil and token == nil you
return a clear error like fmt.Errorf("failed to reload token after refresh:
token is nil (no error returned from GetOauthTokenByID)"); update the
surrounding function (the caller that uses oauthConfig.TokenID) accordingly.
| if err != nil { | ||
| oauthConfig.Status = "failed" | ||
| p.configStore.UpdateOauthConfig(ctx, oauthConfig) | ||
| logger.Error("Token exchange failed", | ||
| "error", err.Error(), | ||
| "client_id", oauthConfig.ClientID, | ||
| "token_url", oauthConfig.TokenURL) | ||
| return fmt.Errorf("token exchange failed: %w", err) | ||
| } |
There was a problem hiding this comment.
Error from UpdateOauthConfig on failure is also ignored.
Similar to the expiry case, when token exchange fails, the config status update error is not checked.
🔧 Suggested fix
if err != nil {
oauthConfig.Status = "failed"
- p.configStore.UpdateOauthConfig(ctx, oauthConfig)
+ if updateErr := p.configStore.UpdateOauthConfig(ctx, oauthConfig); updateErr != nil {
+ logger.Error("Failed to update oauth config status", "error", updateErr, "oauth_config_id", oauthConfig.ID)
+ }
logger.Error("Token exchange failed",🤖 Prompt for AI Agents
In `@framework/oauth/main.go` around lines 465 - 473, When token exchange fails in
the block handling err != nil, call p.configStore.UpdateOauthConfig(ctx,
oauthConfig) and check its returned error; if UpdateOauthConfig returns an
error, log it (e.g., with logger.Error including the update error and context
like oauthConfig.ClientID and oauthConfig.TokenURL) and consider returning a
combined or wrapped error so the caller knows both the token-exchange failure
and the config-update failure; update the handling around oauthConfig,
p.configStore.UpdateOauthConfig, and logger.Error accordingly.
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
framework/configstore/rdb.go (1)
838-924: IncludeAuthTypeandOauthConfigIDin the update operations.
AuthTypeandOauthConfigIDare persisted duringCreateMCPClientConfigbut are missing from theUpdateMCPClientConfigupdates map. This prevents auth configuration changes from being persisted. Since these fields lack the read-only constraints documented for connection info, they should be included in updates.Proposed fix
updates := map[string]interface{}{ "name": clientConfigCopy.Name, "is_code_mode_client": clientConfigCopy.IsCodeModeClient, + "auth_type": clientConfigCopy.AuthType, + "oauth_config_id": clientConfigCopy.OauthConfigID, "tools_to_execute_json": string(toolsToExecuteJSON), "tools_to_auto_execute_json": string(toolsToAutoExecuteJSON), "headers_json": string(headersJSON), "tool_pricing_json": string(toolPricingJSON), "updated_at": time.Now(), }
🤖 Fix all issues with AI agents
In `@framework/configstore/store.go`:
- Around line 186-198: Add implementations (stubs) for all ten new OAuth methods
on MockConfigStore so it satisfies the updated interface: implement
GetOauthConfigByID, GetOauthConfigByState, GetOauthConfigByTokenID,
CreateOauthConfig, UpdateOauthConfig, GetOauthTokenByID, GetExpiringOauthTokens,
CreateOauthToken, UpdateOauthToken, and DeleteOauthToken on the MockConfigStore
type used in transports/bifrost-http/lib/config_test.go; each method should
match the signatures shown (returning the appropriate (*tables.TableOauthConfig
or *tables.TableOauthToken, slice, error) types) and use simple mock behavior
(e.g., return nil/empty results or stored in-memory values and nil error)
consistent with existing mock methods so tests compile and run.
In `@framework/oauth/sync.go`:
- Around line 126-134: The setters SetRefreshInterval and SetLookAheadWindow on
TokenRefreshWorker mutate refreshInterval and lookAheadWindow without
synchronization while the run goroutine (spawned by Start) reads refreshInterval
when creating its ticker, causing a data race; either (A) add synchronization:
embed a sync.Mutex (or sync.RWMutex) in TokenRefreshWorker and lock/unlock
around reads/writes of refreshInterval and lookAheadWindow in
SetRefreshInterval, SetLookAheadWindow and inside run/Start when accessing these
fields (and recreate / stop/start the ticker under the lock when interval
changes), or (B) if these setters are strictly test-only, document that
SetRefreshInterval and SetLookAheadWindow must only be called before Start and
add a comment on TokenRefreshWorker/Start indicating they are not safe to call
while running.
♻️ Duplicate comments (5)
framework/configstore/tables/oauth.go (1)
11-30: Encryption for sensitive OAuth fields remains unaddressed.The sensitive fields (
ClientSecret,CodeVerifierinTableOauthConfig;AccessToken,RefreshTokeninTableOauthToken) are still stored in plaintext. This was flagged in a previous review.Also applies to: 48-58
framework/oauth/sync.go (1)
38-44:Stop()is not idempotent - double call will panic.This issue was flagged in a previous review. Calling
Stop()twice will panic due to closing an already-closed channel. Usesync.Onceto guard the close operation.framework/oauth/main.go (3)
102-145: Avoid refresh with empty refresh token and omit empty client_secret.
exchangeRefreshTokenalways sendsclient_secret, andRefreshAccessTokendoes not guard against missingrefresh_token. Some providers reject empty secrets and empty refresh tokens should short-circuit with a clear error.🔧 Suggested fix
func (p *OauthProvider) RefreshAccessToken(ctx context.Context, oauthConfigID string) error { @@ token, err := p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) if err != nil || token == nil { return fmt.Errorf("oauth token not found: %w", err) } + if token.RefreshToken == "" { + return fmt.Errorf("refresh token missing; re-authorize the OAuth flow") + } @@ func (p *OauthProvider) exchangeRefreshToken(tokenURL, clientID, clientSecret, refreshToken string) (*schemas.TokenExchangeResponse, error) { data := url.Values{} data.Set("grant_type", "refresh_token") data.Set("refresh_token", refreshToken) data.Set("client_id", clientID) - data.Set("client_secret", clientSecret) + if clientSecret != "" { + data.Set("client_secret", clientSecret) + } return p.callTokenEndpoint(tokenURL, data) }Also applies to: 556-563
442-468: Don’t ignore UpdateOauthConfig errors on expiry/failure.
The status update is currently fire-and-forget in both expiry and token-exchange failure paths.🔧 Suggested fix
if time.Now().After(oauthConfig.ExpiresAt) { oauthConfig.Status = "expired" - p.configStore.UpdateOauthConfig(ctx, oauthConfig) + if err := p.configStore.UpdateOauthConfig(ctx, oauthConfig); err != nil { + logger.Error("Failed to update expired oauth config status", "error", err, "oauth_config_id", oauthConfig.ID) + } return fmt.Errorf("oauth flow expired") } @@ if err != nil { oauthConfig.Status = "failed" - p.configStore.UpdateOauthConfig(ctx, oauthConfig) + if err := p.configStore.UpdateOauthConfig(ctx, oauthConfig); err != nil { + logger.Error("Failed to update failed oauth config status", "error", err, "oauth_config_id", oauthConfig.ID) + } logger.Error("Token exchange failed", "error", err.Error(), "client_id", oauthConfig.ClientID, "token_url", oauthConfig.TokenURL) return fmt.Errorf("token exchange failed: %w", err) }
509-522: Handle authorize URLs that already contain query parameters.
String concatenation with"?"breaks URLs that already have a query string.🔧 Suggested fix
func (p *OauthProvider) buildAuthorizeURLWithPKCE(authorizeURL, clientID, redirectURI, state, codeChallenge string, scopes []string) string { params := url.Values{} params.Set("response_type", "code") params.Set("client_id", clientID) params.Set("redirect_uri", redirectURI) params.Set("state", state) params.Set("code_challenge", codeChallenge) params.Set("code_challenge_method", "S256") // SHA-256 hashing if len(scopes) > 0 { params.Set("scope", strings.Join(scopes, " ")) } - return authorizeURL + "?" + params.Encode() + u, err := url.Parse(authorizeURL) + if err != nil { + return authorizeURL + "?" + params.Encode() + } + q := u.Query() + for k, vs := range params { + for _, v := range vs { + q.Add(k, v) + } + } + u.RawQuery = q.Encode() + return u.String() }#!/bin/bash # Check for existing URL-building helpers in the repo before adding new logic rg -n "buildAuthorizeURLWithPKCE|url\\.Parse|RawQuery|Query\\(\\)" --type go
🧹 Nitpick comments (2)
core/mcp/clientmanager.go (1)
113-157: Consider extracting shared logic betweenAddClientandAddClientInMemory.
AddClientInMemoryduplicates the entire body ofAddClient(validation, placeholder entry creation, unlock-connect-cleanup pattern). The only difference is thatAddClientwould persist to the database in the caller context. This duplication increases maintenance burden.Consider extracting the common logic into a private helper method:
♻️ Suggested refactor
+// addClientInternal contains the shared logic for adding MCP clients +func (m *MCPManager) addClientInternal(config schemas.MCPClientConfig) error { + if err := validateMCPClientConfig(&config); err != nil { + return fmt.Errorf("invalid MCP client configuration: %w", err) + } + + configCopy := config + m.mu.Lock() + + if _, ok := m.clientMap[config.ID]; ok { + m.mu.Unlock() + return fmt.Errorf("client %s already exists", config.Name) + } + + m.clientMap[config.ID] = &schemas.MCPClientState{ + Name: config.Name, + ExecutionConfig: config, + ToolMap: make(map[string]schemas.ChatTool), + } + m.mu.Unlock() + + if err := m.connectToMCPClient(configCopy); err != nil { + m.mu.Lock() + delete(m.clientMap, config.ID) + m.mu.Unlock() + return fmt.Errorf("failed to connect to MCP client %s: %w", config.Name, err) + } + + return nil +} + func (m *MCPManager) AddClient(config schemas.MCPClientConfig) error { - // ... current implementation + return m.addClientInternal(config) } func (m *MCPManager) AddClientInMemory(config schemas.MCPClientConfig) error { - // ... current implementation + return m.addClientInternal(config) }framework/oauth/main.go (1)
497-501: Preferbifrost.Ptrfor pointer creation.
Keeps pointer creation consistent across the codebase.♻️ Suggested tweak
- oauthConfig.TokenID = &tokenID + oauthConfig.TokenID = bifrost.Ptr(tokenID)Based on learnings, use
bifrost.Ptr(...)instead of&valuein this repo.
| // OAuth config CRUD | ||
| GetOauthConfigByID(ctx context.Context, id string) (*tables.TableOauthConfig, error) | ||
| GetOauthConfigByState(ctx context.Context, state string) (*tables.TableOauthConfig, error) | ||
| GetOauthConfigByTokenID(ctx context.Context, tokenID string) (*tables.TableOauthConfig, error) | ||
| CreateOauthConfig(ctx context.Context, config *tables.TableOauthConfig) error | ||
| UpdateOauthConfig(ctx context.Context, config *tables.TableOauthConfig) error | ||
|
|
||
| // OAuth token CRUD | ||
| GetOauthTokenByID(ctx context.Context, id string) (*tables.TableOauthToken, error) | ||
| GetExpiringOauthTokens(ctx context.Context, before time.Time) ([]*tables.TableOauthToken, error) | ||
| CreateOauthToken(ctx context.Context, token *tables.TableOauthToken) error | ||
| UpdateOauthToken(ctx context.Context, token *tables.TableOauthToken) error | ||
| DeleteOauthToken(ctx context.Context, id string) error |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Locate ConfigStore interface definition and implementations
echo "=== ConfigStore interface definition ==="
rg -n "type ConfigStore interface" --type go -A 50
echo -e "\n=== Compile-time assertions for ConfigStore ==="
rg -n "var _ .*ConfigStore" --type go
echo -e "\n=== ConfigStore implementations (type definitions) ==="
rg -n "type.*ConfigStore.*struct" --type go
echo -e "\n=== Files containing ConfigStore ==="
rg -l "ConfigStore" --type go | head -20Repository: maximhq/bifrost
Length of output: 5654
🏁 Script executed:
#!/bin/bash
# Get the full ConfigStore interface definition
echo "=== Full ConfigStore interface ==="
rg -n "type ConfigStore interface" --type go -A 200
echo -e "\n\n=== RDBConfigStore struct and methods ==="
cat -n framework/configstore/rdb.go | head -100
echo -e "\n\n=== OAuth methods in RDBConfigStore ==="
rg -n "func.*RDBConfigStore.*Oauth" --type go -i
echo -e "\n\n=== MockConfigStore struct ==="
sed -n '350,500p' transports/bifrost-http/lib/config_test.go | cat -nRepository: maximhq/bifrost
Length of output: 30580
🏁 Script executed:
#!/bin/bash
# Get the full MockConfigStore implementation to check OAuth methods
echo "=== Full MockConfigStore implementation ==="
rg -n "type MockConfigStore" --type go -A 500 transports/bifrost-http/lib/config_test.go | head -600
echo -e "\n\n=== Check for OAuth methods in MockConfigStore ==="
rg -n "func.*MockConfigStore.*Oauth" --type go -i transports/bifrost-http/lib/config_test.go
echo -e "\n\n=== Check file line count ==="
wc -l transports/bifrost-http/lib/config_test.go
echo -e "\n\n=== Get all method signatures in MockConfigStore ==="
rg -n "func \(m \*MockConfigStore\)" --type go transports/bifrost-http/lib/config_test.go | wc -l
rg -n "func \(m \*MockConfigStore\)" --type go transports/bifrost-http/lib/config_test.goRepository: maximhq/bifrost
Length of output: 27975
🏁 Script executed:
#!/bin/bash
# Check for any other ConfigStore implementations or mocks
echo "=== Search for all type assertions with ConfigStore ==="
rg -n "func.*ConfigStore\)" --type go | grep -v "func (m \*MockConfigStore)\|func (s \*RDBConfigStore)" | head -20
echo -e "\n=== Search for other potential ConfigStore structs ==="
rg -n "ConfigStore.*struct" --type go
echo -e "\n=== Check if there are compile errors using grep for build/test files ==="
find . -name "*.go" -type f -exec grep -l "var _.*ConfigStore" {} \; 2>/dev/null | head -5Repository: maximhq/bifrost
Length of output: 1129
Add all 10 OAuth methods to MockConfigStore.
The new OAuth interface methods require MockConfigStore in transports/bifrost-http/lib/config_test.go to implement all missing methods: GetOauthConfigByID, GetOauthConfigByState, GetOauthConfigByTokenID, CreateOauthConfig, UpdateOauthConfig, GetOauthTokenByID, GetExpiringOauthTokens, CreateOauthToken, UpdateOauthToken, and DeleteOauthToken. RDBConfigStore is already complete; only the test mock needs updating.
🤖 Prompt for AI Agents
In `@framework/configstore/store.go` around lines 186 - 198, Add implementations
(stubs) for all ten new OAuth methods on MockConfigStore so it satisfies the
updated interface: implement GetOauthConfigByID, GetOauthConfigByState,
GetOauthConfigByTokenID, CreateOauthConfig, UpdateOauthConfig,
GetOauthTokenByID, GetExpiringOauthTokens, CreateOauthToken, UpdateOauthToken,
and DeleteOauthToken on the MockConfigStore type used in
transports/bifrost-http/lib/config_test.go; each method should match the
signatures shown (returning the appropriate (*tables.TableOauthConfig or
*tables.TableOauthToken, slice, error) types) and use simple mock behavior
(e.g., return nil/empty results or stored in-memory values and nil error)
consistent with existing mock methods so tests compile and run.
| // SetRefreshInterval updates the refresh check interval (for testing) | ||
| func (w *TokenRefreshWorker) SetRefreshInterval(interval time.Duration) { | ||
| w.refreshInterval = interval | ||
| } | ||
|
|
||
| // SetLookAheadWindow updates the look-ahead window for token expiry (for testing) | ||
| func (w *TokenRefreshWorker) SetLookAheadWindow(window time.Duration) { | ||
| w.lookAheadWindow = window | ||
| } |
There was a problem hiding this comment.
Race condition: setters modify fields while worker may be running.
SetRefreshInterval and SetLookAheadWindow modify struct fields without synchronization, while the run goroutine reads w.refreshInterval when creating the ticker. If these setters are called after Start(), there's a data race.
If these are only for testing before starting the worker, add documentation. Otherwise, consider:
- Adding a mutex to protect these fields
- Making the worker re-read the interval on each tick
🔧 Suggested documentation fix (if test-only)
// SetRefreshInterval updates the refresh check interval (for testing)
+// Must be called before Start() to avoid data races.
func (w *TokenRefreshWorker) SetRefreshInterval(interval time.Duration) {
w.refreshInterval = interval
}
// SetLookAheadWindow updates the look-ahead window for token expiry (for testing)
+// Must be called before Start() to avoid data races.
func (w *TokenRefreshWorker) SetLookAheadWindow(window time.Duration) {
w.lookAheadWindow = window
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // SetRefreshInterval updates the refresh check interval (for testing) | |
| func (w *TokenRefreshWorker) SetRefreshInterval(interval time.Duration) { | |
| w.refreshInterval = interval | |
| } | |
| // SetLookAheadWindow updates the look-ahead window for token expiry (for testing) | |
| func (w *TokenRefreshWorker) SetLookAheadWindow(window time.Duration) { | |
| w.lookAheadWindow = window | |
| } | |
| // SetRefreshInterval updates the refresh check interval (for testing) | |
| // Must be called before Start() to avoid data races. | |
| func (w *TokenRefreshWorker) SetRefreshInterval(interval time.Duration) { | |
| w.refreshInterval = interval | |
| } | |
| // SetLookAheadWindow updates the look-ahead window for token expiry (for testing) | |
| // Must be called before Start() to avoid data races. | |
| func (w *TokenRefreshWorker) SetLookAheadWindow(window time.Duration) { | |
| w.lookAheadWindow = window | |
| } |
🤖 Prompt for AI Agents
In `@framework/oauth/sync.go` around lines 126 - 134, The setters
SetRefreshInterval and SetLookAheadWindow on TokenRefreshWorker mutate
refreshInterval and lookAheadWindow without synchronization while the run
goroutine (spawned by Start) reads refreshInterval when creating its ticker,
causing a data race; either (A) add synchronization: embed a sync.Mutex (or
sync.RWMutex) in TokenRefreshWorker and lock/unlock around reads/writes of
refreshInterval and lookAheadWindow in SetRefreshInterval, SetLookAheadWindow
and inside run/Start when accessing these fields (and recreate / stop/start the
ticker under the lock when interval changes), or (B) if these setters are
strictly test-only, document that SetRefreshInterval and SetLookAheadWindow must
only be called before Start and add a comment on TokenRefreshWorker/Start
indicating they are not safe to call while running.
8256d98 to
ddbaed6
Compare
There was a problem hiding this comment.
Actionable comments posted: 9
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
transports/bifrost-http/server/server.go (1)
796-812: OAuth integration has a nil pointer dereference risk.The routes are correctly registered, but
NewOAuthHandlerdoesn't guard against a nilOAuthProvider. Multiple handler methods (RevokeToken,StorePendingMCPClient,GetPendingMCPClient,RemovePendingMCPClient) directly dereferenceh.oauthProviderwithout nil checks, which will cause a panic if OAuth is not configured and these endpoints are called.Consider either:
- Adding nil checks in handler methods before using
oauthProvider- Conditionally registering OAuth routes only if
oauthProvideris not nil- Initializing a default/no-op provider when OAuth is not configured
ui/app/workspace/mcp-gateway/views/mcpClientForm.tsx (1)
124-135: Gate header validation and payload byauth_type.
If the user switches from headers to OAuth/None, hidden headers can still fail validation or be sent in the payload. Validate and include headers only whenauth_type === "headers".🔧 Suggested fix
- if ((form.connection_type === "http" || form.connection_type === "sse") && form.headers) { + if ( + (form.connection_type === "http" || form.connection_type === "sse") && + form.auth_type === "headers" && + form.headers + ) { // Ensure all EnvVar values have either a value or env_var for (const [key, envVar] of Object.entries(form.headers)) { if (!envVar.value && !envVar.env_var) { return `Header "${key}" must have a value`; } } }- headers: form.headers && Object.keys(form.headers).length > 0 ? form.headers : undefined, + headers: + form.auth_type === "headers" && form.headers && Object.keys(form.headers).length > 0 + ? form.headers + : undefined,Also applies to: 239-239, 362-374
🤖 Fix all issues with AI agents
In `@core/schemas/oauth.go`:
- Around line 37-46: Remove JSON serialization exposure for sensitive fields in
the OAuth2Token struct by changing the struct tags on AccessToken and
RefreshToken to omit them from JSON (e.g., json:"-") or replace with a redacted
DTO; update the OAuth2Token definition (symbols: OAuth2Token, AccessToken,
RefreshToken) to ensure these fields are not marshaled, and if you opt for a
redacted DTO, add a conversion helper that returns a redacted view for responses
consistent with existing patterns like GetProviderConfigRedacted.
In `@docs/openapi/bundle.py`:
- Around line 435-454: When args.inline is true the code writes both input_path
(the YAML) and output_path (the JSON) which can clobber the just-updated input
if args.output equals args.input; modify the block handling args.inline in
bundle.py to compare output_path.resolve() and input_path.resolve() and guard
against collision by skipping the JSON write (or aborting with a clear error
message) when they are the same, and log/print a notice that the JSON write was
skipped or that the user must pick a different --output; keep the YAML inlining
behavior for input_path and only proceed to open output_path for json.dump when
the paths differ.
In `@docs/openapi/openapi.json`:
- Around line 167229-167232: The documentation for the OAuthConfigRequest schema
references an undefined `server_url` in the `client_id` description; either add
a `server_url` property to the OAuthConfigRequest schema or update the text to
reference the actual property used for discovery (for example
`connection_string`); locate the OAuthConfigRequest schema (the block containing
`client_id`) and modify the description to reference the correct property name
or add a new `server_url` field with its type and description to keep the docs
accurate and consistent.
- Around line 129198-129283: The 404 response for the
/api/oauth/config/{id}/status endpoint has an invalid nested schema structure
(schema → content → application/json → schema); update the response to reference
the existing reusable NotFound response/component instead of embedding a content
key inside schema—locate the 404 response under the path
/api/oauth/config/{id}/status and replace the current object with a $ref to the
NotFound response component (e.g., "#/components/responses/NotFound") so the
OpenAPI 3.0 document is valid.
- Around line 127847-127853: The discriminator mapping under the schema that
uses "propertyName": "connection_type" currently points to invalid references
like "#/MCPClientCreateRequestHTTP" and to non-existent schemas
(MCPClientCreateRequestHTTP, MCPClientCreateRequestSSE,
MCPClientCreateRequestSTDIO); fix it by updating each mapping value to a valid
OpenAPI JSON Pointer (e.g. "#/components/schemas/MCPClientCreateRequest") or, if
separate variant schemas are intended, add properly named schemas
(MCPClientCreateRequestHTTP, MCPClientCreateRequestSSE,
MCPClientCreateRequestSTDIO) under components/schemas and change the mapping
values to "#/components/schemas/<SchemaName>"; ensure this change is applied for
both discriminator blocks that reference connection_type so tooling can resolve
them correctly.
In `@docs/openapi/paths/management/oauth.yaml`:
- Around line 18-35: The OpenAPI spec currently marks the query parameter "code"
as required which will fail validation when providers return "error" and
"error_description" without a code; update the parameter definitions in the
management/oauth.yaml to make "code" optional (set required: false) or replace
the parameters with a oneOf/anyOf schema that allows either { error +
error_description } or { code } so the handler can process OAuth error
redirects; adjust the parameters named "code", "error", and "error_description"
accordingly.
- Around line 74-81: The 404 response is incorrectly using a schema $ref that
points to a response object; replace the nested schema block under the '404'
response with a response-level $ref to
../../openapi.yaml#/components/responses/NotFound so it matches the 500 style
(i.e., remove the content/schema wrapper and set '404': $ref:
'../../openapi.yaml#/components/responses/NotFound').
In `@framework/oauth2/init.go`:
- Around line 5-8: The package-level logger (variable logger set via SetLogger
in NewOAuth2Provider) is used without nil checks in main.go and discovery.go;
update all places that call logger methods in those files to guard with if
logger != nil (or similarly check before using logger) consistent with sync.go's
pattern (e.g., wrap calls in if logger != nil { logger.Info/... } ), ensuring
every logger usage is protected to avoid nil derefs while preserving existing
log messages and call sites.
In `@transports/bifrost-http/handlers/oauth2.go`:
- Around line 61-63: The handler is calling provider/store methods with
context.Background() which loses request cancellation/tracing; update all calls
like h.oauthProvider.CompleteOAuthFlow(...) (and similar calls in the same
file/ranges) to pass the request context variable (the *fasthttp.RequestCtx
named ctx) instead of context.Background(); ensure every place that currently
uses context.Background() for oauth/provider/store operations uses ctx so
request cancellation/tracing is preserved (e.g., replace context.Background()
with ctx in calls to h.oauthProvider.CompleteOAuthFlow and the other
provider/store methods referenced in the file).
♻️ Duplicate comments (16)
framework/configstore/tables/mcp.go (1)
34-37: Avoid CASCADE delete for shared OAuth configs.
With a nullable FK,OnDelete:CASCADEcan wipe MCP clients if a shared OAuth config is deleted. PreferSET NULL(orRESTRICT) and keep the constraint in a single place to avoid duplicate tags.🛠️ Suggested adjustment
- AuthType string `gorm:"type:varchar(20);default:'headers'" json:"auth_type"` // "none", "headers", "oauth" - 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 + AuthType string `gorm:"type:varchar(20);default:'headers'" json:"auth_type"` // "none", "headers", "oauth" + OauthConfigID *string `gorm:"type:varchar(255);index" json:"oauth_config_id"` // Foreign key to oauth_configs.ID + OauthConfig *TableOauthConfig `gorm:"foreignKey:OauthConfigID;references:ID;constraint:OnDelete:SET NULL" json:"-"` // Gorm relationshipGORM foreign key constraint tags: OnDelete:SET NULL vs CASCADE for nullable relationships, and whether to define the constraint on the field vs. relation.framework/configstore/migrations.go (1)
3021-3026: Nullable column constraint issue remains unaddressed.The comment at lines 3021-3024 states that AutoMigrate will handle making
authorize_urlandtoken_urlnullable, buttriggerMigrationsdoes not call AutoMigrate. This was flagged in a previous review. Discovery-only configs will fail on upgrade if these columns had NOT NULL constraints in earlier schema versions.framework/configstore/tables/oauth.go (1)
11-30: Critical: OAuth sensitive fields are stored in plaintext.Comments on lines 14, 21 claim fields are "Encrypted" but no encryption is implemented. The
BeforeSavehook only sets defaults.ClientSecret,CodeVerifier,AccessToken, andRefreshTokenwill be stored in plaintext, contradicting the documentation comments.framework/configstore/store.go (1)
42-46: Ensure all ConfigStore implementations/mocks include the new methods.
The interface now requiresGetMCPClientByIDand all OAuth config/token CRUD methods. Any missing implementation (especially mocks) will fail builds/tests.#!/bin/bash # Verify MockConfigStore (and other implementations) define all new methods. set -euo pipefail methods=( GetMCPClientByID GetOauthConfigByID GetOauthConfigByState GetOauthConfigByTokenID CreateOauthConfig UpdateOauthConfig GetOauthTokenByID GetExpiringOauthTokens CreateOauthToken UpdateOauthToken DeleteOauthToken ) echo "=== MockConfigStore checks ===" for m in "${methods[@]}"; do if ! rg -n "func \\(m \\*MockConfigStore\\) ${m}\\b" transports/bifrost-http/lib/config_test.go; then echo "Missing method on MockConfigStore: ${m}" fi done echo "=== Other ConfigStore implementations ===" rg -n "type .*ConfigStore struct" --type go for m in "${methods[@]}"; do rg -n "func \\(.*\\*.*ConfigStore\\) ${m}\\b" --type go || true doneAlso applies to: 186-198
transports/bifrost-http/lib/config.go (1)
1559-1564: Guard OAuth provider initialization when ConfigStore is disabled.
oauth2.NewOAuth2Providerand the refresh worker are started even whenconfig.ConfigStoreis nil, which can cause nil derefs inside the worker/provider. Initialize only when the store is available (apply in both init paths).🔧 Suggested fix
- // Initialize OAuth provider - config.OAuthProvider = oauth2.NewOAuth2Provider(config.ConfigStore, logger) - - // Start token refresh worker for automatic OAuth token refresh - config.TokenRefreshWorker = oauth2.NewTokenRefreshWorker(config.OAuthProvider, logger) - config.TokenRefreshWorker.Start(ctx) + if config.ConfigStore != nil { + // Initialize OAuth provider + config.OAuthProvider = oauth2.NewOAuth2Provider(config.ConfigStore, logger) + + // Start token refresh worker for automatic OAuth token refresh + config.TokenRefreshWorker = oauth2.NewTokenRefreshWorker(config.OAuthProvider, logger) + config.TokenRefreshWorker.Start(ctx) + } else { + logger.Warn("config store disabled; OAuth provider not initialized") + }Also applies to: 1919-1924
ui/app/workspace/mcp-gateway/views/oauth2Authorizer.tsx (1)
142-151: Validate postMessage origin/source before acting.
The handler currently trusts anyoauth_successmessage, which allows any window/frame to spoof completion. Validateevent.originandevent.sourceagainst the popup.🔒 Suggested fix
const handleMessage = (event: MessageEvent) => { - // Verify message is from OAuth callback - if (event.data?.type === "oauth_success") { + // Verify message origin/source before acting + if (event.origin !== window.location.origin) return; + if (popupRef.current && event.source !== popupRef.current) return; + if (event.data?.type === "oauth_success") { // OAuth succeeded, stop polling and check status immediately stopPolling(); // Trigger immediate status check checkOAuthStatus(); } };framework/oauth2/sync.go (2)
11-17: MakeStop()idempotent to prevent panics on multiple calls.
Closing a channel twice panics; guard withsync.Once.🔧 Suggested fix
import ( "context" + "sync" "time" "github.com/maximhq/bifrost/core/schemas" ) @@ type TokenRefreshWorker struct { provider *OAuth2Provider refreshInterval time.Duration lookAheadWindow time.Duration // How far ahead to look for expiring tokens stopCh chan struct{} + stopOnce sync.Once logger schemas.Logger } @@ func (w *TokenRefreshWorker) Stop() { - close(w.stopCh) + w.stopOnce.Do(func() { close(w.stopCh) }) if w.logger != nil { w.logger.Info("Token refresh worker stopped") } }Also applies to: 38-43
126-133: Setter methods can race with the running worker.
SetRefreshIntervalandSetLookAheadWindowmutate fields read by the goroutine; either guard with a mutex or document that they must be called beforeStart().✍️ Minimal documentation fix (if test-only)
-// SetRefreshInterval updates the refresh check interval (for testing) +// SetRefreshInterval updates the refresh check interval (for testing). +// Must be called before Start() to avoid data races. func (w *TokenRefreshWorker) SetRefreshInterval(interval time.Duration) { w.refreshInterval = interval } -// SetLookAheadWindow updates the look-ahead window for token expiry (for testing) +// SetLookAheadWindow updates the look-ahead window for token expiry (for testing). +// Must be called before Start() to avoid data races. func (w *TokenRefreshWorker) SetLookAheadWindow(window time.Duration) { w.lookAheadWindow = window }transports/bifrost-http/handlers/oauth2.go (1)
110-137: Escape OAuth error strings before injecting into HTML/JS.
erroranderror_descriptionare user-controlled; rendering them raw enables reflected XSS in the popup.🔒 Suggested fix
import ( "context" "fmt" + "html" + "html/template" @@ errorMsg := errorParam if errorDescription != "" { errorMsg = fmt.Sprintf("%s: %s", errorParam, errorDescription) } + escapedHTML := html.EscapeString(errorMsg) + escapedJS := template.JSEscapeString(errorMsg) ctx.SetBodyString(fmt.Sprintf(` @@ - window.opener.postMessage({ type: 'oauth_failed', error: '%s' }, '*'); + window.opener.postMessage({ type: 'oauth_failed', error: '%s' }, '*'); window.close(); } </script> @@ - <p>%s</p> + <p>%s</p> <p style="color: `#666`;">You can close this window.</p> </div> </div> @@ - `, errorMsg, errorMsg)) + `, escapedJS, escapedHTML))transports/bifrost-http/handlers/mcp.go (1)
220-225: GuardConnectionStringbefore callingGetValue().If
connection_stringis omitted,req.ConnectionStringis nil and callingGetValue()will panic.🔧 Suggested fix
if req.OauthConfig.ClientID == "" { // If no client_id, we need server URL for discovery - if req.ConnectionString.GetValue() == "" { + if req.ConnectionString == nil || req.ConnectionString.GetValue() == "" { SendError(ctx, fasthttp.StatusBadRequest, "Either client_id must be provided, or server URL must be set for OAuth discovery and dynamic client registration") return }framework/oauth2/main.go (5)
92-95: Avoid wrapping a nil error when token reload returns nil.
fmt.Errorf(... %w, err)produces a confusing%!w(<nil>)whenerris nil.🔧 Suggested fix
- token, err = p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) - if err != nil || token == nil { - return "", fmt.Errorf("failed to reload token after refresh: %w", err) - } + token, err = p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) + if err != nil { + return "", fmt.Errorf("failed to reload token after refresh: %w", err) + } + if token == nil { + return "", fmt.Errorf("token not found after refresh") + }
117-129: Guard missing refresh tokens and don’t send empty client_secret.Refreshing with an empty refresh token or sending an empty client_secret will cause failures with many OAuth servers, especially for public clients.
🔧 Suggested fix
// Load oauth_token token, err := p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) if err != nil || token == nil { return fmt.Errorf("oauth token not found: %w", err) } + if token.RefreshToken == "" { + return fmt.Errorf("refresh token missing; re-authorize the OAuth flow") + } // Call OAuth provider's token endpoint with refresh_token newTokenResponse, err := p.exchangeRefreshToken( oauthConfig.TokenURL, oauthConfig.ClientID, oauthConfig.ClientSecret, token.RefreshToken, )data.Set("grant_type", "refresh_token") data.Set("refresh_token", refreshToken) data.Set("client_id", clientID) - data.Set("client_secret", clientSecret) + if clientSecret != "" { + data.Set("client_secret", clientSecret) + }Also applies to: 556-563
442-447: Handle UpdateOauthConfig errors in expiry/failure paths.Those updates are currently dropped on the floor, which can leave the DB in an inconsistent state.
🔧 Suggested fix
if time.Now().After(oauthConfig.ExpiresAt) { oauthConfig.Status = "expired" - p.configStore.UpdateOauthConfig(ctx, oauthConfig) + if err := p.configStore.UpdateOauthConfig(ctx, oauthConfig); err != nil { + logger.Error("Failed to update expired oauth config status", "error", err, "oauth_config_id", oauthConfig.ID) + } return fmt.Errorf("oauth flow expired") } // Exchange code for tokens with PKCE verifier tokenResponse, err := p.exchangeCodeForTokensWithPKCE( ... ) if err != nil { oauthConfig.Status = "failed" - p.configStore.UpdateOauthConfig(ctx, oauthConfig) + if updateErr := p.configStore.UpdateOauthConfig(ctx, oauthConfig); updateErr != nil { + logger.Error("Failed to update oauth config status", "error", updateErr, "oauth_config_id", oauthConfig.ID) + } logger.Error("Token exchange failed", "error", err.Error(), "client_id", oauthConfig.ClientID, "token_url", oauthConfig.TokenURL) return fmt.Errorf("token exchange failed: %w", err) }Also applies to: 465-468
480-480: Don’t ignore json.Marshal errors for scopes.🔧 Suggested fix
- scopesJSON, _ := json.Marshal(scopes) + scopesJSON, err := json.Marshal(scopes) + if err != nil { + return fmt.Errorf("failed to serialize token scopes: %w", err) + }
509-523: Preserve existing query parameters when building authorize URLs.Simple concatenation breaks URLs that already contain a query string.
🔧 Suggested fix
- return authorizeURL + "?" + params.Encode() + u, err := url.Parse(authorizeURL) + if err != nil { + return authorizeURL + "?" + params.Encode() + } + q := u.Query() + for k, vs := range params { + q[k] = vs + } + u.RawQuery = q.Encode() + return u.String()framework/oauth2/discovery.go (1)
53-134: Guard logger usage to prevent nil dereference in discovery/registration.The comment says the logger can be nil, but many logger calls are unconditional. This will panic when discovery/registration is invoked before
SetLogger().🔧 Suggested pattern (apply across logger calls)
- logger.Debug(fmt.Sprintf("[OAuth Discovery] Server responded with status: %d", resp.StatusCode)) + if logger != nil { + logger.Debug(fmt.Sprintf("[OAuth Discovery] Server responded with status: %d", resp.StatusCode)) + }Also applies to: 216-229, 235-243, 274-299, 352-352, 398-451
🧹 Nitpick comments (8)
docs/mcp/connecting-to-servers.mdx (1)
76-115: Clarify optional OAuth fields for PKCE/discovery (if supported).Docs currently imply
client_secret,authorize_url, andtoken_urlare always required. If public clients (PKCE) or discovery allow omitting any of these, it’d be helpful to call that out, and note that dynamic registration/discovery depend on server support.docs/openapi/schemas/management/oauth.yaml (1)
3-11: Avoid duplicateMCPAuthTypedefinitions across schema files.
MCPAuthTypeis also defined indocs/openapi/schemas/management/mcp.yaml. Consider defining it once and referencing to prevent drift.core/schemas/mcp.go (1)
117-147: Consider auto‑refresh on expired tokens inHttpHeaders.
IfGetAccessTokenreturnsErrOAuth2TokenExpired, the call currently fails without attempting a refresh, even though a refresh API exists. A small retry keeps OAuth clients resilient.♻️ Suggested adjustment
- accessToken, err := oauth2Provider.GetAccessToken(ctx, *c.OauthConfigID) - if err != nil { - return nil, err - } + accessToken, err := oauth2Provider.GetAccessToken(ctx, *c.OauthConfigID) + if err != nil { + if errors.Is(err, ErrOAuth2TokenExpired) { + if refreshErr := oauth2Provider.RefreshAccessToken(ctx, *c.OauthConfigID); refreshErr != nil { + return nil, refreshErr + } + accessToken, err = oauth2Provider.GetAccessToken(ctx, *c.OauthConfigID) + } + if err != nil { + return nil, err + } + }docs/openapi/paths/management/oauth.yaml (1)
83-106: DELETE endpoint missing 404 response.The DELETE operation for revoking an OAuth config should include a 404 response for cases where the config ID doesn't exist, similar to the GET operation.
🛠️ Suggested fix
responses: '200': description: OAuth token revoked successfully content: application/json: schema: $ref: '../../schemas/management/common.yaml#/SuccessResponse' + '404': + $ref: '../../openapi.yaml#/components/responses/NotFound' '500': $ref: '../../openapi.yaml#/components/responses/InternalError'transports/bifrost-http/handlers/oauth2.go (1)
207-210: Preferbifrost.Ptrover&for pointer creation.
Keeps pointer construction consistent with repo conventions. Based on learnings, usebifrost.Ptrhere.♻️ Suggested tweak
var registrationURL *string if req.RegistrationURL != "" { - registrationURL = &req.RegistrationURL + registrationURL = bifrost.Ptr(req.RegistrationURL) }transports/bifrost-http/handlers/mcp.go (1)
232-237: Verify Host header origin in production deployments.The redirect URI is constructed from
ctx.Host(), which reflects the incoming request'sHostheader. In deployments behind a reverse proxy, ensure the proxy setsX-Forwarded-Hostor that Bifrost is configured to trust only specific hosts. Otherwise, an attacker-controlledHostheader could redirect the OAuth callback to an unintended destination.docs/openapi/openapi.json (1)
166707-166739: Deduplicate OAuth config schema via$refto prevent drift.Line 166707-166739 defines an inline
oauth_configobject while Line 167225-167257 introducesOAuthConfigRequest. Reuse the component to avoid divergence across the stack.♻️ Suggested refactor
- "oauth_config": { - "type": "object", - "description": "OAuth configuration for initiating OAuth flow.\nOnly include this when creating a client with auth_type \"oauth\".\nThis will trigger the OAuth flow and return an authorization URL.\n", - "properties": { - "client_id": { ... }, - "client_secret": { ... }, - "authorize_url": { ... }, - "token_url": { ... }, - "registration_url": { ... }, - "scopes": { ... } - } - }, + "oauth_config": { + "allOf": [ + { "$ref": "#/components/schemas/OAuthConfigRequest" } + ], + "description": "OAuth configuration for initiating OAuth flow.\nOnly include this when creating a client with auth_type \"oauth\".\nThis will trigger the OAuth flow and return an authorization URL.\n" + },Also applies to: 167225-167257
framework/oauth2/main.go (1)
498-499: Preferbifrost.Ptrfor pointer construction.Based on learnings, prefer `bifrost.Ptr(...)` for pointer creation.♻️ Suggested tweak
- oauthConfig.TokenID = &tokenID + oauthConfig.TokenID = bifrost.Ptr(tokenID)
| // OauthToken represents OAuth access and refresh tokens | ||
| type OAuth2Token struct { | ||
| ID string `json:"id"` | ||
| AccessToken string `json:"access_token"` | ||
| RefreshToken string `json:"refresh_token"` | ||
| TokenType string `json:"token_type"` | ||
| ExpiresAt time.Time `json:"expires_at"` | ||
| Scopes []string `json:"scopes"` | ||
| LastRefreshedAt *time.Time `json:"last_refreshed_at,omitempty"` | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Find all usages of OAuth2Token
rg -n --type=go 'OAuth2Token' -A 3 -B 1Repository: maximhq/bifrost
Length of output: 3966
🏁 Script executed:
# Look for API handlers or endpoint definitions that might return OAuth2Token
rg -n --type=go '(handler|endpoint|Handler|route|Router|GET|POST|PUT|DELETE).*OAuth|OAuth.*\(.*\w+.*http' -C 2Repository: maximhq/bifrost
Length of output: 7613
🏁 Script executed:
# Search for marshaling/serialization of OAuth2Token
rg -n --type=go 'json\.(Marshal|Unmarshal|Encoder|Decoder).*OAuth|OAuth.*json\.(Marshal|Unmarshal)' -C 2Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Check if there's a redacted/sanitized DTO already defined
rg -n --type=go 'type.*OAuth.*DTO|type.*Redacted.*OAuth|func.*Redacted|sanitize.*OAuth' -C 3Repository: maximhq/bifrost
Length of output: 9012
🏁 Script executed:
# Look at the repository structure to understand PR context
git log --oneline -20 2>/dev/null | head -20 || echo "No git history available"Repository: maximhq/bifrost
Length of output: 98
🏁 Script executed:
# Check getOAuthConfigStatus implementation
rg -n --type=go -A 20 'func.*getOAuthConfigStatus' transports/bifrost-http/handlers/oauth2.goRepository: maximhq/bifrost
Length of output: 777
🏁 Script executed:
# Check completeMCPClientOAuth implementation
rg -n --type=go -A 30 'func.*completeMCPClientOAuth' transports/bifrost-http/handlers/mcp.goRepository: maximhq/bifrost
Length of output: 1303
🏁 Script executed:
# Check if OAuth2Token is ever returned/serialized in HTTP responses
rg -n --type=go 'OAuth2Token.*json|json.*OAuth2Token|ctx\.JSON.*OAuth2Token|WriteJSON.*OAuth2Token' -C 2Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Check the git diff for the current PR to understand context
git diff HEAD~1 HEAD --stat 2>/dev/null | head -20 || git show --stat 2>/dev/null | head -20 || echo "No git info available"Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Get more of the completeMCPClientOAuth function to see what it returns
rg -n --type=go -A 60 'func.*completeMCPClientOAuth' transports/bifrost-http/handlers/mcp.go | tail -45Repository: maximhq/bifrost
Length of output: 1834
🏁 Script executed:
# Check if OAuth2Token is stored or retrieved anywhere that might be exposed
rg -n --type=go 'GetOauthConfigByID|CreateOauthConfig|UpdateOauthConfig' -A 5 -B 1Repository: maximhq/bifrost
Length of output: 9260
🏁 Script executed:
# Look at the OauthConfig table/struct to see what fields it has
rg -n --type=go 'type.*OauthConfig|type.*OAuth.*Config' -A 10 core/Repository: maximhq/bifrost
Length of output: 2540
🏁 Script executed:
# Check the full oauth.go file to understand all OAuth-related types
cat -n core/schemas/oauth.goRepository: maximhq/bifrost
Length of output: 4046
Remove JSON tags from OAuth2Token's sensitive fields.
While current endpoints don't expose this struct, the access_token and refresh_token JSON tags pose a latent risk if OAuth2Token is returned in future management endpoints. Consider changing these fields to json:"-" or use a redacted DTO. The codebase already has redaction patterns (e.g., GetProviderConfigRedacted); apply the same approach here for consistency and defense-in-depth.
🤖 Prompt for AI Agents
In `@core/schemas/oauth.go` around lines 37 - 46, Remove JSON serialization
exposure for sensitive fields in the OAuth2Token struct by changing the struct
tags on AccessToken and RefreshToken to omit them from JSON (e.g., json:"-") or
replace with a redacted DTO; update the OAuth2Token definition (symbols:
OAuth2Token, AccessToken, RefreshToken) to ensure these fields are not
marshaled, and if you opt for a redacted DTO, add a conversion helper that
returns a redacted view for responses consistent with existing patterns like
GetProviderConfigRedacted.
| # Handle inline replacement for Mintlify compatibility | ||
| if args.inline: | ||
| input_path = base_path / args.input | ||
| # When inlining, update the original YAML file | ||
| with open(input_path, "w", encoding="utf-8") as f: | ||
| yaml.dump( | ||
| spec, | ||
| f, | ||
| default_flow_style=False, | ||
| allow_unicode=True, | ||
| sort_keys=False, | ||
| ) | ||
| print(f"✓ Updated original file with resolved references: {input_path}") | ||
|
|
||
| # Also create the JSON output for reference | ||
| output_path = base_path / args.output | ||
| with open(output_path, "w", encoding="utf-8") as f: | ||
| json.dump(spec, f, indent=args.indent, ensure_ascii=False) | ||
| print(f"✓ JSON bundled specification written to: {output_path}") | ||
| else: |
There was a problem hiding this comment.
Guard against --inline clobbering the input file when --output matches --input.
In inline mode, the JSON output write can overwrite the input YAML if the output path equals the input path. Add a path collision check.
🛠️ Proposed fix
if args.inline:
input_path = base_path / args.input
+ output_path = base_path / args.output
+ if output_path.resolve() == input_path.resolve():
+ print("Error: --output must be different from --input when --inline is used")
+ sys.exit(1)
# When inlining, update the original YAML file
with open(input_path, "w", encoding="utf-8") as f:
yaml.dump(
spec,
f,
default_flow_style=False,
allow_unicode=True,
sort_keys=False,
)
print(f"✓ Updated original file with resolved references: {input_path}")
# Also create the JSON output for reference
- output_path = base_path / args.output
with open(output_path, "w", encoding="utf-8") as f:
json.dump(spec, f, indent=args.indent, ensure_ascii=False)
print(f"✓ JSON bundled specification written to: {output_path}")🤖 Prompt for AI Agents
In `@docs/openapi/bundle.py` around lines 435 - 454, When args.inline is true the
code writes both input_path (the YAML) and output_path (the JSON) which can
clobber the just-updated input if args.output equals args.input; modify the
block handling args.inline in bundle.py to compare output_path.resolve() and
input_path.resolve() and guard against collision by skipping the JSON write (or
aborting with a clear error message) when they are the same, and log/print a
notice that the JSON write was skipped or that the user must pick a different
--output; keep the YAML inlining behavior for input_path and only proceed to
open output_path for json.dump when the paths differ.
| "discriminator": { | ||
| "propertyName": "connection_type", | ||
| "mapping": { | ||
| "http": "#/MCPClientCreateRequestHTTP", | ||
| "sse": "#/MCPClientCreateRequestSSE", | ||
| "stdio": "#/MCPClientCreateRequestSTDIO" | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
OpenAPI 3.1 discriminator mapping $ref format specifications
💡 Result:
In OpenAPI 3.1, discriminator.mapping values are not $ref objects. The spec defines mapping as a map of string → string, where each value is either:
- a schema component name, or
- a URI reference (including relative references such as
#/components/schemas/Dog, or absolute URLs). [1][2]
Valid formats
A) Map to a component schema name (implicit component lookup)
discriminator:
propertyName: petType
mapping:
cachorro: DogBecause the mapping value is a schema component name, tooling resolves it under #/components/schemas. [1]
B) Map to a URI reference (same “string form” as a $ref value)
discriminator:
propertyName: petType
mapping:
dog: "#/components/schemas/Dog"
monster: "https://example.com/schemas/Monster/schema.json"This is explicitly allowed: mapping values may identify a schema “by a URI”. [1][2]
Ambiguity rule (important in 3.1)
If a mapping value could be interpreted as both a valid schema name and a valid relative URI reference, behavior is implementation-defined; the spec recommends treating it as a schema name. To force it to be treated as a relative URI reference, prefix with "./". [1]
Invalid format (what not to do)
This is invalid because mapping values must be strings, not $ref objects:
mapping:
dog:
$ref: "#/components/schemas/Dog" # invalid(Use dog: "#/components/schemas/Dog" instead.) [1]
Sources: OpenAPI Specification v3.1.2, Discriminator Object / mapping definition + mapping rules and examples. [1][2]
References
[1] (spec.openapis.org)
[2] (spec.openapis.org)
Citations:
🏁 Script executed:
# Check the actual discriminator mappings at the specified lines
sed -n '127847,127853p' docs/openapi/openapi.jsonRepository: maximhq/bifrost
Length of output: 376
🏁 Script executed:
# Check the other location mentioned
sed -n '167037,167043p' docs/openapi/openapi.jsonRepository: maximhq/bifrost
Length of output: 320
🏁 Script executed:
# Search for the referenced schema components
rg 'MCPClientCreateRequestHTTP|MCPClientCreateRequestSSE|MCPClientCreateRequestSTDIO' docs/openapi/openapi.json | head -20Repository: maximhq/bifrost
Length of output: 392
🏁 Script executed:
# Check what components exist in the file
rg '"MCPClientCreateRequest' docs/openapi/openapi.json | head -30Repository: maximhq/bifrost
Length of output: 92
🏁 Script executed:
# Search for the component schema definitions with full path
rg '"MCPClientCreateRequestHTTP"|"MCPClientCreateRequestSSE"|"MCPClientCreateRequestSTDIO"' docs/openapi/openapi.json -A 2 | head -40Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Check if components/schemas section exists and what's in it
rg '"components"\s*:\s*{' docs/openapi/openapi.json | head -5Repository: maximhq/bifrost
Length of output: 162
🏁 Script executed:
# Look for where these schemas are actually defined
rg -B 2 '"MCPClientCreateRequestHTTP"' docs/openapi/openapi.json | head -20Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Find where MCPClientCreateRequestHTTP is defined as a component
rg 'MCPClientCreateRequestHTTP' docs/openapi/openapi.json -B 5 -A 1 | head -50Repository: maximhq/bifrost
Length of output: 557
🏁 Script executed:
# Check the JSON structure: components section
rg -i '"components"' docs/openapi/openapi.json | head -5Repository: maximhq/bifrost
Length of output: 76
🏁 Script executed:
# Get a better view of the schemas section
python3 << 'EOF'
import json
try:
with open('docs/openapi/openapi.json', 'r') as f:
data = json.load(f)
# Check if components/schemas exists
if 'components' in data and 'schemas' in data['components']:
schemas = data['components']['schemas']
# Look for MCP-related schemas
mcp_schemas = [k for k in schemas.keys() if 'MCP' in k or 'mcp' in k.lower()]
print("MCP-related schemas found:", mcp_schemas[:20])
# Check for the specific ones
for name in ['MCPClientCreateRequestHTTP', 'MCPClientCreateRequestSSE', 'MCPClientCreateRequestSTDIO']:
if name in schemas:
print(f"✓ {name} exists in components/schemas")
else:
print(f"✗ {name} NOT found in components/schemas")
else:
print("No components/schemas found in the file")
except json.JSONDecodeError as e:
print(f"JSON parsing error: {e}")
except Exception as e:
print(f"Error: {e}")
EOFRepository: maximhq/bifrost
Length of output: 511
Fix discriminator mapping references — schemas do not exist in components/schemas.
The discriminator mappings at lines 127847–127853 and 167037–167043 have two critical issues:
-
Invalid URI reference format: The mappings use
#/MCPClientCreateRequestHTTP(missing/components/schemaspath segment), which is not a valid OpenAPI 3.1 reference. -
Referenced schemas do not exist: The schemas
MCPClientCreateRequestHTTP,MCPClientCreateRequestSSE, andMCPClientCreateRequestSTDIOdo not exist incomponents/schemas. OnlyMCPClientCreateRequestis defined. This will cause validation failures and break any tooling that attempts to resolve these references.
Verify which schema should be referenced (likely MCPClientCreateRequest or new variants that need to be added to components/schemas) and correct the mappings accordingly.
🔧 Expected format
- "http": "#/MCPClientCreateRequestHTTP",
- "sse": "#/MCPClientCreateRequestSSE",
- "stdio": "#/MCPClientCreateRequestSTDIO"
+ "http": "#/components/schemas/MCPClientCreateRequest",
+ "sse": "#/components/schemas/MCPClientCreateRequest",
+ "stdio": "#/components/schemas/MCPClientCreateRequest"(or add the missing schema variants to components/schemas if they are intentionally separate)
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "discriminator": { | |
| "propertyName": "connection_type", | |
| "mapping": { | |
| "http": "#/MCPClientCreateRequestHTTP", | |
| "sse": "#/MCPClientCreateRequestSSE", | |
| "stdio": "#/MCPClientCreateRequestSTDIO" | |
| } | |
| "discriminator": { | |
| "propertyName": "connection_type", | |
| "mapping": { | |
| "http": "#/components/schemas/MCPClientCreateRequest", | |
| "sse": "#/components/schemas/MCPClientCreateRequest", | |
| "stdio": "#/components/schemas/MCPClientCreateRequest" | |
| } |
🤖 Prompt for AI Agents
In `@docs/openapi/openapi.json` around lines 127847 - 127853, The discriminator
mapping under the schema that uses "propertyName": "connection_type" currently
points to invalid references like "#/MCPClientCreateRequestHTTP" and to
non-existent schemas (MCPClientCreateRequestHTTP, MCPClientCreateRequestSSE,
MCPClientCreateRequestSTDIO); fix it by updating each mapping value to a valid
OpenAPI JSON Pointer (e.g. "#/components/schemas/MCPClientCreateRequest") or, if
separate variant schemas are intended, add properly named schemas
(MCPClientCreateRequestHTTP, MCPClientCreateRequestSSE,
MCPClientCreateRequestSTDIO) under components/schemas and change the mapping
values to "#/components/schemas/<SchemaName>"; ensure this change is applied for
both discriminator blocks that reference connection_type so tooling can resolve
them correctly.
| "404": { | ||
| "description": "OAuth config not found", | ||
| "content": { | ||
| "application/json": { | ||
| "schema": { | ||
| "description": "Resource not found", | ||
| "content": { | ||
| "application/json": { | ||
| "schema": { | ||
| "type": "object", | ||
| "description": "Error response from Bifrost", | ||
| "properties": { | ||
| "event_id": { | ||
| "type": "string" | ||
| }, | ||
| "type": { | ||
| "type": "string" | ||
| }, | ||
| "is_bifrost_error": { | ||
| "type": "boolean" | ||
| }, | ||
| "status_code": { | ||
| "type": "integer" | ||
| }, | ||
| "error": { | ||
| "type": "object", | ||
| "properties": { | ||
| "type": { | ||
| "type": "string" | ||
| }, | ||
| "code": { | ||
| "type": "string" | ||
| }, | ||
| "message": { | ||
| "type": "string" | ||
| }, | ||
| "param": { | ||
| "type": "string" | ||
| }, | ||
| "event_id": { | ||
| "type": "string" | ||
| } | ||
| } | ||
| }, | ||
| "extra_fields": { | ||
| "type": "object", | ||
| "properties": { | ||
| "provider": { | ||
| "type": "string", | ||
| "description": "AI model provider identifier", | ||
| "enum": [ | ||
| "openai", | ||
| "azure", | ||
| "anthropic", | ||
| "bedrock", | ||
| "cohere", | ||
| "vertex", | ||
| "mistral", | ||
| "ollama", | ||
| "groq", | ||
| "sgl", | ||
| "parasail", | ||
| "perplexity", | ||
| "cerebras", | ||
| "gemini", | ||
| "openrouter", | ||
| "elevenlabs", | ||
| "huggingface", | ||
| "nebius", | ||
| "xai" | ||
| ] | ||
| }, | ||
| "model_requested": { | ||
| "type": "string" | ||
| }, | ||
| "request_type": { | ||
| "type": "string" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's check the file exists and get its size
wc -l docs/openapi/openapi.jsonRepository: maximhq/bifrost
Length of output: 91
🏁 Script executed:
# Read the specific lines mentioned in the review (129198-129283)
sed -n '129198,129283p' docs/openapi/openapi.json | head -100Repository: maximhq/bifrost
Length of output: 3515
🏁 Script executed:
# Search for "NotFound" component in the responses section
rg -A 5 '"NotFound"' docs/openapi/openapi.jsonRepository: maximhq/bifrost
Length of output: 232
🏁 Script executed:
# Look at the broader context around line 129198 to understand the endpoint structure
sed -n '129180,129200p' docs/openapi/openapi.jsonRepository: maximhq/bifrost
Length of output: 833
🏁 Script executed:
# Check if there are other responses with similar nested "content" pattern within schemas
rg -B 3 '"content":\s*{\s*"application/json":\s*{\s*"schema":\s*{' docs/openapi/openapi.json | head -50Repository: maximhq/bifrost
Length of output: 211
🏁 Script executed:
# Get context on what endpoint this 404 is part of
sed -n '129100,129200p' docs/openapi/openapi.json | grep -A 10 -B 10 '"404"' | head -30Repository: maximhq/bifrost
Length of output: 469
🏁 Script executed:
# Check for git changes to confirm this is new
git diff HEAD docs/openapi/openapi.json | grep -A 20 -B 5 '"404"' | head -50Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Simpler search for nested content patterns - look for "content" appearing twice in responses
rg '"content"' docs/openapi/openapi.json | wc -lRepository: maximhq/bifrost
Length of output: 63
🏁 Script executed:
# Let's check the git status to understand if this file was modified
git status docs/openapi/openapi.jsonRepository: maximhq/bifrost
Length of output: 124
🏁 Script executed:
# Check if there's a git diff showing changes to this specific section
git diff --unified=0 docs/openapi/openapi.json | grep -A 10 -B 10 '"404"' || echo "No changes in git diff"Repository: maximhq/bifrost
Length of output: 81
🏁 Script executed:
# Alternative: check recent commits affecting this file
git log --oneline -n 10 -- docs/openapi/openapi.jsonRepository: maximhq/bifrost
Length of output: 98
🏁 Script executed:
# Search for other 404 responses to see if they have the same malformation
rg -B 2 '"404":' docs/openapi/openapi.json | head -50Repository: maximhq/bifrost
Length of output: 673
🏁 Script executed:
# Look at a properly structured response for comparison (like 200)
sed -n '129100,129195p' docs/openapi/openapi.json | tail -40Repository: maximhq/bifrost
Length of output: 1895
🏁 Script executed:
# Check if this specific malformed structure (content inside schema) appears elsewhere
python3 << 'EOF'
import json
# Try to validate if the JSON is parseable and check structure
try:
with open('docs/openapi/openapi.json', 'r') as f:
spec = json.load(f)
# Check if there are multiple 404 responses with the malformed structure
count = 0
malformed_endpoints = []
def check_response(path, response_obj):
global count, malformed_endpoints
if isinstance(response_obj, dict) and 'content' in response_obj:
for media_type, media_obj in response_obj.get('content', {}).items():
if 'schema' in media_obj:
schema = media_obj['schema']
# Check if schema has 'content' key (invalid in OpenAPI)
if isinstance(schema, dict) and 'content' in schema:
count += 1
malformed_endpoints.append(path)
# Traverse all paths
for path, path_obj in spec.get('paths', {}).items():
for method, op in path_obj.items():
if isinstance(op, dict) and 'responses' in op:
for status_code, response_obj in op['responses'].items():
check_response(f"{path} {method} {status_code}", response_obj)
print(f"Found {count} malformed responses with nested 'content' in schema:")
for ep in malformed_endpoints:
print(f" - {ep}")
except Exception as e:
print(f"Error: {e}")
EOFRepository: maximhq/bifrost
Length of output: 161
Fix nested response structure in 404 error schema.
The 404 response at /api/oauth/config/{id}/status (lines 129198-129283) contains an invalid nested response structure: schema → content → application/json → schema. In OpenAPI 3.0, a schema object cannot contain a content key. Reference the existing NotFound response component instead.
🔧 Proposed fix
"404": {
- "description": "OAuth config not found",
- "content": {
- "application/json": {
- "schema": {
- "description": "Resource not found",
- "content": {
- "application/json": {
- "schema": { ... }
- }
- }
- }
- }
- }
+ "$ref": "#/components/responses/NotFound"
},🤖 Prompt for AI Agents
In `@docs/openapi/openapi.json` around lines 129198 - 129283, The 404 response for
the /api/oauth/config/{id}/status endpoint has an invalid nested schema
structure (schema → content → application/json → schema); update the response to
reference the existing reusable NotFound response/component instead of embedding
a content key inside schema—locate the 404 response under the path
/api/oauth/config/{id}/status and replace the current object with a $ref to the
NotFound response component (e.g., "#/components/responses/NotFound") so the
OpenAPI 3.0 document is valid.
| "client_id": { | ||
| "type": "string", | ||
| "description": "OAuth client ID. Optional if client supports dynamic client registration (RFC 7591).\nIf not provided, the server_url must be set for OAuth discovery and dynamic registration.\n" | ||
| }, |
There was a problem hiding this comment.
Clarify server_url reference in OAuthConfigRequest docs.
Line 167231-167232 says server_url must be set for discovery, but server_url is not defined in this schema. Either add the field or update the wording to reference the actual property (e.g., connection_string).
🤖 Prompt for AI Agents
In `@docs/openapi/openapi.json` around lines 167229 - 167232, The documentation
for the OAuthConfigRequest schema references an undefined `server_url` in the
`client_id` description; either add a `server_url` property to the
OAuthConfigRequest schema or update the text to reference the actual property
used for discovery (for example `connection_string`); locate the
OAuthConfigRequest schema (the block containing `client_id`) and modify the
description to reference the correct property name or add a new `server_url`
field with its type and description to keep the docs accurate and consistent.
| - name: code | ||
| in: query | ||
| required: true | ||
| description: Authorization code from the OAuth provider | ||
| schema: | ||
| type: string | ||
| - name: error | ||
| in: query | ||
| required: false | ||
| description: Error code if authorization failed | ||
| schema: | ||
| type: string | ||
| - name: error_description | ||
| in: query | ||
| required: false | ||
| description: Error description if authorization failed | ||
| schema: | ||
| type: string |
There was a problem hiding this comment.
Consider making code optional when error parameters are present.
When OAuth authorization fails, providers typically redirect with error and error_description but without a code. Having code as required: true may cause request validation to fail before the handler can process error responses. Consider making code optional or documenting that validation should be handled in the handler.
🤖 Prompt for AI Agents
In `@docs/openapi/paths/management/oauth.yaml` around lines 18 - 35, The OpenAPI
spec currently marks the query parameter "code" as required which will fail
validation when providers return "error" and "error_description" without a code;
update the parameter definitions in the management/oauth.yaml to make "code"
optional (set required: false) or replace the parameters with a oneOf/anyOf
schema that allows either { error + error_description } or { code } so the
handler can process OAuth error redirects; adjust the parameters named "code",
"error", and "error_description" accordingly.
| '404': | ||
| description: OAuth config not found | ||
| content: | ||
| application/json: | ||
| schema: | ||
| $ref: '../../openapi.yaml#/components/responses/NotFound' | ||
| '500': | ||
| $ref: '../../openapi.yaml#/components/responses/InternalError' |
There was a problem hiding this comment.
Inconsistent $ref usage for error responses.
Line 79 references ../../openapi.yaml#/components/responses/NotFound under schema:, but this path points to a response object, not a schema. Line 81 correctly uses $ref at the response level for 500. Consider aligning the 404 response format:
🛠️ Suggested fix
'404':
- description: OAuth config not found
- content:
- application/json:
- schema:
- $ref: '../../openapi.yaml#/components/responses/NotFound'
+ $ref: '../../openapi.yaml#/components/responses/NotFound'🤖 Prompt for AI Agents
In `@docs/openapi/paths/management/oauth.yaml` around lines 74 - 81, The 404
response is incorrectly using a schema $ref that points to a response object;
replace the nested schema block under the '404' response with a response-level
$ref to ../../openapi.yaml#/components/responses/NotFound so it matches the 500
style (i.e., remove the content/schema wrapper and set '404': $ref:
'../../openapi.yaml#/components/responses/NotFound').
| var logger schemas.Logger | ||
|
|
||
| func SetLogger(l schemas.Logger) { | ||
| logger = l |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's find where SetLogger is called
rg -n "oauth2\.SetLogger|SetLogger" --glob '*.go' -A 2
# Check logger usage in oauth2 package
rg -n "logger\." framework/oauth2/ --glob '*.go' -A 2
# Look at the oauth2 init.go file to understand context
cat -n framework/oauth2/init.go
# Check for any initialization/setup code
rg -n "oauth2\." --glob '*main*.go' -A 2Repository: maximhq/bifrost
Length of output: 16832
🏁 Script executed:
# Find the Logger interface definition
rg -n "type Logger interface" --glob '*.go' -A 10
# Check if there are any nil checks on logger usage
rg -n "logger == nil|if logger|logger != nil" framework/oauth2/ --glob '*.go'
# Look at the full context of SetLogger call in oauth2/main.go
sed -n '35,55p' framework/oauth2/main.goRepository: maximhq/bifrost
Length of output: 1956
🏁 Script executed:
# Get more context around the NewOAuth2Provider function
sed -n '35,50p' framework/oauth2/main.go
# Check the discovery.go context around the nil check
rg -n "if logger != nil" framework/oauth2/discovery.go -B 2 -A 2Repository: maximhq/bifrost
Length of output: 879
Add nil checks to logger usage in main.go for consistency.
The SetLogger call in NewOAuth2Provider (lines 40-44) ensures the logger is initialized during startup. However, logger usage in main.go and discovery.go lacks nil guards, while sync.go consistently checks if w.logger != nil before all logger calls. Apply the same defensive pattern to maintain consistency across the package.
🤖 Prompt for AI Agents
In `@framework/oauth2/init.go` around lines 5 - 8, The package-level logger
(variable logger set via SetLogger in NewOAuth2Provider) is used without nil
checks in main.go and discovery.go; update all places that call logger methods
in those files to guard with if logger != nil (or similarly check before using
logger) consistent with sync.go's pattern (e.g., wrap calls in if logger != nil
{ logger.Info/... } ), ensuring every logger usage is protected to avoid nil
derefs while preserving existing log messages and call sites.
| if err := h.oauthProvider.CompleteOAuthFlow(context.Background(), state, code); err != nil { | ||
| SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("OAuth flow completion failed: %v", err)) | ||
| return |
There was a problem hiding this comment.
Use the request ctx instead of context.Background() for store/provider calls.
These operations should inherit request cancellation/tracing; pass the *fasthttp.RequestCtx directly. Based on learnings, keep fasthttp.RequestCtx as the primary context carrier.
🔧 Suggested fix
- if err := h.oauthProvider.CompleteOAuthFlow(context.Background(), state, code); err != nil {
+ if err := h.oauthProvider.CompleteOAuthFlow(ctx, state, code); err != nil {
SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("OAuth flow completion failed: %v", err))
return
}
@@
- oauthConfig, err := h.store.ConfigStore.GetOauthConfigByState(context.Background(), state)
+ oauthConfig, err := h.store.ConfigStore.GetOauthConfigByState(ctx, state)
if err == nil && oauthConfig != nil {
oauthConfig.Status = "failed"
- h.store.ConfigStore.UpdateOauthConfig(context.Background(), oauthConfig)
+ h.store.ConfigStore.UpdateOauthConfig(ctx, oauthConfig)
}
}
@@
- oauthConfig, err := h.store.ConfigStore.GetOauthConfigByID(context.Background(), configID)
+ oauthConfig, err := h.store.ConfigStore.GetOauthConfigByID(ctx, configID)
@@
- token, err := h.store.ConfigStore.GetOauthTokenByID(context.Background(), *oauthConfig.TokenID)
+ token, err := h.store.ConfigStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID)
@@
- if err := h.oauthProvider.RevokeToken(context.Background(), configID); err != nil {
+ if err := h.oauthProvider.RevokeToken(ctx, configID); err != nil {Also applies to: 100-103, 145-168, 182-183
🤖 Prompt for AI Agents
In `@transports/bifrost-http/handlers/oauth2.go` around lines 61 - 63, The handler
is calling provider/store methods with context.Background() which loses request
cancellation/tracing; update all calls like
h.oauthProvider.CompleteOAuthFlow(...) (and similar calls in the same
file/ranges) to pass the request context variable (the *fasthttp.RequestCtx
named ctx) instead of context.Background(); ensure every place that currently
uses context.Background() for oauth/provider/store operations uses ctx so
request cancellation/tracing is preserved (e.g., replace context.Background()
with ctx in calls to h.oauthProvider.CompleteOAuthFlow and the other
provider/store methods referenced in the file).
ddbaed6 to
30d19ca
Compare
34d5d04 to
ddb54ae
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
framework/configstore/rdb.go (1)
846-855: Defaultauth_typewhen omitted to avoid empty DB values.
clientConfigCopy.AuthTypecan be the zero value when older clients/requests omit it, which will store""and bypass the DB default ('headers'). That yields an invalid auth type and breaks backward compatibility. Consider defaulting before insert.🛠️ Suggested fix
- dbClient := tables.TableMCPClient{ + authType := string(clientConfigCopy.AuthType) + if authType == "" { + authType = "headers" + } + 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), + AuthType: authType, OauthConfigID: clientConfigCopy.OauthConfigID, ToolsToExecute: clientConfigCopy.ToolsToExecute, ToolsToAutoExecute: clientConfigCopy.ToolsToAutoExecute, Headers: clientConfigCopy.Headers, IsPingAvailable: clientConfigCopy.IsPingAvailable, }docs/openapi/schemas/management/mcp.yaml (1)
70-82: Resolve merge-conflict markers — schema is currently invalid.The conflict markers (
<<<<<<<,=======,>>>>>>>) will break OpenAPI parsing and must be resolved. The intended resolution likely keeps both theis_code_mode_clientdescription and theis_ping_availableproperty.🛠️ Suggested resolution
is_code_mode_client: type: boolean -<<<<<<< HEAD + description: Whether this client is available in code mode is_ping_available: type: boolean default: true description: | Whether the MCP server supports ping for health checks. If true, uses lightweight ping method for health checks. If false, uses listTools method for health checks instead. -======= - description: Whether this client is available in code mode ->>>>>>> 4e846193 (feat: plugin schema extensions for mcp plugins)
🤖 Fix all issues with AI agents
In `@docs/openapi/schemas/management/mcp.yaml`:
- Around line 189-198: The OpenAPI schema exposes auth_type and oauth_config_id
in MCPClientUpdateRequest but the backend UpdateMCPClientConfig function does
not persist them; update the backend or schema to match. Fix by adding auth_type
and oauth_config_id to the update map in framework/configstore/rdb.go's
UpdateMCPClientConfig so they are written to the DB when present (ensure field
names match DB columns and handle nil/empty values), or alternatively
remove/mark auth_type and oauth_config_id as readOnly/omit from
MCPClientUpdateRequest in docs/openapi/schemas/management/mcp.yaml so the schema
reflects the actual updatable fields. Ensure the change references the
MCPClientUpdateRequest schema and the UpdateMCPClientConfig function for
consistency.
♻️ Duplicate comments (10)
docs/openapi/bundle.py (1)
435-454: Guard against--inlineclobbering the input file when--outputmatches--input.The inline mode writes YAML to
input_pathfirst, then writes JSON tooutput_path. If a user specifies--outputwith the same path as--input, the JSON write will overwrite the just-updated YAML file.🛠️ Proposed fix
if args.inline: input_path = base_path / args.input + output_path = base_path / args.output + if output_path.resolve() == input_path.resolve(): + print("Error: --output must be different from --input when --inline is used") + sys.exit(1) # When inlining, update the original YAML file with open(input_path, "w", encoding="utf-8") as f: yaml.dump( spec, f, default_flow_style=False, allow_unicode=True, sort_keys=False, ) print(f"✓ Updated original file with resolved references: {input_path}") # Also create the JSON output for reference - output_path = base_path / args.output with open(output_path, "w", encoding="utf-8") as f: json.dump(spec, f, indent=args.indent, ensure_ascii=False) print(f"✓ JSON bundled specification written to: {output_path}")framework/configstore/migrations.go (1)
3032-3036: Explicitly drop NOT NULL onauthorize_url/token_url.
triggerMigrationsdoesn’t invoke AutoMigrate, so relying on struct tags won’t relax NOT NULL. Discovery-only configs can fail on upgrade unless these columns are altered explicitly.🛠️ Suggested fix
// Make authorize_url and token_url nullable (can be discovered) - // GORM doesn't provide a direct way to alter column constraints, - // but since these fields don't have NOT NULL in the struct anymore, - // AutoMigrate will handle it on the next table sync + switch tx.Dialector.Name() { + case "postgres": + if err := tx.Exec("ALTER TABLE oauth_configs ALTER COLUMN authorize_url DROP NOT NULL").Error; err != nil { + return fmt.Errorf("failed to drop NOT NULL on authorize_url: %w", err) + } + if err := tx.Exec("ALTER TABLE oauth_configs ALTER COLUMN token_url DROP NOT NULL").Error; err != nil { + return fmt.Errorf("failed to drop NOT NULL on token_url: %w", err) + } + case "mysql": + if err := tx.Exec("ALTER TABLE oauth_configs MODIFY authorize_url TEXT NULL").Error; err != nil { + return fmt.Errorf("failed to set authorize_url nullable: %w", err) + } + if err := tx.Exec("ALTER TABLE oauth_configs MODIFY token_url TEXT NULL").Error; err != nil { + return fmt.Errorf("failed to set token_url nullable: %w", err) + } + }framework/configstore/tables/oauth.go (1)
12-52: Encrypt OAuth secrets before persistence.
ClientSecret,CodeVerifier,AccessToken, andRefreshTokenare stored as raw strings. This is a high‑risk secret exposure unless encrypted at rest via hooks or the write path.core/schemas/oauth.go (1)
37-45: Avoid JSON exposure of access/refresh tokens.Even if not currently returned,
json:"access_token"/json:"refresh_token"make accidental exposure easy. Preferjson:"-"or a redacted DTO.🛡️ Suggested change
type OAuth2Token struct { ID string `json:"id"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` + AccessToken string `json:"-"` + RefreshToken string `json:"-"` TokenType string `json:"token_type"` ExpiresAt time.Time `json:"expires_at"` Scopes []string `json:"scopes"` LastRefreshedAt *time.Time `json:"last_refreshed_at,omitempty"` }framework/configstore/store.go (1)
186-198: Verify all ConfigStore implementations/mocks cover the new OAuth methods.
Interface expansion will break builds if any implementation in the stack is missing these signatures. As per coding guidelines, review this in the context of the full stack.#!/bin/bash # Verify ConfigStore implementations include the new OAuth methods rg -n "type ConfigStore interface" --type go -A 40 rg -n "func .*GetOauthConfigByID" --type go rg -n "func .*GetOauthTokenByID" --type go rg -n "type MockConfigStore" --type go -A 200framework/oauth2/discovery.go (1)
53-134: Guard logger usage to prevent nil dereference.
Only the initial call is guarded (Line 54), but later calls like Line 74, Line 84, Line 106, Line 216, Line 274, Line 352, Line 398, and Line 431 are unconditional. IfSetLoggerisn’t called, this will panic.🛠️ Minimal guard pattern (apply across all logger calls)
- logger.Debug(fmt.Sprintf("[OAuth Discovery] Server responded with status: %d", resp.StatusCode)) + if logger != nil { + logger.Debug(fmt.Sprintf("[OAuth Discovery] Server responded with status: %d", resp.StatusCode)) + }Also applies to: 216-242, 232-243, 273-299, 326-352, 388-451
docs/openapi/openapi.json (4)
127852-127858: Discriminator mapping refs still aren’t valid OpenAPI pointers.
Mapping values should be schema names or JSON Pointers under#/components/schemas/...;#/MCPClientCreateRequestHTTPwon’t resolve. Ensure the referenced schemas exist or update mapping values to valid pointers. As per coding guidelines, please validate against the full PR stack.
129208-129293: 404 response schema nestscontentinsideschema.
OpenAPI responses should not placecontentunder a schema object; reference the sharedNotFoundresponse instead.
167057-167063: Second discriminator mapping block uses the same invalid refs.
Ensure mapping values resolve to valid#/components/schemas/...targets (or add the missing schema components). As per coding guidelines, please validate against the full PR stack.
167249-167252:server_urlis referenced but not defined in this schema.
Either add the field or update the text to the actual property used for discovery.
🧹 Nitpick comments (1)
core/internal/mcptests/agent_request_id_test.go (1)
29-31: Preferbifrost.Ptr()for pointer creation.This keeps pointer construction consistent with repo conventions.
♻️ Proposed refactor
import ( "context" "fmt" "sync" "testing" + bifrost "github.com/maximhq/bifrost/core" "github.com/maximhq/bifrost/core/mcp" "github.com/maximhq/bifrost/core/schemas" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ - logger := &testLogger{t: t} + logger := bifrost.Ptr(testLogger{t: t})Based on learnings, prefer bifrost.Ptr for pointer creation.
| auth_type: | ||
| $ref: '#/MCPAuthType' | ||
| description: Authentication type for the MCP connection | ||
| oauth_config_id: | ||
| type: string | ||
| description: | | ||
| OAuth config ID for OAuth authentication. | ||
| References the oauth_configs table. | ||
| Only relevant when auth_type is "oauth". | ||
| headers: |
There was a problem hiding this comment.
Update schema exposes fields the backend doesn’t persist.
MCPClientUpdateRequest now accepts auth_type and oauth_config_id, but framework/configstore/rdb.go UpdateMCPClientConfig (Line 923-932) does not update those columns. That makes updates silently no-op. Either add them to the update map or mark them read-only/remove from the update schema.
🤖 Prompt for AI Agents
In `@docs/openapi/schemas/management/mcp.yaml` around lines 189 - 198, The OpenAPI
schema exposes auth_type and oauth_config_id in MCPClientUpdateRequest but the
backend UpdateMCPClientConfig function does not persist them; update the backend
or schema to match. Fix by adding auth_type and oauth_config_id to the update
map in framework/configstore/rdb.go's UpdateMCPClientConfig so they are written
to the DB when present (ensure field names match DB columns and handle nil/empty
values), or alternatively remove/mark auth_type and oauth_config_id as
readOnly/omit from MCPClientUpdateRequest in
docs/openapi/schemas/management/mcp.yaml so the schema reflects the actual
updatable fields. Ensure the change references the MCPClientUpdateRequest schema
and the UpdateMCPClientConfig function for consistency.
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
docs/openapi/schemas/management/mcp.yaml (1)
72-82: Resolve merge conflict markers before merge.The
<<<<<<< HEAD/=======/>>>>>>>block makes the OpenAPI schema invalid YAML. Please resolve by keeping both fields and removing the markers.🧩 Example resolution
is_code_mode_client: type: boolean -<<<<<<< HEAD - is_ping_available: - type: boolean - default: true - description: | - Whether the MCP server supports ping for health checks. - If true, uses lightweight ping method for health checks. - If false, uses listTools method for health checks instead. -======= - description: Whether this client is available in code mode ->>>>>>> 4e846193 (feat: plugin schema extensions for mcp plugins) + description: Whether this client is available in code mode + is_ping_available: + type: boolean + default: true + description: | + Whether the MCP server supports ping for health checks. + If true, uses lightweight ping method for health checks. + If false, uses listTools method for health checks instead.framework/configstore/store.go (1)
40-46: Add GetMCPClientByID to MockConfigStore.The
MockConfigStoreis missing theGetMCPClientByIDmethod required by theConfigStoreinterface (defined at framework/configstore/store.go:42), whileRDBConfigStoreimplements it (framework/configstore/rdb.go:814). This creates an interface mismatch. Add the missing method totransports/bifrost-http/lib/config_test.gowith the other MCP config methods.framework/configstore/rdb.go (1)
846-855: Add auth fields to update map or validate them as immutable.Auth fields (
auth_type,oauth_config_id) are set inCreateMCPClientConfigbut excluded from theUpdateMCPClientConfigupdate map with no validation. If these should be immutable like connection info, add validation to reject changes; otherwise, include them in the updates map.If mutable, add to update map
updates := map[string]interface{}{ "name": clientConfigCopy.Name, "is_code_mode_client": clientConfigCopy.IsCodeModeClient, + "auth_type": clientConfigCopy.AuthType, + "oauth_config_id": clientConfigCopy.OauthConfigID, "tools_to_execute_json": string(toolsToExecuteJSON), "tools_to_auto_execute_json": string(toolsToAutoExecuteJSON), "headers_json": string(headersJSON), "tool_pricing_json": string(toolPricingJSON), "is_ping_available": clientConfigCopy.IsPingAvailable, "updated_at": time.Now(), }
🤖 Fix all issues with AI agents
In `@docs/openapi/paths/management/mcp.yaml`:
- Around line 221-233: The response objects for status codes '400' and '404'
combine a $ref with a sibling description, which OpenAPI forbids; update the
responses in the MCP path by either removing the inline description fields and
leaving only the $ref for '400' and '404', or replace the $ref with a full
inline response object that includes your description and schema; locate the
'400' and '404' response entries in the MCP YAML (the blocks currently
containing "$ref: '../../openapi.yaml#/components/responses/BadRequest'" and
"$ref: '../../openapi.yaml#/components/responses/NotFound'") and apply one of
the two fixes so there are no sibling fields alongside $ref.
♻️ Duplicate comments (10)
docs/openapi/bundle.py (1)
435-453: Guard against--inlineclobbering the input file when--outputmatches--input.
This can overwrite the just-updated YAML. Add a path-collision check before writing JSON.🛠️ Proposed fix
if args.inline: input_path = base_path / args.input + output_path = base_path / args.output + if output_path.resolve() == input_path.resolve(): + print("Error: --output must be different from --input when --inline is used") + sys.exit(1) # When inlining, update the original YAML file with open(input_path, "w", encoding="utf-8") as f: yaml.dump( spec, f, default_flow_style=False, allow_unicode=True, sort_keys=False, ) print(f"✓ Updated original file with resolved references: {input_path}") # Also create the JSON output for reference - output_path = base_path / args.output with open(output_path, "w", encoding="utf-8") as f: json.dump(spec, f, indent=args.indent, ensure_ascii=False) print(f"✓ JSON bundled specification written to: {output_path}")framework/configstore/tables/mcp.go (1)
35-38: Avoid cascading deletes from oauth_configs to MCP clients.With a nullable
OauthConfigID,OnDelete:CASCADEcan delete MCP client rows if OAuth configs are cleaned up/rotated independently. PreferSET NULL(orRESTRICT) and keep the constraint on just one tag to avoid redundant declarations.🔧 Suggested adjustment
- 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 + OauthConfigID *string `gorm:"type:varchar(255);index" json:"oauth_config_id"` // Foreign key to oauth_configs.ID + OauthConfig *TableOauthConfig `gorm:"foreignKey:OauthConfigID;references:ID;constraint:OnDelete:SET NULL" json:"-"`framework/oauth2/discovery.go (1)
74-134: Guard logger usage to avoid nil panics.Many log calls are unconditional even though logger can be nil. This will panic if discovery/PKCE/registration runs before
SetLogger. Add a safe wrapper or initialize a default/no-op logger at entry.🛡️ Example: safe logging helpers (apply across the file)
+func logDebug(msg string) { + if logger != nil { + logger.Debug(msg) + } +} + +func logWarn(msg string) { + if logger != nil { + logger.Warn(msg) + } +} + +func logError(msg string) { + if logger != nil { + logger.Error(msg) + } +} -logger.Debug(fmt.Sprintf("[OAuth Discovery] Server responded with status: %d", resp.StatusCode)) +logDebug(fmt.Sprintf("[OAuth Discovery] Server responded with status: %d", resp.StatusCode)) -logger.Warn(fmt.Sprintf("[OAuth Discovery] Failed to fetch resource metadata: %v", err)) +logWarn(fmt.Sprintf("[OAuth Discovery] Failed to fetch resource metadata: %v", err)) -logger.Error(fmt.Sprintf("[Dynamic Registration] Failed with status %d: %s", resp.StatusCode, string(respBody))) +logError(fmt.Sprintf("[Dynamic Registration] Failed with status %d: %s", resp.StatusCode, string(respBody)))Also applies to: 216-243, 274-299, 352-353, 398-451
core/schemas/oauth.go (1)
37-45: Redact access/refresh tokens from JSON serialization.These fields are sensitive and should not be marshaled in any response DTO, even if current endpoints don’t expose them.
🔒 Proposed hardening
type OAuth2Token struct { ID string `json:"id"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` + AccessToken string `json:"-"` + RefreshToken string `json:"-"` TokenType string `json:"token_type"` ExpiresAt time.Time `json:"expires_at"` Scopes []string `json:"scopes"` LastRefreshedAt *time.Time `json:"last_refreshed_at,omitempty"` }docs/openapi/openapi.json (4)
127852-127858: Duplicate: discriminator mapping uses invalid schema refs.
This duplicates the earlier review: the mapping values like#/MCPClientCreateRequestHTTPare invalid/missing. Please align with valid OpenAPI refs or add the schemas.OpenAPI 3.1 discriminator mapping value format (component name vs URI reference)
129208-129293: Duplicate: 404 response nestscontentinsideschema.
This matches the earlier review: theschemaobject should not contain a nestedcontent. Please reference the NotFound response component instead.OpenAPI response object schema should not contain content key
167057-167063: Duplicate: discriminator mapping uses invalid schema refs.
Same issue as earlier: mapping values point to non-existent/invalid refs.OpenAPI 3.1 discriminator mapping value format (component name vs URI reference)
167249-167252: Duplicate:server_urlreferenced but not defined.
Same as earlier review comment—update the wording or add the property.framework/configstore/store.go (1)
186-198: Ensure all ConfigStore implementations (especially mocks) include the new OAuth methods.Missing stubs in test mocks will break builds.
#!/bin/bash # Verify OAuth methods are implemented across ConfigStore implementations/mocks rg -n "type ConfigStore interface" --type go -A 120 rg -n "func .*GetOauthConfigByID|GetOauthConfigByState|GetOauthConfigByTokenID|CreateOauthConfig|UpdateOauthConfig" --type go rg -n "func .*GetOauthTokenByID|GetExpiringOauthTokens|CreateOauthToken|UpdateOauthToken|DeleteOauthToken" --type go rg -n "type MockConfigStore" --type go -A 200framework/configstore/migrations.go (1)
2995-3036: Make authorize_url/token_url nullable explicitly (don’t rely on AutoMigrate).
triggerMigrationsdoesn’t runAutoMigrate, so existing DBs with NOT NULL constraints will keep them, breaking discovery-only configs. Add dialect-specific ALTERs here.🛠️ Proposed fix
// Make authorize_url and token_url nullable (can be discovered) - // GORM doesn't provide a direct way to alter column constraints, - // but since these fields don't have NOT NULL in the struct anymore, - // AutoMigrate will handle it on the next table sync + switch tx.Dialector.Name() { + case "postgres": + if err := tx.Exec("ALTER TABLE oauth_configs ALTER COLUMN authorize_url DROP NOT NULL").Error; err != nil { + return fmt.Errorf("failed to drop NOT NULL on authorize_url: %w", err) + } + if err := tx.Exec("ALTER TABLE oauth_configs ALTER COLUMN token_url DROP NOT NULL").Error; err != nil { + return fmt.Errorf("failed to drop NOT NULL on token_url: %w", err) + } + case "mysql": + if err := tx.Exec("ALTER TABLE oauth_configs MODIFY authorize_url TEXT NULL").Error; err != nil { + return fmt.Errorf("failed to set authorize_url nullable: %w", err) + } + if err := tx.Exec("ALTER TABLE oauth_configs MODIFY token_url TEXT NULL").Error; err != nil { + return fmt.Errorf("failed to set token_url nullable: %w", err) + } + }
🧹 Nitpick comments (2)
core/schemas/mcp.go (1)
83-86: PreferMCPConnectionStateforStatetyping.This keeps state values constrained and consistent with the existing enum.
♻️ Suggested type tightening
- State string `json:"state,omitempty"` // Connection state (connected, disconnected, error) + State MCPConnectionState `json:"state,omitempty"` // Connection state (connected, disconnected, error)core/mcp/clientmanager.go (1)
113-157: Consider deduplicatingAddClientandAddClientInMemory.The implementations are identical; a shared helper would reduce drift risk.
| '200': | ||
| description: MCP client connected successfully with OAuth | ||
| content: | ||
| application/json: | ||
| schema: | ||
| $ref: '../../schemas/management/common.yaml#/SuccessResponse' | ||
| '400': | ||
| description: OAuth not authorized yet or MCP client not found in pending OAuth clients | ||
| $ref: '../../openapi.yaml#/components/responses/BadRequest' | ||
| '404': | ||
| description: MCP client not found in pending OAuth clients or OAuth config not found | ||
| $ref: '../../openapi.yaml#/components/responses/NotFound' | ||
| '500': |
There was a problem hiding this comment.
Invalid response objects: $ref can’t be combined with description.
OpenAPI disallows sibling fields next to $ref. Either inline the response or move the custom description into a separate component.
✅ Minimal fix (remove custom descriptions)
- '400':
- description: OAuth not authorized yet or MCP client not found in pending OAuth clients
- $ref: '../../openapi.yaml#/components/responses/BadRequest'
+ '400':
+ $ref: '../../openapi.yaml#/components/responses/BadRequest'
- '404':
- description: MCP client not found in pending OAuth clients or OAuth config not found
- $ref: '../../openapi.yaml#/components/responses/NotFound'
+ '404':
+ $ref: '../../openapi.yaml#/components/responses/NotFound'📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| '200': | |
| description: MCP client connected successfully with OAuth | |
| content: | |
| application/json: | |
| schema: | |
| $ref: '../../schemas/management/common.yaml#/SuccessResponse' | |
| '400': | |
| description: OAuth not authorized yet or MCP client not found in pending OAuth clients | |
| $ref: '../../openapi.yaml#/components/responses/BadRequest' | |
| '404': | |
| description: MCP client not found in pending OAuth clients or OAuth config not found | |
| $ref: '../../openapi.yaml#/components/responses/NotFound' | |
| '500': | |
| '200': | |
| description: MCP client connected successfully with OAuth | |
| content: | |
| application/json: | |
| schema: | |
| $ref: '../../schemas/management/common.yaml#/SuccessResponse' | |
| '400': | |
| $ref: '../../openapi.yaml#/components/responses/BadRequest' | |
| '404': | |
| $ref: '../../openapi.yaml#/components/responses/NotFound' | |
| '500': |
🤖 Prompt for AI Agents
In `@docs/openapi/paths/management/mcp.yaml` around lines 221 - 233, The response
objects for status codes '400' and '404' combine a $ref with a sibling
description, which OpenAPI forbids; update the responses in the MCP path by
either removing the inline description fields and leaving only the $ref for
'400' and '404', or replace the $ref with a full inline response object that
includes your description and schema; locate the '400' and '404' response
entries in the MCP YAML (the blocks currently containing "$ref:
'../../openapi.yaml#/components/responses/BadRequest'" and "$ref:
'../../openapi.yaml#/components/responses/NotFound'") and apply one of the two
fixes so there are no sibling fields alongside $ref.
ddb54ae to
d848e91
Compare
30d19ca to
0fcfd40
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
docs/openapi/schemas/management/mcp.yaml (1)
72-82: Resolve merge conflict markers — file is invalid YAML.
The conflict markers will break schema parsing. Please merge both descriptions correctly.🛠️ Suggested resolution
is_code_mode_client: type: boolean -<<<<<<< HEAD - is_ping_available: - type: boolean - default: true - description: | - Whether the MCP server supports ping for health checks. - If true, uses lightweight ping method for health checks. - If false, uses listTools method for health checks instead. -======= description: Whether this client is available in code mode ->>>>>>> 4e846193 (feat: plugin schema extensions for mcp plugins) + is_ping_available: + type: boolean + default: true + description: | + Whether the MCP server supports ping for health checks. + If true, uses lightweight ping method for health checks. + If false, uses listTools method for health checks instead.framework/configstore/rdb.go (2)
846-855: DefaultAuthTypewhen unset to avoid empty-string persistence.
If callers omitAuthType, the zero value gets stored as"", bypassing the DB default and risking “unknown auth type” behavior. Consider normalizing to"headers"(or a constant if available).🛠️ Proposed fix
// Create new client + authType := clientConfigCopy.AuthType + if authType == "" { + authType = schemas.MCPAuthType("headers") + } 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), + AuthType: string(authType), OauthConfigID: clientConfigCopy.OauthConfigID, ToolsToExecute: clientConfigCopy.ToolsToExecute, ToolsToAutoExecute: clientConfigCopy.ToolsToAutoExecute, Headers: clientConfigCopy.Headers, IsPingAvailable: clientConfigCopy.IsPingAvailable, }
921-934: Persistauth_typeandoauth_config_idon updates.
UpdateMCPClientConfignever writes these new fields, so OAuth changes won’t stick once a client is edited.🛠️ Proposed fix
updates := map[string]interface{}{ "name": clientConfigCopy.Name, "is_code_mode_client": clientConfigCopy.IsCodeModeClient, + "auth_type": clientConfigCopy.AuthType, + "oauth_config_id": clientConfigCopy.OauthConfigID, "tools_to_execute_json": string(toolsToExecuteJSON), "tools_to_auto_execute_json": string(toolsToAutoExecuteJSON), "headers_json": string(headersJSON), "tool_pricing_json": string(toolPricingJSON), "is_ping_available": clientConfigCopy.IsPingAvailable, "updated_at": time.Now(), }
🤖 Fix all issues with AI agents
In `@docs/openapi/schemas/management/oauth.yaml`:
- Around line 1-132: The OpenAPI schemas are out of sync with server behavior:
update the OAuthConfigStatus enum to include the missing status values "expired"
and "revoked" (so OAuthConfigStatus.enum becomes [pending, authorized, failed,
expired, revoked]) and remove the sensitive token fields from the OAuthToken
schema by deleting the access_token and refresh_token properties (these are not
returned per TableOauthToken/json:"-"). Locate and modify the OAuthConfigStatus
and OAuthToken definitions in the YAML to apply these changes.
In `@framework/oauth2/main.go`:
- Around line 244-259: The cleanup goroutine in cleanupExpiredPendingClients
leaks because it runs forever; add a stop mechanism: add a stop channel (or
context) field on OAuth2Provider (e.g., stopCleanup chan struct{}) initialized
in NewOAuth2Provider, modify cleanupExpiredPendingClients to select on ticker.C
and stopCleanup (stop signal) so it returns when signaled and ensure
ticker.Stop() is called; implement a Close() method on OAuth2Provider that
closes the stop channel (or cancels the context) and optionally waits for
completion (use a sync.WaitGroup if needed) before returning; keep using p.mu
and p.pendingMCPClients inside the goroutine as before.
♻️ Duplicate comments (18)
framework/configstore/migrations.go (1)
2997-3035: Explicitly drop NOT NULL on authorize_url/token_url.
triggerMigrationsdoesn’t call AutoMigrate, so existing NOT NULL constraints won’t be removed; discovery-only configs will fail on upgrade. Please alter the columns explicitly in this migration.🛠️ Suggested fix
// Add use_discovery column if !migrator.HasColumn(oauthConfig, "use_discovery") { if err := migrator.AddColumn(oauthConfig, "use_discovery"); err != nil { return fmt.Errorf("failed to add use_discovery column: %w", err) } } - // Make authorize_url and token_url nullable (can be discovered) - // GORM doesn't provide a direct way to alter column constraints, - // but since these fields don't have NOT NULL in the struct anymore, - // AutoMigrate will handle it on the next table sync + // Make authorize_url and token_url nullable (can be discovered) + switch tx.Dialector.Name() { + case "postgres": + if err := tx.Exec("ALTER TABLE oauth_configs ALTER COLUMN authorize_url DROP NOT NULL").Error; err != nil { + return fmt.Errorf("failed to drop NOT NULL on authorize_url: %w", err) + } + if err := tx.Exec("ALTER TABLE oauth_configs ALTER COLUMN token_url DROP NOT NULL").Error; err != nil { + return fmt.Errorf("failed to drop NOT NULL on token_url: %w", err) + } + case "mysql": + if err := tx.Exec("ALTER TABLE oauth_configs MODIFY authorize_url TEXT NULL").Error; err != nil { + return fmt.Errorf("failed to set authorize_url nullable: %w", err) + } + if err := tx.Exec("ALTER TABLE oauth_configs MODIFY token_url TEXT NULL").Error; err != nil { + return fmt.Errorf("failed to set token_url nullable: %w", err) + } + }framework/configstore/tables/oauth.go (1)
12-23: Protect OAuth secrets at rest.
These fields are marked “encrypted” in comments but are still stored as plaintext at the model layer. Ensure encryption is applied before persistence (or document why plaintext is acceptable).Also applies to: 49-52
docs/openapi/schemas/management/mcp.yaml (1)
189-197: Verify updates persist auth_type/oauth_config_id.
The schema accepts these fields on update; ensure the persistence layer updates the corresponding columns.#!/bin/bash # Verify UpdateMCPClientConfig updates auth_type/oauth_config_id rg -n "UpdateMCPClientConfig" -C 3 framework/configstore/rdb.go rg -n "auth_type|oauth_config_id" -C 3 framework/configstore/rdb.gocore/schemas/oauth.go (1)
37-45: Remove JSON exposure of access/refresh tokens.
These fields should not be serializable by default to avoid accidental leakage.🔒 Proposed change
type OAuth2Token struct { ID string `json:"id"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` + AccessToken string `json:"-"` + RefreshToken string `json:"-"` TokenType string `json:"token_type"` ExpiresAt time.Time `json:"expires_at"` Scopes []string `json:"scopes"` LastRefreshedAt *time.Time `json:"last_refreshed_at,omitempty"` }docs/openapi/openapi.json (4)
127852-127858: Fix discriminator mapping references — schemas do not exist.The discriminator mappings reference non-existent schemas with invalid URI format:
#/MCPClientCreateRequestHTTPshould be#/components/schemas/MCPClientCreateRequestHTTP- The schemas
MCPClientCreateRequestHTTP,MCPClientCreateRequestSSE, andMCPClientCreateRequestSTDIOdon't exist incomponents/schemasThis will cause OpenAPI validation failures and break code generation tooling.
🔧 Option 1: Reference the inline oneOf entries by index (not recommended)
OpenAPI 3.1 discriminator with inline
oneOfschemas is problematic. Consider extracting the variants into named schemas.🔧 Option 2: Add the missing schemas to components/schemas
Define
MCPClientCreateRequestHTTP,MCPClientCreateRequestSSE, andMCPClientCreateRequestSTDIOincomponents/schemasand update mappings:"discriminator": { "propertyName": "connection_type", "mapping": { - "http": "#/MCPClientCreateRequestHTTP", - "sse": "#/MCPClientCreateRequestSSE", - "stdio": "#/MCPClientCreateRequestSTDIO" + "http": "#/components/schemas/MCPClientCreateRequestHTTP", + "sse": "#/components/schemas/MCPClientCreateRequestSSE", + "stdio": "#/components/schemas/MCPClientCreateRequestSTDIO" } }
129208-129293: Fix invalid nested response structure in 404 error.The 404 response contains an invalid nested structure:
schema→content→application/json→schema. In OpenAPI 3.x, a schema object cannot contain acontentkey — this is malformed JSON that will fail validation.Use the
NotFoundresponse component defined in hunk 8 instead.🔧 Proposed fix
"404": { "description": "OAuth config not found", - "content": { - "application/json": { - "schema": { - "description": "Resource not found", - "content": { - "application/json": { - "schema": { - ... - } - } - } - } - } - } + "$ref": "#/components/responses/NotFound" },
167057-167063: Same discriminator mapping issue as above.This discriminator has the same invalid references to non-existent schemas. Apply the same fix here.
167249-167252: Clarifyserver_urlreference in OAuthConfigRequest.The
client_iddescription states "the server_url must be set for OAuth discovery and dynamic registration" butserver_urlis not defined in this schema. Either:
- Add a
server_urlproperty toOAuthConfigRequest, or- Update the description to reference the actual field used (e.g.,
connection_stringfrom the parent MCP client request)framework/oauth2/sync.go (2)
38-44: MakeStop()idempotent to prevent panic on multiple calls.Closing a channel twice will panic. Use
sync.Onceto guard the close operation, consistent with patterns in the codebase (e.g.,framework/tracing/store.go).🔧 Suggested fix
+import "sync" type TokenRefreshWorker struct { provider *OAuth2Provider refreshInterval time.Duration lookAheadWindow time.Duration stopCh chan struct{} + stopOnce sync.Once logger schemas.Logger } func (w *TokenRefreshWorker) Stop() { - close(w.stopCh) + w.stopOnce.Do(func() { close(w.stopCh) }) if w.logger != nil { w.logger.Info("Token refresh worker stopped") } }
126-134: Race condition: setters modify fields while worker may be running.
SetRefreshIntervalandSetLookAheadWindowmodify struct fields without synchronization, while therungoroutine readsw.refreshIntervalwhen creating the ticker. If these setters are called afterStart(), there's a data race.🔧 Suggested fix (if test-only, add documentation)
// SetRefreshInterval updates the refresh check interval (for testing) +// Must be called before Start() to avoid data races. func (w *TokenRefreshWorker) SetRefreshInterval(interval time.Duration) { w.refreshInterval = interval } // SetLookAheadWindow updates the look-ahead window for token expiry (for testing) +// Must be called before Start() to avoid data races. func (w *TokenRefreshWorker) SetLookAheadWindow(window time.Duration) { w.lookAheadWindow = window }framework/oauth2/discovery.go (1)
74-74: Guard all logger calls to prevent nil panics.Line 54 guards the logger, but subsequent calls at lines 74, 84, 87, 98, 100, 106, 111, 127-133, 216, 219, 222, 228, 236, 239, 242, 274, 297, 352, 398-399, 431, 446-450 lack protection. If discovery is invoked before
SetLogger()is called, this causes a panic.🔧 Suggested fix - wrap all logger calls
- logger.Debug(fmt.Sprintf("[OAuth Discovery] Server responded with status: %d", resp.StatusCode)) + if logger != nil { + logger.Debug(fmt.Sprintf("[OAuth Discovery] Server responded with status: %d", resp.StatusCode)) + } resourceMetadataURL, scopesFromHeader := parseWWWAuthenticateHeader(wwwAuth) if resourceMetadataURL != "" { - logger.Debug(fmt.Sprintf("[OAuth Discovery] Found resource_metadata URL: %s", resourceMetadataURL)) + if logger != nil { + logger.Debug(fmt.Sprintf("[OAuth Discovery] Found resource_metadata URL: %s", resourceMetadataURL)) + } } if len(scopesFromHeader) > 0 { - logger.Debug(fmt.Sprintf("[OAuth Discovery] Found scopes in header: %v", scopesFromHeader)) + if logger != nil { + logger.Debug(fmt.Sprintf("[OAuth Discovery] Found scopes in header: %v", scopesFromHeader)) + } }Apply similar nil checks to all other unguarded logger calls throughout the file.
Also applies to: 84-88
framework/oauth2/main.go (7)
556-565: Handle refresh flows for public clients: don't send emptyclient_secret.Per RFC 6749,
exchangeRefreshTokenmust not sendclient_secretwhen empty—public clients cannot maintain secret confidentiality and some OAuth servers reject empty secrets. TheexchangeCodeForTokensandexchangeCodeForTokensWithPKCEmethods already correctly guard this.🔧 Suggested fix
func (p *OAuth2Provider) exchangeRefreshToken(tokenURL, clientID, clientSecret, refreshToken string) (*schemas.OAuth2TokenExchangeResponse, error) { data := url.Values{} data.Set("grant_type", "refresh_token") data.Set("refresh_token", refreshToken) data.Set("client_id", clientID) - data.Set("client_secret", clientSecret) + if clientSecret != "" { + data.Set("client_secret", clientSecret) + } return p.callTokenEndpoint(tokenURL, data) }
92-95: Improve error message when token is nil after refresh.When
tokenisnilbuterris alsonil,fmt.Errorfwith%won anilerror produces a confusing message ending with%!w(<nil>).🔧 Suggested fix
token, err = p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) - if err != nil || token == nil { - return "", fmt.Errorf("failed to reload token after refresh: %w", err) + if err != nil { + return "", fmt.Errorf("failed to reload token after refresh: %w", err) + } + if token == nil { + return "", fmt.Errorf("token not found after refresh") }
443-447: Error fromUpdateOauthConfigis silently ignored when flow expires.This could leave the database in an inconsistent state.
🔧 Suggested fix
if time.Now().After(oauthConfig.ExpiresAt) { oauthConfig.Status = "expired" - p.configStore.UpdateOauthConfig(ctx, oauthConfig) + if err := p.configStore.UpdateOauthConfig(ctx, oauthConfig); err != nil { + logger.Error("Failed to update expired oauth config status", "error", err, "oauth_config_id", oauthConfig.ID) + } return fmt.Errorf("oauth flow expired") }
465-473: Error fromUpdateOauthConfigon token exchange failure is also ignored.🔧 Suggested fix
if err != nil { oauthConfig.Status = "failed" - p.configStore.UpdateOauthConfig(ctx, oauthConfig) + if updateErr := p.configStore.UpdateOauthConfig(ctx, oauthConfig); updateErr != nil { + logger.Error("Failed to update oauth config status", "error", updateErr, "oauth_config_id", oauthConfig.ID) + } logger.Error("Token exchange failed",
480-480: Ignoredjson.Marshalerror.The error from
json.Marshal(scopes)is discarded. While unlikely to fail for[]string, it's inconsistent with line 382 which properly handles the same operation.🔧 Suggested fix
- scopesJSON, _ := json.Marshal(scopes) + scopesJSON, err := json.Marshal(scopes) + if err != nil { + return fmt.Errorf("failed to serialize token scopes: %w", err) + }
509-523: Handle authorize URLs that already contain query parameters.
buildAuthorizeURLWithPKCEappends?via simple string concatenation, which breaks URLs that already contain query parameters. Useurl.Parseto safely merge existing and new query parameters.🔧 Suggested fix
func (p *OAuth2Provider) buildAuthorizeURLWithPKCE(authorizeURL, clientID, redirectURI, state, codeChallenge string, scopes []string) string { params := url.Values{} params.Set("response_type", "code") params.Set("client_id", clientID) params.Set("redirect_uri", redirectURI) params.Set("state", state) params.Set("code_challenge", codeChallenge) params.Set("code_challenge_method", "S256") if len(scopes) > 0 { params.Set("scope", strings.Join(scopes, " ")) } - return authorizeURL + "?" + params.Encode() + u, err := url.Parse(authorizeURL) + if err != nil { + return authorizeURL + "?" + params.Encode() + } + q := u.Query() + for k, vs := range params { + for _, v := range vs { + q.Set(k, v) + } + } + u.RawQuery = q.Encode() + return u.String() }
117-121: Guard against missing refresh token before attempting refresh.If
token.RefreshTokenis empty, the refresh attempt will fail at the OAuth server. Fail early with a clear error message.🔧 Suggested fix
// Load oauth_token token, err := p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) if err != nil || token == nil { return fmt.Errorf("oauth token not found: %w", err) } + + if token.RefreshToken == "" { + return fmt.Errorf("refresh token missing; re-authorize the OAuth flow") + }
🧹 Nitpick comments (5)
plugins/semanticcache/plugin_vectorstore_test.go (2)
103-106: Update error messages to reference new method names.The method calls are correctly updated to
PreLLMHookandPostLLMHook, but the error messages still reference the old names ("PreHook failed","PostHook failed"). This could cause confusion when debugging test failures.♻️ Suggested fix
modifiedReq, shortCircuit, err := setup.Plugin.PreLLMHook(ctx, request) if err != nil { - t.Fatalf("[%s] PreHook failed: %v", tc.Name, err) + t.Fatalf("[%s] PreLLMHook failed: %v", tc.Name, err) }_, _, err = setup.Plugin.PostLLMHook(ctx, response, nil) if err != nil { - t.Fatalf("[%s] PostHook failed: %v", tc.Name, err) + t.Fatalf("[%s] PostLLMHook failed: %v", tc.Name, err) }_, shortCircuit2, err := setup.Plugin.PreLLMHook(ctx2, request) if err != nil { - t.Fatalf("[%s] Second PreHook failed: %v", tc.Name, err) + t.Fatalf("[%s] Second PreLLMHook failed: %v", tc.Name, err) }Also applies to: 144-147, 159-162
309-312: Same error message inconsistency as noted above.The method calls are correctly updated, but error messages at lines 311, 343, and 377 should also reference
PreLLMHook/PostLLMHookfor consistency.♻️ Suggested fix
_, shortCircuit1, err := setup.Plugin.PreLLMHook(ctx, request1) if err != nil { - t.Fatalf("[%s] First PreHook failed: %v", tc.Name, err) + t.Fatalf("[%s] First PreLLMHook failed: %v", tc.Name, err) }_, _, err = setup.Plugin.PostLLMHook(ctx, response, nil) if err != nil { - t.Fatalf("[%s] PostHook failed: %v", tc.Name, err) + t.Fatalf("[%s] PostLLMHook failed: %v", tc.Name, err) }_, shortCircuit2, err := setup.Plugin.PreLLMHook(ctx2, request2) if err != nil { - t.Fatalf("[%s] Second PreHook failed: %v", tc.Name, err) + t.Fatalf("[%s] Second PreLLMHook failed: %v", tc.Name, err) }Also applies to: 341-344, 375-378
core/mcp/clientmanager.go (1)
113-157: Consider consolidating AddClient and AddClientInMemory.
Both functions are nearly identical; a shared helper would reduce drift risk.docs/openapi/schemas/management/mcp.yaml (1)
102-106: Use local schema refs for OAuthConfigRequest.
Prefer local$ref: '#/SchemaName'style within schema files to align with bundler expectations. As per coding guidelines, consider moving or aliasing OAuthConfigRequest locally and referencing it via#/OAuthConfigRequest.docs/openapi/openapi.json (1)
129379-129418: Consider adding 404 response for consistency.The
revokeOAuthConfigDELETE endpoint only defines 200 and 500 responses. The corresponding GET endpoint (getOAuthConfigStatus) includes a 404 response for when the OAuth config is not found.For API consistency and better client error handling, consider adding a 404 response here as well, unless the delete is intentionally idempotent (returning 200 even if the resource doesn't exist).
| # OAuth API schemas | ||
|
|
||
| MCPAuthType: | ||
| type: string | ||
| enum: [none, headers, oauth] | ||
| description: | | ||
| Authentication type for MCP connections: | ||
| - none: No authentication | ||
| - headers: Header-based authentication (API keys, custom headers, etc.) | ||
| - oauth: OAuth 2.0 authentication | ||
|
|
||
| OAuthConfigRequest: | ||
| type: object | ||
| description: OAuth configuration for MCP client creation | ||
| properties: | ||
| client_id: | ||
| type: string | ||
| description: | | ||
| OAuth client ID. Optional if client supports dynamic client registration (RFC 7591). | ||
| If not provided, the server_url must be set for OAuth discovery and dynamic registration. | ||
| client_secret: | ||
| type: string | ||
| description: | | ||
| OAuth client secret. Optional for public clients using PKCE or clients obtained via dynamic registration. | ||
| authorize_url: | ||
| type: string | ||
| description: | | ||
| OAuth authorization endpoint URL. Optional - will be discovered from server_url if not provided. | ||
| token_url: | ||
| type: string | ||
| description: | | ||
| OAuth token endpoint URL. Optional - will be discovered from server_url if not provided. | ||
| registration_url: | ||
| type: string | ||
| description: | | ||
| Dynamic client registration endpoint URL (RFC 7591). Optional - will be discovered from server_url if not provided. | ||
| scopes: | ||
| type: array | ||
| items: | ||
| type: string | ||
| description: | | ||
| OAuth scopes requested. Optional - can be discovered from server_url if not provided. | ||
| Example: ["read", "write"] | ||
|
|
||
| OAuthFlowInitiation: | ||
| type: object | ||
| description: Response when initiating an OAuth flow | ||
| properties: | ||
| status: | ||
| type: string | ||
| enum: [pending_oauth] | ||
| message: | ||
| type: string | ||
| oauth_config_id: | ||
| type: string | ||
| description: ID of the OAuth config created for this flow | ||
| authorize_url: | ||
| type: string | ||
| description: URL to redirect the user to for authorization | ||
| expires_at: | ||
| type: string | ||
| format: date-time | ||
| description: When the OAuth authorization request expires | ||
| mcp_client_id: | ||
| type: string | ||
| description: The MCP client ID that initiated this OAuth flow | ||
|
|
||
| OAuthConfigStatus: | ||
| type: object | ||
| description: Status of an OAuth configuration | ||
| properties: | ||
| id: | ||
| type: string | ||
| description: OAuth config ID | ||
| status: | ||
| type: string | ||
| enum: [pending, authorized, failed] | ||
| description: | | ||
| Current status of the OAuth flow: | ||
| - pending: User has not yet authorized | ||
| - authorized: User authorized and token is stored | ||
| - failed: Authorization failed | ||
| created_at: | ||
| type: string | ||
| format: date-time | ||
| description: When this OAuth config was created | ||
| expires_at: | ||
| type: string | ||
| format: date-time | ||
| description: When this OAuth config expires (becomes invalid if not completed) | ||
| token_id: | ||
| type: string | ||
| description: ID of the associated OAuth token (only present if status is authorized) | ||
| token_expires_at: | ||
| type: string | ||
| format: date-time | ||
| description: When the OAuth access token expires (only present if status is authorized) | ||
| token_scopes: | ||
| type: array | ||
| items: | ||
| type: string | ||
| description: Scopes granted in the OAuth token (only present if status is authorized) | ||
|
|
||
| OAuthToken: | ||
| type: object | ||
| description: OAuth access and refresh tokens | ||
| properties: | ||
| id: | ||
| type: string | ||
| description: Unique token identifier | ||
| access_token: | ||
| type: string | ||
| description: OAuth access token | ||
| refresh_token: | ||
| type: string | ||
| description: OAuth refresh token for obtaining new access tokens | ||
| token_type: | ||
| type: string | ||
| description: Token type (typically "Bearer") | ||
| expires_at: | ||
| type: string | ||
| format: date-time | ||
| description: When the access token expires | ||
| scopes: | ||
| type: array | ||
| items: | ||
| type: string | ||
| description: Scopes granted in this token | ||
| last_refreshed_at: | ||
| type: string | ||
| format: date-time | ||
| description: When the token was last refreshed |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Search for TableOauthConfig definition and status enum
rg -n "TableOauthConfig|type.*OAuthConfig" -g '*.go' -A 5 | head -100Repository: maximhq/bifrost
Length of output: 7956
🏁 Script executed:
# Search for TableOauthToken definition and json tags
rg -n "TableOauthToken|type.*OAuthToken" -g '*.go' -A 10 | head -100Repository: maximhq/bifrost
Length of output: 7675
🏁 Script executed:
# Search for OAuth status constants/enums
rg -n "pending|authorized|failed|expired|revoked" -g '*.go' | grep -i oauth | head -50Repository: maximhq/bifrost
Length of output: 4940
🏁 Script executed:
# Find OAuth-related handlers and endpoints
rg -n "oauth|OAuth" -g '*.go' -l | head -20Repository: maximhq/bifrost
Length of output: 714
🏁 Script executed:
# Get the full TableOauthConfig struct definition
cat -n framework/configstore/tables/oauth.go | sed -n '1,45p'Repository: maximhq/bifrost
Length of output: 3082
🏁 Script executed:
# Search for Status field specifically and any type hints
rg -A 1 "Status.*string" framework/configstore/tables/oauth.goRepository: maximhq/bifrost
Length of output: 342
🏁 Script executed:
# Check for any constants or type definitions for OAuth status
rg -n "const.*pending|const.*authorized|const.*failed|const.*expired|const.*revoked" --type goRepository: maximhq/bifrost
Length of output: 541
Align OAuth status enum and token fields with server implementation.
The OpenAPI schema is missing two status values that the server supports: expired and revoked are both set in the codebase (framework/oauth2/main.go:201, framework/oauth2/sync.go:112) but excluded from the enum. Additionally, the OAuthToken schema exposes access_token and refresh_token fields, but TableOauthToken uses json:"-" tags to hide these in actual API responses for security—remove them from the schema.
🛠️ Suggested enum alignment
status:
type: string
- enum: [pending, authorized, failed]
+ enum: [pending, authorized, failed, expired, revoked]
description: |
Current status of the OAuth flow:
- pending: User has not yet authorized
- authorized: User authorized and token is stored
- failed: Authorization failed
+ - expired: Authorization window expired
+ - revoked: Authorization revokedAlso remove access_token and refresh_token from the OAuthToken schema properties—these are never exposed in API responses.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| # OAuth API schemas | |
| MCPAuthType: | |
| type: string | |
| enum: [none, headers, oauth] | |
| description: | | |
| Authentication type for MCP connections: | |
| - none: No authentication | |
| - headers: Header-based authentication (API keys, custom headers, etc.) | |
| - oauth: OAuth 2.0 authentication | |
| OAuthConfigRequest: | |
| type: object | |
| description: OAuth configuration for MCP client creation | |
| properties: | |
| client_id: | |
| type: string | |
| description: | | |
| OAuth client ID. Optional if client supports dynamic client registration (RFC 7591). | |
| If not provided, the server_url must be set for OAuth discovery and dynamic registration. | |
| client_secret: | |
| type: string | |
| description: | | |
| OAuth client secret. Optional for public clients using PKCE or clients obtained via dynamic registration. | |
| authorize_url: | |
| type: string | |
| description: | | |
| OAuth authorization endpoint URL. Optional - will be discovered from server_url if not provided. | |
| token_url: | |
| type: string | |
| description: | | |
| OAuth token endpoint URL. Optional - will be discovered from server_url if not provided. | |
| registration_url: | |
| type: string | |
| description: | | |
| Dynamic client registration endpoint URL (RFC 7591). Optional - will be discovered from server_url if not provided. | |
| scopes: | |
| type: array | |
| items: | |
| type: string | |
| description: | | |
| OAuth scopes requested. Optional - can be discovered from server_url if not provided. | |
| Example: ["read", "write"] | |
| OAuthFlowInitiation: | |
| type: object | |
| description: Response when initiating an OAuth flow | |
| properties: | |
| status: | |
| type: string | |
| enum: [pending_oauth] | |
| message: | |
| type: string | |
| oauth_config_id: | |
| type: string | |
| description: ID of the OAuth config created for this flow | |
| authorize_url: | |
| type: string | |
| description: URL to redirect the user to for authorization | |
| expires_at: | |
| type: string | |
| format: date-time | |
| description: When the OAuth authorization request expires | |
| mcp_client_id: | |
| type: string | |
| description: The MCP client ID that initiated this OAuth flow | |
| OAuthConfigStatus: | |
| type: object | |
| description: Status of an OAuth configuration | |
| properties: | |
| id: | |
| type: string | |
| description: OAuth config ID | |
| status: | |
| type: string | |
| enum: [pending, authorized, failed] | |
| description: | | |
| Current status of the OAuth flow: | |
| - pending: User has not yet authorized | |
| - authorized: User authorized and token is stored | |
| - failed: Authorization failed | |
| created_at: | |
| type: string | |
| format: date-time | |
| description: When this OAuth config was created | |
| expires_at: | |
| type: string | |
| format: date-time | |
| description: When this OAuth config expires (becomes invalid if not completed) | |
| token_id: | |
| type: string | |
| description: ID of the associated OAuth token (only present if status is authorized) | |
| token_expires_at: | |
| type: string | |
| format: date-time | |
| description: When the OAuth access token expires (only present if status is authorized) | |
| token_scopes: | |
| type: array | |
| items: | |
| type: string | |
| description: Scopes granted in the OAuth token (only present if status is authorized) | |
| OAuthToken: | |
| type: object | |
| description: OAuth access and refresh tokens | |
| properties: | |
| id: | |
| type: string | |
| description: Unique token identifier | |
| access_token: | |
| type: string | |
| description: OAuth access token | |
| refresh_token: | |
| type: string | |
| description: OAuth refresh token for obtaining new access tokens | |
| token_type: | |
| type: string | |
| description: Token type (typically "Bearer") | |
| expires_at: | |
| type: string | |
| format: date-time | |
| description: When the access token expires | |
| scopes: | |
| type: array | |
| items: | |
| type: string | |
| description: Scopes granted in this token | |
| last_refreshed_at: | |
| type: string | |
| format: date-time | |
| description: When the token was last refreshed | |
| # OAuth API schemas | |
| MCPAuthType: | |
| type: string | |
| enum: [none, headers, oauth] | |
| description: | | |
| Authentication type for MCP connections: | |
| - none: No authentication | |
| - headers: Header-based authentication (API keys, custom headers, etc.) | |
| - oauth: OAuth 2.0 authentication | |
| OAuthConfigRequest: | |
| type: object | |
| description: OAuth configuration for MCP client creation | |
| properties: | |
| client_id: | |
| type: string | |
| description: | | |
| OAuth client ID. Optional if client supports dynamic client registration (RFC 7591). | |
| If not provided, the server_url must be set for OAuth discovery and dynamic registration. | |
| client_secret: | |
| type: string | |
| description: | | |
| OAuth client secret. Optional for public clients using PKCE or clients obtained via dynamic registration. | |
| authorize_url: | |
| type: string | |
| description: | | |
| OAuth authorization endpoint URL. Optional - will be discovered from server_url if not provided. | |
| token_url: | |
| type: string | |
| description: | | |
| OAuth token endpoint URL. Optional - will be discovered from server_url if not provided. | |
| registration_url: | |
| type: string | |
| description: | | |
| Dynamic client registration endpoint URL (RFC 7591). Optional - will be discovered from server_url if not provided. | |
| scopes: | |
| type: array | |
| items: | |
| type: string | |
| description: | | |
| OAuth scopes requested. Optional - can be discovered from server_url if not provided. | |
| Example: ["read", "write"] | |
| OAuthFlowInitiation: | |
| type: object | |
| description: Response when initiating an OAuth flow | |
| properties: | |
| status: | |
| type: string | |
| enum: [pending_oauth] | |
| message: | |
| type: string | |
| oauth_config_id: | |
| type: string | |
| description: ID of the OAuth config created for this flow | |
| authorize_url: | |
| type: string | |
| description: URL to redirect the user to for authorization | |
| expires_at: | |
| type: string | |
| format: date-time | |
| description: When the OAuth authorization request expires | |
| mcp_client_id: | |
| type: string | |
| description: The MCP client ID that initiated this OAuth flow | |
| OAuthConfigStatus: | |
| type: object | |
| description: Status of an OAuth configuration | |
| properties: | |
| id: | |
| type: string | |
| description: OAuth config ID | |
| status: | |
| type: string | |
| enum: [pending, authorized, failed, expired, revoked] | |
| description: | | |
| Current status of the OAuth flow: | |
| - pending: User has not yet authorized | |
| - authorized: User authorized and token is stored | |
| - failed: Authorization failed | |
| - expired: Authorization window expired | |
| - revoked: Authorization revoked | |
| created_at: | |
| type: string | |
| format: date-time | |
| description: When this OAuth config was created | |
| expires_at: | |
| type: string | |
| format: date-time | |
| description: When this OAuth config expires (becomes invalid if not completed) | |
| token_id: | |
| type: string | |
| description: ID of the associated OAuth token (only present if status is authorized) | |
| token_expires_at: | |
| type: string | |
| format: date-time | |
| description: When the OAuth access token expires (only present if status is authorized) | |
| token_scopes: | |
| type: array | |
| items: | |
| type: string | |
| description: Scopes granted in the OAuth token (only present if status is authorized) | |
| OAuthToken: | |
| type: object | |
| description: OAuth access and refresh tokens | |
| properties: | |
| id: | |
| type: string | |
| description: Unique token identifier | |
| access_token: | |
| type: string | |
| description: OAuth access token | |
| refresh_token: | |
| type: string | |
| description: OAuth refresh token for obtaining new access tokens | |
| token_type: | |
| type: string | |
| description: Token type (typically "Bearer") | |
| expires_at: | |
| type: string | |
| format: date-time | |
| description: When the access token expires | |
| scopes: | |
| type: array | |
| items: | |
| type: string | |
| description: Scopes granted in this token | |
| last_refreshed_at: | |
| type: string | |
| format: date-time | |
| description: When the token was last refreshed |
🤖 Prompt for AI Agents
In `@docs/openapi/schemas/management/oauth.yaml` around lines 1 - 132, The OpenAPI
schemas are out of sync with server behavior: update the OAuthConfigStatus enum
to include the missing status values "expired" and "revoked" (so
OAuthConfigStatus.enum becomes [pending, authorized, failed, expired, revoked])
and remove the sensitive token fields from the OAuthToken schema by deleting the
access_token and refresh_token properties (these are not returned per
TableOauthToken/json:"-"). Locate and modify the OAuthConfigStatus and
OAuthToken definitions in the YAML to apply these changes.
framework/oauth2/main.go
Outdated
| func (p *OAuth2Provider) cleanupExpiredPendingClients() { | ||
| ticker := time.NewTicker(1 * time.Minute) | ||
| defer ticker.Stop() | ||
|
|
||
| for range ticker.C { | ||
| p.mu.Lock() | ||
| now := time.Now() | ||
| for mcpClientID, pending := range p.pendingMCPClients { | ||
| if now.Sub(pending.CreatedAt) > 5*time.Minute { | ||
| delete(p.pendingMCPClients, mcpClientID) | ||
| logger.Debug("Cleaned up expired pending MCP client", "mcp_client_id", mcpClientID) | ||
| } | ||
| } | ||
| p.mu.Unlock() | ||
| } | ||
| } |
There was a problem hiding this comment.
Goroutine leak: cleanupExpiredPendingClients has no shutdown mechanism.
The cleanup goroutine started in NewOAuth2Provider runs indefinitely with no way to stop it. If the provider is discarded, the goroutine will leak. Add a stop channel and a Close() method.
🔧 Suggested fix
type OAuth2Provider struct {
configStore configstore.ConfigStore
mu sync.RWMutex
pendingMCPClients map[string]*PendingMCPClient
+ stopCh chan struct{}
}
func NewOAuth2Provider(configStore configstore.ConfigStore, logger schemas.Logger) *OAuth2Provider {
// ...
p := &OAuth2Provider{
configStore: configStore,
pendingMCPClients: make(map[string]*PendingMCPClient),
+ stopCh: make(chan struct{}),
}
go p.cleanupExpiredPendingClients()
return p
}
+func (p *OAuth2Provider) Close() {
+ close(p.stopCh)
+}
func (p *OAuth2Provider) cleanupExpiredPendingClients() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
- for range ticker.C {
+ for {
+ select {
+ case <-ticker.C:
+ // existing cleanup logic
+ case <-p.stopCh:
+ return
+ }
+ }
}🤖 Prompt for AI Agents
In `@framework/oauth2/main.go` around lines 244 - 259, The cleanup goroutine in
cleanupExpiredPendingClients leaks because it runs forever; add a stop
mechanism: add a stop channel (or context) field on OAuth2Provider (e.g.,
stopCleanup chan struct{}) initialized in NewOAuth2Provider, modify
cleanupExpiredPendingClients to select on ticker.C and stopCleanup (stop signal)
so it returns when signaled and ensure ticker.Stop() is called; implement a
Close() method on OAuth2Provider that closes the stop channel (or cancels the
context) and optionally waits for completion (use a sync.WaitGroup if needed)
before returning; keep using p.mu and p.pendingMCPClients inside the goroutine
as before.
d848e91 to
dd76b9e
Compare
0fcfd40 to
ec62573
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
docs/openapi/schemas/management/mcp.yaml (1)
72-82: Unresolved merge conflict markers will break OpenAPI parsing.The file contains unresolved Git merge conflict markers (
<<<<<<< HEAD,=======,>>>>>>> 4e846193). This will cause the OpenAPI bundler and any consumers to fail parsing the schema.🔧 Proposed fix - resolve the merge conflict
is_code_mode_client: type: boolean -<<<<<<< HEAD - is_ping_available: - type: boolean - default: true - description: | - Whether the MCP server supports ping for health checks. - If true, uses lightweight ping method for health checks. - If false, uses listTools method for health checks instead. -======= description: Whether this client is available in code mode ->>>>>>> 4e846193 (feat: plugin schema extensions for mcp plugins) connection_type: $ref: '#/MCPConnectionType'If
is_ping_availableis needed, include it properly without conflict markers:is_code_mode_client: type: boolean description: Whether this client is available in code mode + is_ping_available: + type: boolean + default: true + description: | + Whether the MCP server supports ping for health checks. + If true, uses lightweight ping method for health checks. + If false, uses listTools method for health checks instead. connection_type: $ref: '#/MCPConnectionType'
🤖 Fix all issues with AI agents
In `@framework/oauth2/discovery.go`:
- Around line 340-346: The PKCE verifier generation uses int(randomBytes[i]) %
len(charset) which creates modulo bias; change this to rejection sampling:
repeatedly draw random bytes and only map a byte to charset when its value is <
(256/len(charset))*len(charset) (i.e. the largest multiple of charset length
<256) to ensure uniform selection, filling the b slice until it reaches the
desired length; update the logic around the verifier variable and use the
existing charset and randomBytes identifiers (or a new per-byte random draw) so
verifier = string(b) remains the final step.
♻️ Duplicate comments (16)
docs/openapi/schemas/management/mcp.yaml (1)
189-198: Update schema exposes fields the backend doesn't persist.
MCPClientUpdateRequestincludesauth_typeandoauth_config_id, but per previous review comments,framework/configstore/rdb.goUpdateMCPClientConfigdoes not update those columns. This makes updates silently no-op. Either add them to the update map in the backend or mark themreadOnly/remove from the update schema.docs/openapi/paths/management/mcp.yaml (1)
227-232: Invalid response objects:$refcan't be combined withdescription.OpenAPI 3.0 disallows sibling fields next to
$ref. Either inline the response or remove the custom description.✅ Minimal fix (remove custom descriptions)
'400': - description: OAuth not authorized yet or MCP client not found in pending OAuth clients $ref: '../../openapi.yaml#/components/responses/BadRequest' '404': - description: MCP client not found in pending OAuth clients or OAuth config not found $ref: '../../openapi.yaml#/components/responses/NotFound'core/schemas/oauth.go (1)
37-46: Remove JSON tags from OAuth2Token's sensitive fields.The
access_tokenandrefresh_tokenJSON tags pose a latent security risk ifOAuth2Tokenis returned in API responses. Consider usingjson:"-"for sensitive fields or creating a redacted DTO for external responses, consistent with patterns likeGetProviderConfigRedactedelsewhere in the codebase.🔒 Proposed fix
type OAuth2Token struct { ID string `json:"id"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` + AccessToken string `json:"-"` + RefreshToken string `json:"-"` TokenType string `json:"token_type"` ExpiresAt time.Time `json:"expires_at"` Scopes []string `json:"scopes"` LastRefreshedAt *time.Time `json:"last_refreshed_at,omitempty"` }framework/oauth2/discovery.go (1)
53-136: Guard all logger calls to prevent nil panics.The function docstring mentions logger "can be nil for silent operation" but only line 54-56 guards the logger call. Subsequent calls at lines 74, 84, 87, 98, 100, 106, 111, 127-133 will panic if
loggeris nil. Apply consistent nil guards or initialize a default logger at function entry.docs/openapi/openapi.json (4)
127852-127858: Fix discriminator mapping refs to valid component paths.
mappingvalues point to non-resolvable JSON pointers; OpenAPI tooling will fail to resolve them. Point to#/components/schemas/...and ensure those components exist (or adjust mapping to the actual schema locations).🔧 Proposed fix (adjust if schema names differ)
- "http": "#/MCPClientCreateRequestHTTP", - "sse": "#/MCPClientCreateRequestSSE", - "stdio": "#/MCPClientCreateRequestSTDIO" + "http": "#/components/schemas/MCPClientCreateRequestHTTP", + "sse": "#/components/schemas/MCPClientCreateRequestSSE", + "stdio": "#/components/schemas/MCPClientCreateRequestSTDIO"
129208-129293: 404 response schema is malformed (nested content).
schemamust not contain acontentobject. Use the reusable NotFound response component instead.🔧 Proposed fix
- "404": { - "description": "OAuth config not found", - "content": { - "application/json": { - "schema": { - "description": "Resource not found", - "content": { - "application/json": { - "schema": { ... } - } - } - } - } - } - }, + "404": { + "$ref": "#/components/responses/NotFound" + },
167057-167063: Discriminator mapping here still points to invalid refs.
Same resolution issue as earlier; use valid component pointers and ensure the variant schemas exist.🔧 Proposed fix (adjust if schema names differ)
- "http": "#/MCPClientCreateRequestHTTP", - "sse": "#/MCPClientCreateRequestSSE", - "stdio": "#/MCPClientCreateRequestSTDIO" + "http": "#/components/schemas/MCPClientCreateRequestHTTP", + "sse": "#/components/schemas/MCPClientCreateRequestSSE", + "stdio": "#/components/schemas/MCPClientCreateRequestSTDIO"
167249-167252:server_urlis referenced but not defined.
The description mentions aserver_urlproperty that doesn’t exist in this schema. Update the text (and mirror the fix in the duplicated descriptions under MCP client variants) or add the missing property.🔧 Suggested wording update
- "description": "OAuth client ID. Optional if client supports dynamic client registration (RFC 7591).\nIf not provided, the server_url must be set for OAuth discovery and dynamic registration.\n" + "description": "OAuth client ID. Optional if client supports dynamic client registration (RFC 7591).\nIf not provided, the parent MCP client connection_string must be set for OAuth discovery and dynamic registration.\n"framework/oauth2/main.go (6)
38-50: Add a shutdown path for the cleanup goroutine.The cleanup worker runs forever and is started in the constructor with no stop mechanism, which can leak goroutines when providers are discarded (tests, reloads).
🔧 Suggested fix
type OAuth2Provider struct { configStore configstore.ConfigStore mu sync.RWMutex pendingMCPClients map[string]*PendingMCPClient // Key: mcp_client_id + stopCh chan struct{} } func NewOAuth2Provider(configStore configstore.ConfigStore, logger schemas.Logger) *OAuth2Provider { // ... p := &OAuth2Provider{ configStore: configStore, pendingMCPClients: make(map[string]*PendingMCPClient), + stopCh: make(chan struct{}), } go p.cleanupExpiredPendingClients() return p } +func (p *OAuth2Provider) Close() { + close(p.stopCh) +} + func (p *OAuth2Provider) cleanupExpiredPendingClients() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() - for range ticker.C { - p.mu.Lock() - // existing cleanup logic - p.mu.Unlock() - } + for { + select { + case <-ticker.C: + p.mu.Lock() + // existing cleanup logic + p.mu.Unlock() + case <-p.stopCh: + return + } + } }Also applies to: 243-258
92-95: Handle nil token after refresh without wrapping a nil error.The current
err != nil || token == nilpath can return%!w(<nil>).🔧 Suggested fix
token, err = p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) - if err != nil || token == nil { - return "", fmt.Errorf("failed to reload token after refresh: %w", err) - } + if err != nil { + return "", fmt.Errorf("failed to reload token after refresh: %w", err) + } + if token == nil { + return "", fmt.Errorf("token not found after refresh") + }
117-129: Guard refresh token usage and omit empty client_secret for public clients.Refresh should fail fast if
refresh_tokenis empty, andclient_secretshould only be sent when non-empty to avoid rejection by public‑client servers.RFC 6749 refresh token client authentication public clients client_secret required🔧 Suggested fix
token, err := p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) if err != nil || token == nil { return fmt.Errorf("oauth token not found: %w", err) } + if token.RefreshToken == "" { + return fmt.Errorf("refresh token missing; re-authorize the OAuth flow") + } func (p *OAuth2Provider) exchangeRefreshToken(tokenURL, clientID, clientSecret, refreshToken string) (*schemas.OAuth2TokenExchangeResponse, error) { data := url.Values{} data.Set("grant_type", "refresh_token") data.Set("refresh_token", refreshToken) data.Set("client_id", clientID) - data.Set("client_secret", clientSecret) + if clientSecret != "" { + data.Set("client_secret", clientSecret) + }Also applies to: 556-563
442-468: Don’t ignore UpdateOauthConfig errors on expiry/failure.If the status update fails, the DB can drift from the actual flow state.
🔧 Suggested fix
if time.Now().After(oauthConfig.ExpiresAt) { oauthConfig.Status = "expired" - p.configStore.UpdateOauthConfig(ctx, oauthConfig) + if err := p.configStore.UpdateOauthConfig(ctx, oauthConfig); err != nil { + logger.Error("Failed to update expired oauth config status", "error", err, "oauth_config_id", oauthConfig.ID) + } return fmt.Errorf("oauth flow expired") } @@ if err != nil { oauthConfig.Status = "failed" - p.configStore.UpdateOauthConfig(ctx, oauthConfig) + if updateErr := p.configStore.UpdateOauthConfig(ctx, oauthConfig); updateErr != nil { + logger.Error("Failed to update oauth config status", "error", updateErr, "oauth_config_id", oauthConfig.ID) + } logger.Error("Token exchange failed",
475-481: Check json.Marshal(scopes) errors before persisting.🔧 Suggested fix
var scopes []string if tokenResponse.Scope != "" { scopes = strings.Split(tokenResponse.Scope, " ") } - scopesJSON, _ := json.Marshal(scopes) + scopesJSON, err := json.Marshal(scopes) + if err != nil { + return fmt.Errorf("failed to serialize token scopes: %w", err) + }
509-522: Build authorize URL safely when the base has query params.Simple string concatenation breaks when
authorizeURLalready includes?.Go net/url best practice for merging existing query parameters when building URLs🔧 Suggested fix
func (p *OAuth2Provider) buildAuthorizeURLWithPKCE(authorizeURL, clientID, redirectURI, state, codeChallenge string, scopes []string) string { params := url.Values{} params.Set("response_type", "code") params.Set("client_id", clientID) params.Set("redirect_uri", redirectURI) params.Set("state", state) params.Set("code_challenge", codeChallenge) params.Set("code_challenge_method", "S256") // SHA-256 hashing if len(scopes) > 0 { params.Set("scope", strings.Join(scopes, " ")) } - return authorizeURL + "?" + params.Encode() + u, err := url.Parse(authorizeURL) + if err != nil { + return authorizeURL + "?" + params.Encode() + } + q := u.Query() + for k, vs := range params { + for _, v := range vs { + q.Add(k, v) + } + } + u.RawQuery = q.Encode() + return u.String() }framework/configstore/migrations.go (1)
2997-3035: Explicitly drop NOT NULL on authorize_url/token_url in the migration.Relying on AutoMigrate won’t work here because
triggerMigrationsdoesn’t run it; upgrades from older schemas could fail for discovery-only configs.🛠️ Suggested fix
// Make authorize_url and token_url nullable (can be discovered) - // GORM doesn't provide a direct way to alter column constraints, - // but since these fields don't have NOT NULL in the struct anymore, - // AutoMigrate will handle it on the next table sync + switch tx.Dialector.Name() { + case "postgres": + if err := tx.Exec("ALTER TABLE oauth_configs ALTER COLUMN authorize_url DROP NOT NULL").Error; err != nil { + return fmt.Errorf("failed to drop NOT NULL on authorize_url: %w", err) + } + if err := tx.Exec("ALTER TABLE oauth_configs ALTER COLUMN token_url DROP NOT NULL").Error; err != nil { + return fmt.Errorf("failed to drop NOT NULL on token_url: %w", err) + } + case "mysql": + if err := tx.Exec("ALTER TABLE oauth_configs MODIFY authorize_url TEXT NULL").Error; err != nil { + return fmt.Errorf("failed to set authorize_url nullable: %w", err) + } + if err := tx.Exec("ALTER TABLE oauth_configs MODIFY token_url TEXT NULL").Error; err != nil { + return fmt.Errorf("failed to set token_url nullable: %w", err) + } + }framework/configstore/tables/oauth.go (1)
12-55: Encrypt OAuth secrets before persisting.
ClientSecret,AccessToken,RefreshToken, andCodeVerifierare stored plaintext; the hooks only set defaults. Add encryption on write and decryption on read (hooks or store layer), or document an intentional plaintext model.
🧹 Nitpick comments (8)
docs/mcp/connecting-to-servers.mdx (1)
142-156: Consider adding a note about optionalclient_secretfor public clients.The HTTP OAuth example includes
client_secretwhile this SSE OAuth example omits it. This is valid (PKCE enables public clients without secrets), but readers may find the inconsistency confusing.Consider adding a brief inline note or comment explaining that
client_secretis optional when using PKCE for public clients.core/schemas/mcp.go (1)
83-86: Consider type consistency forStatefield.The
Statefield is declared asstring, butMCPConnectionStateis a dedicated type defined later (lines 168-174). Meanwhile,MCPClientState.State(line 185) usesMCPConnectionState. Consider using the same type for consistency and type safety.♻️ Proposed fix
- State string `json:"state,omitempty"` // Connection state (connected, disconnected, error) + State MCPConnectionState `json:"state,omitempty"` // Connection state (connected, disconnected, error)core/mcp/clientmanager.go (1)
113-157: Consider extracting common logic to reduce duplication.
AddClientInMemoryis nearly identical toAddClient. The only difference is thatAddClientwould persist to DB (though this isn't shown in the current code). Consider extracting the shared validation and in-memory registration logic to a private helper method.♻️ Suggested refactor pattern
// addClientInternal handles the common logic for adding a client in memory func (m *MCPManager) addClientInternal(config schemas.MCPClientConfig) error { if err := validateMCPClientConfig(&config); err != nil { return fmt.Errorf("invalid MCP client configuration: %w", err) } configCopy := config m.mu.Lock() if _, ok := m.clientMap[config.ID]; ok { m.mu.Unlock() return fmt.Errorf("client %s already exists", config.Name) } m.clientMap[config.ID] = &schemas.MCPClientState{ Name: config.Name, ExecutionConfig: config, ToolMap: make(map[string]schemas.ChatTool), } m.mu.Unlock() if err := m.connectToMCPClient(configCopy); err != nil { m.mu.Lock() delete(m.clientMap, config.ID) m.mu.Unlock() return fmt.Errorf("failed to connect to MCP client %s: %w", config.Name, err) } return nil } func (m *MCPManager) AddClient(config schemas.MCPClientConfig) error { return m.addClientInternal(config) } func (m *MCPManager) AddClientInMemory(config schemas.MCPClientConfig) error { return m.addClientInternal(config) }framework/oauth2/discovery.go (1)
406-412: Preferbytes.NewReaderoverstrings.NewReader(string(...)).The current code performs an unnecessary byte-to-string conversion. Using
bytes.NewReader(reqBody)directly is more efficient.♻️ Suggested improvement
Add
bytesto imports:import ( + "bytes" "context"Then update the request creation:
- httpReq, err := http.NewRequestWithContext(ctx, "POST", registrationURL, strings.NewReader(string(reqBody))) + httpReq, err := http.NewRequestWithContext(ctx, "POST", registrationURL, bytes.NewReader(reqBody))framework/oauth2/main.go (1)
497-501: Prefer bifrost.Ptr for TokenID pointer assignment.♻️ Suggested change
- oauthConfig.TokenID = &tokenID + oauthConfig.TokenID = bifrost.Ptr(tokenID)Based on learnings, use
bifrost.Ptr(...)instead of&for pointer creation in this repo.framework/configstore/rdb.go (3)
2712-2750: Inconsistent not-found semantics.These OAuth Get methods return
(nil, nil)when a record is not found, while most other Get methods in this file return(nil, ErrNotFound). For example:
GetMCPClientByIDreturnsErrNotFound(line 818)GetVirtualKeyreturnsErrNotFound(line 1294)GetTeamreturnsErrNotFound(line 1788)Consider aligning with the dominant pattern for consistency, or document why OAuth methods use different semantics.
♻️ Suggested consistency fix (example for GetOauthConfigByID)
func (s *RDBConfigStore) GetOauthConfigByID(ctx context.Context, id string) (*tables.TableOauthConfig, error) { var config tables.TableOauthConfig result := s.db.WithContext(ctx).Where("id = ?", id).First(&config) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, nil + return nil, ErrNotFound } return nil, fmt.Errorf("failed to get oauth config: %w", result.Error) } return &config, nil }
2752-2786: MissingparseGormErrorand transaction parameter support.The OAuth Create/Update methods don't use
parseGormErrorfor user-friendly constraint violation messages, and don't accept optionaltx ...*gorm.DBparameters for transaction support. This differs from other methods likeCreateMCPClientConfig(line 861),CreateVirtualKey(line 1335), etc.♻️ Suggested improvement (example for CreateOauthConfig)
-func (s *RDBConfigStore) CreateOauthConfig(ctx context.Context, config *tables.TableOauthConfig) error { - result := s.db.WithContext(ctx).Create(config) - if result.Error != nil { - return fmt.Errorf("failed to create oauth config: %w", result.Error) +func (s *RDBConfigStore) CreateOauthConfig(ctx context.Context, config *tables.TableOauthConfig, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 { + txDB = tx[0] + } else { + txDB = s.db } - return nil + if err := txDB.WithContext(ctx).Create(config).Error; err != nil { + return s.parseGormError(err) + } + return nil }
2788-2795:DeleteOauthTokensilently succeeds if ID doesn't exist.The method doesn't check
RowsAffected, so deleting a non-existent token succeeds silently. This may be intentional for idempotency, but differs from methods likeDeleteVirtualKeyProviderConfigwhich returnsErrNotFound.If idempotent deletes are desired, this is fine. Otherwise, consider adding a check.
|
|
||
| // Convert to allowed charset | ||
| b := make([]byte, length) | ||
| for i := range b { | ||
| b[i] = charset[int(randomBytes[i])%len(charset)] | ||
| } | ||
| verifier = string(b) |
There was a problem hiding this comment.
Modulo bias in PKCE verifier generation.
Using int(randomBytes[i]) % len(charset) introduces modulo bias since 256 is not evenly divisible by 66 (charset length). Characters at indices 0-57 have a ~0.15% higher probability than indices 58-65. While the impact on PKCE security is minimal due to the 128-character length, consider using rejection sampling for uniform distribution.
🔧 Suggested fix using rejection sampling
// Convert to allowed charset
b := make([]byte, length)
- for i := range b {
- b[i] = charset[int(randomBytes[i])%len(charset)]
+ charsetLen := byte(len(charset))
+ for i := 0; i < length; {
+ // Rejection sampling: reject values that would cause bias
+ // 256 / 66 = 3, so accept values < 3*66 = 198
+ if randomBytes[i] < 198 {
+ b[i] = charset[randomBytes[i]%charsetLen]
+ i++
+ } else {
+ // Generate new random byte for this position
+ if _, err := rand.Read(randomBytes[i : i+1]); err != nil {
+ return "", "", fmt.Errorf("failed to generate random bytes: %w", err)
+ }
+ }
}🤖 Prompt for AI Agents
In `@framework/oauth2/discovery.go` around lines 340 - 346, The PKCE verifier
generation uses int(randomBytes[i]) % len(charset) which creates modulo bias;
change this to rejection sampling: repeatedly draw random bytes and only map a
byte to charset when its value is < (256/len(charset))*len(charset) (i.e. the
largest multiple of charset length <256) to ensure uniform selection, filling
the b slice until it reaches the desired length; update the logic around the
verifier variable and use the existing charset and randomBytes identifiers (or a
new per-byte random draw) so verifier = string(b) remains the final step.
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
docs/openapi/schemas/management/mcp.yaml (1)
70-82: Resolve leftover merge conflict markers.
The schema will not parse with conflict markers in place.🐛 Suggested resolution
is_code_mode_client: type: boolean -<<<<<<< HEAD - is_ping_available: - type: boolean - default: true - description: | - Whether the MCP server supports ping for health checks. - If true, uses lightweight ping method for health checks. - If false, uses listTools method for health checks instead. -======= - description: Whether this client is available in code mode ->>>>>>> 4e846193 (feat: plugin schema extensions for mcp plugins) + description: Whether this client is available in code mode + is_ping_available: + type: boolean + default: true + description: | + Whether the MCP server supports ping for health checks. + If true, uses lightweight ping method for health checks. + If false, uses listTools method for health checks instead.
🤖 Fix all issues with AI agents
In `@docs/openapi/openapi.json`:
- Around line 166698-166704: MCPClientCreateRequest's connection_type enum
currently includes "inprocess" which has no matching discriminator/oneOf variant
and is not creatable via HTTP; remove "inprocess" from the enum array in the
OpenAPI schema so only valid HTTP-createable values remain (e.g., "http",
"stdio", "sse"), ensuring the enum in the MCPClientCreateRequest definition
matches available discriminator mappings/oneOf variants.
♻️ Duplicate comments (16)
framework/configstore/tables/oauth.go (1)
12-55: Sensitive OAuth secrets appear stored in plaintext.
This mirrors a previously flagged issue; please ensure encryption is applied before persistence or clarify the security posture.Also applies to: 49-57
framework/oauth2/discovery.go (1)
53-134: Guard logger usage to avoid nil dereference.
Same issue as prior review: unguarded logger calls can panic if SetLogger wasn’t invoked. Consider a helper to no-op when logger is nil.🧩 One way to harden log calls
+func logDebug(msg string) { + if logger != nil { + logger.Debug(msg) + } +} + +func logWarn(msg string) { + if logger != nil { + logger.Warn(msg) + } +} + +func logError(msg string) { + if logger != nil { + logger.Error(msg) + } +}Then replace
logger.Debug/Warn/Error(...)withlogDebug/logWarn/logError(...)throughout this file.To verify coverage, you can list remaining direct logger calls:
#!/bin/bash rg -n "logger\.(Debug|Warn|Error)" framework/oauth2/discovery.goAlso applies to: 216-243, 274-299, 352-353, 398-451
core/schemas/oauth.go (1)
37-46: Avoid JSON exposure of OAuth tokens.
This is the same concern as earlier: the JSON tags allow accidental leakage if the struct is ever returned. Prefer redaction orjson:"-".🔒 Safer JSON tags
type OAuth2Token struct { ID string `json:"id"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` + AccessToken string `json:"-"` + RefreshToken string `json:"-"` TokenType string `json:"token_type"` ExpiresAt time.Time `json:"expires_at"` Scopes []string `json:"scopes"` LastRefreshedAt *time.Time `json:"last_refreshed_at,omitempty"` }You can confirm current serialization sites with:
#!/bin/bash rg -n --type=go "OAuth2Token" -C 2framework/oauth2/main.go (6)
32-52: Add a shutdown mechanism for the cleanup goroutine.
The background cleanup runs forever and will leak if the provider is discarded.🔧 Suggested fix
type OAuth2Provider struct { configStore configstore.ConfigStore mu sync.RWMutex pendingMCPClients map[string]*PendingMCPClient // Key: mcp_client_id + stopCh chan struct{} } func NewOAuth2Provider(configStore configstore.ConfigStore, logger schemas.Logger) *OAuth2Provider { // ... p := &OAuth2Provider{ configStore: configStore, pendingMCPClients: make(map[string]*PendingMCPClient), + stopCh: make(chan struct{}), } go p.cleanupExpiredPendingClients() return p } +func (p *OAuth2Provider) Close() { + close(p.stopCh) +} + func (p *OAuth2Provider) cleanupExpiredPendingClients() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() - for range ticker.C { - p.mu.Lock() - now := time.Now() - for mcpClientID, pending := range p.pendingMCPClients { - if now.Sub(pending.CreatedAt) > 5*time.Minute { - delete(p.pendingMCPClients, mcpClientID) - logger.Debug("Cleaned up expired pending MCP client", "mcp_client_id", mcpClientID) - } - } - p.mu.Unlock() - } + for { + select { + case <-ticker.C: + p.mu.Lock() + now := time.Now() + for mcpClientID, pending := range p.pendingMCPClients { + if now.Sub(pending.CreatedAt) > 5*time.Minute { + delete(p.pendingMCPClients, mcpClientID) + logger.Debug("Cleaned up expired pending MCP client", "mcp_client_id", mcpClientID) + } + } + p.mu.Unlock() + case <-p.stopCh: + return + } + } }Also applies to: 243-259
92-95: Avoid wrapping a nil error when token reload returns nil.🔧 Suggested fix
- token, err = p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) - if err != nil || token == nil { - return "", fmt.Errorf("failed to reload token after refresh: %w", err) - } + token, err = p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) + if err != nil { + return "", fmt.Errorf("failed to reload token after refresh: %w", err) + } + if token == nil { + return "", fmt.Errorf("token not found after refresh") + }
117-129: Handle missing refresh_token and avoid sending empty client_secret.🔧 Suggested fix
// Load oauth_token token, err := p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) if err != nil || token == nil { return fmt.Errorf("oauth token not found: %w", err) } + if token.RefreshToken == "" { + return fmt.Errorf("refresh token missing; re-authorize the OAuth flow") + } func (p *OAuth2Provider) exchangeRefreshToken(tokenURL, clientID, clientSecret, refreshToken string) (*schemas.OAuth2TokenExchangeResponse, error) { data := url.Values{} data.Set("grant_type", "refresh_token") data.Set("refresh_token", refreshToken) data.Set("client_id", clientID) - data.Set("client_secret", clientSecret) + if clientSecret != "" { + data.Set("client_secret", clientSecret) + }RFC 6749 refresh token client authentication public clients client_secretAlso applies to: 556-563
442-468: Don’t ignore UpdateOauthConfig errors on expiry or exchange failure.🔧 Suggested fix
if time.Now().After(oauthConfig.ExpiresAt) { oauthConfig.Status = "expired" - p.configStore.UpdateOauthConfig(ctx, oauthConfig) + if err := p.configStore.UpdateOauthConfig(ctx, oauthConfig); err != nil { + logger.Error("Failed to update expired oauth config status", "error", err, "oauth_config_id", oauthConfig.ID) + } return fmt.Errorf("oauth flow expired") } if err != nil { oauthConfig.Status = "failed" - p.configStore.UpdateOauthConfig(ctx, oauthConfig) + if updateErr := p.configStore.UpdateOauthConfig(ctx, oauthConfig); updateErr != nil { + logger.Error("Failed to update oauth config status", "error", updateErr, "oauth_config_id", oauthConfig.ID) + } logger.Error("Token exchange failed", "error", err.Error(),
475-481: Handlejson.Marshalerror for scopes.🔧 Suggested fix
- scopesJSON, _ := json.Marshal(scopes) + scopesJSON, err := json.Marshal(scopes) + if err != nil { + return fmt.Errorf("failed to serialize token scopes: %w", err) + }
509-523: Build authorize URL safely when the base URL already has query params.🔧 Suggested fix
- return authorizeURL + "?" + params.Encode() + u, err := url.Parse(authorizeURL) + if err != nil { + return authorizeURL + "?" + params.Encode() + } + q := u.Query() + for k, vs := range params { + for _, v := range vs { + q.Add(k, v) + } + } + u.RawQuery = q.Encode() + return u.String()docs/openapi/paths/management/mcp.yaml (1)
221-232: Remove sibling descriptions from$refresponses (OpenAPI violation).
This is the same issue flagged earlier —$refobjects can’t have sibling fields likedescription.♻️ Minimal fix
- '400': - description: OAuth not authorized yet or MCP client not found in pending OAuth clients - $ref: '../../openapi.yaml#/components/responses/BadRequest' + '400': + $ref: '../../openapi.yaml#/components/responses/BadRequest' - '404': - description: MCP client not found in pending OAuth clients or OAuth config not found - $ref: '../../openapi.yaml#/components/responses/NotFound' + '404': + $ref: '../../openapi.yaml#/components/responses/NotFound'OpenAPI specification: can a response object include sibling fields alongside a $ref (e.g., description + $ref)?docs/openapi/paths/management/oauth.yaml (2)
18-34:codeshouldn’t be required when OAuth returns an error.Providers often redirect with
error/error_descriptionand nocode, so a requiredcodecan fail validation before your handler runs. Consider making it optional or modeling success/error variants.
74-79: Use response-level$reffor the 404 response.
../../openapi.yaml#/components/responses/NotFoundis a response object, not a schema. Reference it at the response level to match the 500 response style.🛠️ Proposed fix
'404': - description: OAuth config not found - content: - application/json: - schema: - $ref: '../../openapi.yaml#/components/responses/NotFound' + $ref: '../../openapi.yaml#/components/responses/NotFound'docs/openapi/openapi.json (4)
127852-127858: Fix discriminator mapping JSON pointers (currently invalid).Line 127855 uses
#/MCPClientCreateRequestHTTP, which is not a valid OpenAPI components pointer and won’t resolve in tooling. Use#/components/schemas/...and ensure the target schemas exist (or map to the existing create request schema if variants aren’t defined).🔧 Possible fix (if variant schemas exist)
- "http": "#/MCPClientCreateRequestHTTP", - "sse": "#/MCPClientCreateRequestSSE", - "stdio": "#/MCPClientCreateRequestSTDIO" + "http": "#/components/schemas/MCPClientCreateRequestHTTP", + "sse": "#/components/schemas/MCPClientCreateRequestSSE", + "stdio": "#/components/schemas/MCPClientCreateRequestSTDIO"
129208-129293: 404 response schema nestscontentinsideschema.Line 129213 embeds a
contentblock inside a schema object, which is invalid in OpenAPI 3.1. Reuse theNotFoundresponse component instead.🔧 Suggested fix
- "404": { - "description": "OAuth config not found", - "content": { - "application/json": { - "schema": { - "description": "Resource not found", - "content": { - "application/json": { - "schema": { ... } - } - } - } - } - } - }, + "404": { + "$ref": "#/components/responses/NotFound" + },
167057-167063: Discriminator mapping uses invalid JSON pointers here too.Same issue as above: these mappings need valid
#/components/schemas/...pointers and resolvable schemas.
167249-167252:server_urlis referenced but not defined in this schema.Line 167251 mentions
server_url, butOAuthConfigRequestdoesn’t define it. Either add the field or update the description to reference the actual property used for discovery (likely the MCP clientconnection_string).✏️ Possible doc fix
- "description": "OAuth client ID. Optional if client supports dynamic client registration (RFC 7591).\nIf not provided, the server_url must be set for OAuth discovery and dynamic registration.\n" + "description": "OAuth client ID. Optional if client supports dynamic client registration (RFC 7591).\nIf not provided, the MCP client connection_string must be set for OAuth discovery and dynamic registration.\n"
🧹 Nitpick comments (5)
core/schemas/mcp.go (2)
83-86: Consider usingMCPConnectionStateforStateto avoid invalid values.
Right nowStateis a plain string, which can drift from the defined connection states.♻️ Suggested tweak
- State string `json:"state,omitempty"` // Connection state (connected, disconnected, error) + State MCPConnectionState `json:"state,omitempty"` // Connection state (connected, disconnected, error)
118-148: Preserve custom headers when using OAuth auth type.
The OAuth branch setsAuthorizationbut dropsHeaders. If users need extra headers (e.g., versioning), they’re lost.♻️ Suggested merge
case MCPAuthTypeOauth: if c.OauthConfigID == nil { return nil, ErrOAuth2ConfigNotFound } if oauth2Provider == nil { return nil, ErrOAuth2ProviderNotAvailable } accessToken, err := oauth2Provider.GetAccessToken(ctx, *c.OauthConfigID) if err != nil { return nil, err } headers["Authorization"] = "Bearer " + accessToken + for key, value := range c.Headers { + if _, exists := headers[key]; !exists { + headers[key] = value.GetValue() + } + }framework/configstore/rdb.go (1)
792-805: Default emptyauth_typetoheadersto avoid blank API values.
IfAuthTypeis empty, it gets stored and surfaced as""instead of the default.♻️ Suggested guard
- AuthType: schemas.MCPAuthType(dbClient.AuthType), + authType := dbClient.AuthType + if authType == "" { + authType = string(schemas.MCPAuthTypeHeaders) + } + AuthType: schemas.MCPAuthType(authType),- AuthType: string(clientConfigCopy.AuthType), + authType := clientConfigCopy.AuthType + if authType == "" { + authType = schemas.MCPAuthTypeHeaders + } + AuthType: string(authType),Also applies to: 846-855
core/mcp/clientmanager.go (1)
113-157: Consider delegating toAddClientto avoid duplicated logic.♻️ Suggested simplification
func (m *MCPManager) AddClientInMemory(config schemas.MCPClientConfig) error { - if err := validateMCPClientConfig(&config); err != nil { - return fmt.Errorf("invalid MCP client configuration: %w", err) - } - // Make a copy of the config to use after unlocking - configCopy := config - m.mu.Lock() - if _, ok := m.clientMap[config.ID]; ok { - m.mu.Unlock() - return fmt.Errorf("client %s already exists", config.Name) - } - // Create placeholder entry - m.clientMap[config.ID] = &schemas.MCPClientState{ - Name: config.Name, - ExecutionConfig: config, - ToolMap: make(map[string]schemas.ChatTool), - } - // Temporarily unlock for the connection attempt - // This is to avoid deadlocks when the connection attempt is made - m.mu.Unlock() - // Connect using the copied config - if err := m.connectToMCPClient(configCopy); err != nil { - // Re-lock to clean up the failed entry - m.mu.Lock() - delete(m.clientMap, config.ID) - m.mu.Unlock() - return fmt.Errorf("failed to connect to MCP client %s: %w", config.Name, err) - } - return nil + return m.AddClient(config) }docs/openapi/openapi.json (1)
166494-166506: Prefer$reftoMCPAuthTypeto avoid enum duplication.Now that
MCPAuthTypeexists, referencing it will keep the enum consistent across schemas.♻️ Suggested refactor
- "auth_type": { - "type": "string", - "enum": [ - "none", - "headers", - "oauth" - ], - "description": "Authentication type for the MCP connection" - }, + "auth_type": { + "$ref": "#/components/schemas/MCPAuthType" + },
| "type": "string", | ||
| "enum": [ | ||
| "http", | ||
| "stdio", | ||
| "sse", | ||
| "inprocess" | ||
| ], |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's look at the context around the specified lines in the OpenAPI file
# The line numbers suggest this is a large file, so let's read the relevant section
wc -l docs/openapi/openapi.jsonRepository: maximhq/bifrost
Length of output: 91
🏁 Script executed:
# Read the context around line 166698-166704 with surrounding lines for full schema understanding
sed -n '166690,166730p' docs/openapi/openapi.jsonRepository: maximhq/bifrost
Length of output: 1978
🏁 Script executed:
# Search for "oneOf" patterns and discriminator usage around the relevant section
# to understand if there are variants for different connection types
sed -n '166650,166750p' docs/openapi/openapi.json | head -100Repository: maximhq/bifrost
Length of output: 4956
🏁 Script executed:
# Look at more context to see the full oneOf structure
sed -n '166650,166850p' docs/openapi/openapi.jsonRepository: maximhq/bifrost
Length of output: 9275
🏁 Script executed:
# Search for "discriminator" in the openapi file to understand the schema pattern
rg -n "discriminator" docs/openapi/openapi.json | head -20Repository: maximhq/bifrost
Length of output: 134
🏁 Script executed:
# Search the codebase for inprocess handler/connection support
rg -n -i "inprocess" --type=go | head -30Repository: maximhq/bifrost
Length of output: 3294
🏁 Script executed:
# Check the discriminator definitions to understand how oneOf is structured
sed -n '127850,127865p' docs/openapi/openapi.jsonRepository: maximhq/bifrost
Length of output: 843
🏁 Script executed:
# Check the second discriminator as well
sed -n '167055,167070p' docs/openapi/openapi.jsonRepository: maximhq/bifrost
Length of output: 879
🏁 Script executed:
# Search for the complete oneOf structure to see all variants and how they handle connection types
rg -n "MCPClientCreateRequest" docs/openapi/openapi.jsonRepository: maximhq/bifrost
Length of output: 475
🏁 Script executed:
# Let's see the full schema definition including all oneOf variants
sed -n '166674,166800p' docs/openapi/openapi.json | grep -A 5 -B 5 "MCPClientCreateRequest"Repository: maximhq/bifrost
Length of output: 196
🏁 Script executed:
# Get a better view of the complete oneOf array and variants
python3 << 'EOF'
import json
with open('docs/openapi/openapi.json', 'r') as f:
spec = json.load(f)
# Find MCPClientCreateRequest schema
mcp_create = spec['components']['schemas']['MCPClientCreateRequest']
# Print the keys
print("MCPClientCreateRequest structure:")
print(f"Has 'oneOf': {'oneOf' in mcp_create}")
print(f"Has 'allOf': {'allOf' in mcp_create}")
if 'oneOf' in mcp_create:
print(f"\nNumber of oneOf variants: {len(mcp_create['oneOf'])}")
for i, variant in enumerate(mcp_create['oneOf']):
print(f"\nVariant {i}: {list(variant.keys())}")
if 'allOf' in variant:
print(f" allOf blocks: {len(variant['allOf'])}")
if 'allOf' in mcp_create:
print(f"\nallOf blocks: {len(mcp_create['allOf'])}")
for i, block in enumerate(mcp_create['allOf']):
print(f"Block {i}: {list(block.keys())}")
if 'discriminator' in mcp_create:
print(f"\nDiscriminator:")
disc = mcp_create['discriminator']
print(f" propertyName: {disc.get('propertyName')}")
if 'mapping' in disc:
print(f" mapping keys: {list(disc['mapping'].keys())}")
EOFRepository: maximhq/bifrost
Length of output: 367
🏁 Script executed:
# Check if there are separate schema definitions for the variants
rg "MCPClientCreateRequest(HTTP|SSE|STDIO|InProcess)" docs/openapi/openapi.jsonRepository: maximhq/bifrost
Length of output: 392
🏁 Script executed:
# Now let's examine the full oneOf structure to see all 3 variants and understand the pattern
python3 << 'EOF'
import json
with open('docs/openapi/openapi.json', 'r') as f:
spec = json.load(f)
mcp_create = spec['components']['schemas']['MCPClientCreateRequest']
# Examine each variant
for i, variant in enumerate(mcp_create['oneOf']):
print(f"\n=== Variant {i} ===")
if 'allOf' in variant:
# Check the second allOf block which has the discriminator constraint
if len(variant['allOf']) > 1:
second_block = variant['allOf'][1]
if 'properties' in second_block and 'connection_type' in second_block['properties']:
conn_enum = second_block['properties']['connection_type'].get('enum', [])
print(f"connection_type enum: {conn_enum}")
if 'required' in second_block:
print(f"required fields: {second_block['required']}")
print("\n=== Discriminator mapping ===")
disc = mcp_create['discriminator']
print(f"Mapped connection types: {list(disc['mapping'].keys())}")
print("\n=== Base schema enum (first allOf in first variant) ===")
base_enum = mcp_create['oneOf'][0]['allOf'][0]['properties']['connection_type']['enum']
print(f"Enum values: {base_enum}")
print("\n=== Description ===")
print(mcp_create.get('description', 'No description'))
EOFRepository: maximhq/bifrost
Length of output: 825
🏁 Script executed:
# Double-check: search for any schema references to InProcess
rg "InProcess|inprocess" docs/openapi/openapi.json | grep -v "is_code_mode"Repository: maximhq/bifrost
Length of output: 1102
Remove inprocess from the connection_type enum in MCPClientCreateRequest.
The enum includes inprocess but there's no corresponding discriminator mapping or oneOf variant for it. The schema description confirms "InProcess: server instance must be provided programmatically (Go package only)". Since inprocess connections cannot be created via the HTTP API (only via Go code), it should be removed from the enum.
🔧 Recommended change
"enum": [
"http",
"stdio",
- "sse",
- "inprocess"
+ "sse"
],🤖 Prompt for AI Agents
In `@docs/openapi/openapi.json` around lines 166698 - 166704,
MCPClientCreateRequest's connection_type enum currently includes "inprocess"
which has no matching discriminator/oneOf variant and is not creatable via HTTP;
remove "inprocess" from the enum array in the OpenAPI schema so only valid
HTTP-createable values remain (e.g., "http", "stdio", "sse"), ensuring the enum
in the MCPClientCreateRequest definition matches available discriminator
mappings/oneOf variants.
ec62573 to
4df30b5
Compare
dd76b9e to
1579539
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
docs/openapi/schemas/management/mcp.yaml (1)
70-82: Resolve merge conflict markers inMCPClientCreateRequestBase.The conflict markers make this YAML invalid and will break schema bundling/parsing.
🛠️ Proposed fix
is_code_mode_client: type: boolean + description: Whether this client is available in code mode -<<<<<<< HEAD is_ping_available: type: boolean default: true description: | Whether the MCP server supports ping for health checks. If true, uses lightweight ping method for health checks. If false, uses listTools method for health checks instead. -======= - description: Whether this client is available in code mode ->>>>>>> 4e846193 (feat: plugin schema extensions for mcp plugins)
🤖 Fix all issues with AI agents
In `@docs/openapi/openapi.json`:
- Around line 127494-127499: The openapi schema lists "inprocess" in the
connection_type enum but that value is Go-only and not valid for the HTTP API;
remove "inprocess" from the enum arrays for the connection_type property
wherever it appears (including all oneOf variants) and ensure any discriminator
mappings or references do not include "inprocess" so the HTTP API schema only
exposes "http", "stdio", and "sse" (search for the connection_type enum entries
and the oneOf variants that reference them and delete the "inprocess" entry).
♻️ Duplicate comments (17)
framework/configstore/migrations.go (1)
2987-3035: Ensure authorize_url/token_url are explicitly made nullable during migration.
Relying on AutoMigrate won’t update constraints here, so upgrades from NOT NULL schemas can fail for discovery-only configs.docs/openapi/schemas/management/oauth.yaml (1)
68-117: Align OAuth status enum and redact token secrets in public schema.The public schema is still missing
expired/revokedstatuses and advertises token fields that aren’t returned in responses. Please align to the server contract and avoid documenting secrets.🛠️ Proposed fix
status: type: string - enum: [pending, authorized, failed] + enum: [pending, authorized, failed, expired, revoked] description: | Current status of the OAuth flow: - pending: User has not yet authorized - authorized: User authorized and token is stored - failed: Authorization failed + - expired: Authorization window expired + - revoked: Authorization revoked @@ properties: id: type: string description: Unique token identifier - access_token: - type: string - description: OAuth access token - refresh_token: - type: string - description: OAuth refresh token for obtaining new access tokens token_type: type: string description: Token type (typically "Bearer")docs/openapi/paths/management/oauth.yaml (2)
18-33: Makecodeoptional to allow OAuth error callbacks.Providers often redirect with
error/error_descriptionand nocode. Markingcodeas required can cause validation failures before the handler sees the error.🛠️ Proposed fix
- name: code in: query - required: true + required: false description: Authorization code from the OAuth provider schema: type: string
74-79: Use response-level$reffor the 404 response.The current
$refpoints to a response object but is nested underschema. Use a response-level reference instead.🛠️ Proposed fix
'404': - description: OAuth config not found - content: - application/json: - schema: - $ref: '../../openapi.yaml#/components/responses/NotFound' + $ref: '../../openapi.yaml#/components/responses/NotFound'docs/openapi/schemas/management/mcp.yaml (1)
189-197: Ensure update API persistsauth_typeandoauth_config_id.If the update handler doesn’t write these fields, updates will be no-ops. Please confirm the update map in the stack includes them; otherwise, align schema or backend.
#!/bin/bash # Inspect UpdateMCPClientConfig for persisted fields rg -n "UpdateMCPClientConfig" --type go -A 80 -B 5 rg -n "auth_type|oauth_config_id" framework/configstore/rdb.go -C 3As per coding guidelines, please verify this across the stacked PRs.
framework/oauth2/discovery.go (2)
53-134: Guard logger usage to avoid nil dereference during discovery/PKCE/registration.
loggercan be nil, but many calls are unguarded. Add a small safe logging wrapper (or guard each call) to prevent panics.🛠️ Example pattern (apply across this file)
+func logDebugf(format string, args ...any) { + if logger != nil { + logger.Debug(fmt.Sprintf(format, args...)) + } +} +func logWarnf(format string, args ...any) { + if logger != nil { + logger.Warn(fmt.Sprintf(format, args...)) + } +} +func logErrorf(format string, args ...any) { + if logger != nil { + logger.Error(fmt.Sprintf(format, args...)) + } +} @@ -logger.Debug(fmt.Sprintf("[OAuth Discovery] Server responded with status: %d", resp.StatusCode)) +logDebugf("[OAuth Discovery] Server responded with status: %d", resp.StatusCode)Also applies to: 216-229, 235-243, 273-299, 326-353, 398-451
341-345: Avoid modulo bias in PKCE verifier generation.The modulo mapping biases some characters; use rejection sampling for uniform distribution.
🛠️ Proposed fix
// Convert to allowed charset b := make([]byte, length) - for i := range b { - b[i] = charset[int(randomBytes[i])%len(charset)] - } + charsetLen := byte(len(charset)) + for i := 0; i < length; { + // Accept only values that won't introduce modulo bias + if randomBytes[i] < 198 { // 3 * 66 + b[i] = charset[randomBytes[i]%charsetLen] + i++ + } else { + if _, err := rand.Read(randomBytes[i : i+1]); err != nil { + return "", "", fmt.Errorf("failed to generate random bytes: %w", err) + } + } + }docs/openapi/openapi.json (5)
127852-127858: Discriminator mapping references remain invalid.The discriminator mappings still reference non-existent schemas (
#/MCPClientCreateRequestHTTP, etc.) with an invalid JSON pointer format. This issue was previously flagged and needs to be addressed for OpenAPI tooling to work correctly.
129208-129293: Invalid nested response structure in 404 still present.The 404 response contains an invalid nested structure:
schema→content→application/json→schema. This was previously flagged. The fix should reference theNotFoundresponse component:"$ref": "#/components/responses/NotFound".
166698-166704:inprocessshould be removed from component schema enum.Same issue as in the inline request body schema -
inprocessis in the enum but has no discriminator mapping and cannot be used via HTTP API.
167057-167063: Component schema has same invalid discriminator mapping.The discriminator mapping in the
MCPClientCreateRequestcomponent schema has the same invalid references as the inline schema.
167249-167252:server_urlreference in description still incorrect.The
client_iddescription referencesserver_urlwhich doesn't exist in this schema. This was previously flagged.framework/configstore/tables/mcp.go (1)
35-38: Avoid cascading deletes from oauth_configs into MCP clients.
OnDelete:CASCADEonOauthConfigIDcan wipe MCP client rows when an OAuth config is removed. PreferSET NULL(orRESTRICT) and keep the constraint in a single place.🛠️ Proposed fix
- AuthType string `gorm:"type:varchar(20);default:'headers'" json:"auth_type"` // "none", "headers", "oauth" - 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 + AuthType string `gorm:"type:varchar(20);default:'headers'" json:"auth_type"` // "none", "headers", "oauth" + OauthConfigID *string `gorm:"type:varchar(255);index;constraint:OnDelete:SET NULL" json:"oauth_config_id"` // Foreign key to oauth_configs.ID with SET NULL + OauthConfig *TableOauthConfig `gorm:"foreignKey:OauthConfigID;references:ID;constraint:OnDelete:SET NULL" json:"-"` // Gorm relationshipframework/configstore/tables/oauth.go (1)
12-57: Encrypt OAuth secrets before persistence.
ClientSecret,CodeVerifier,AccessToken, andRefreshTokenare stored in plaintext. Please encrypt before saving and decrypt on read (or integrate into save hooks).framework/oauth2/sync.go (2)
38-43: MakeStop()idempotent to avoid panic on multiple calls.🔧 Suggested fix
type TokenRefreshWorker struct { provider *OAuth2Provider refreshInterval time.Duration lookAheadWindow time.Duration // How far ahead to look for expiring tokens stopCh chan struct{} + stopOnce sync.Once logger schemas.Logger } @@ func (w *TokenRefreshWorker) Stop() { - close(w.stopCh) + w.stopOnce.Do(func() { close(w.stopCh) }) if w.logger != nil { w.logger.Info("Token refresh worker stopped") } }
126-134: Avoid data races in refresh interval setters.These setters can race with the running goroutine. Either guard with a mutex or document that they must be called before
Start().framework/configstore/store.go (1)
186-198: Update test mocks for new OAuth ConfigStore methods.Ensure
MockConfigStoreimplements all new OAuth methods so tests compile, especially across the stacked PRs. As per coding guidelines, review this in the context of the full stack.#!/bin/bash # Verify MockConfigStore contains all OAuth method stubs rg -n "type MockConfigStore" -A 400 transports/bifrost-http/lib/config_test.go rg -n "func \(m \*MockConfigStore\) (GetOauthConfigByID|GetOauthConfigByState|GetOauthConfigByTokenID|CreateOauthConfig|UpdateOauthConfig|GetOauthTokenByID|GetExpiringOauthTokens|CreateOauthToken|UpdateOauthToken|DeleteOauthToken)" -n transports/bifrost-http/lib/config_test.go
🧹 Nitpick comments (3)
docs/openapi/openapi.json (2)
129379-129418: Consider adding 404 response for revoke endpoint.The
DELETE /api/oauth/config/{id}/statusendpoint only defines 200 and 500 responses. Consider adding a 404 response for cases where the OAuth config ID doesn't exist, consistent with the GET endpoint on the same path.🔧 Suggested addition
} + }, + "404": { + "$ref": "#/components/responses/NotFound" } }
167355-167393: Consider markingaccess_tokenandrefresh_tokenas sensitive.The
OAuthTokenschema exposesaccess_tokenandrefresh_tokenfields. While the schema itself is correct, consider whether these should be returned in API responses at all, or if a redacted representation would be more secure.core/mcp/clientmanager.go (1)
113-157: Factor shared add/connect logic to avoid drift.
AddClientandAddClientInMemoryare currently identical; extracting a shared helper will reduce duplication and future divergence.
| "enum": [ | ||
| "http", | ||
| "stdio", | ||
| "sse", | ||
| "inprocess" | ||
| ], |
There was a problem hiding this comment.
Remove inprocess from connection_type enum in HTTP API schemas.
The inprocess connection type is documented as "Go package only" and cannot be created via the HTTP API, yet it appears in the enum. This may confuse API consumers and cause validation issues since there's no corresponding discriminator mapping. Consider removing it from the enum values in all oneOf variants.
🤖 Prompt for AI Agents
In `@docs/openapi/openapi.json` around lines 127494 - 127499, The openapi schema
lists "inprocess" in the connection_type enum but that value is Go-only and not
valid for the HTTP API; remove "inprocess" from the enum arrays for the
connection_type property wherever it appears (including all oneOf variants) and
ensure any discriminator mappings or references do not include "inprocess" so
the HTTP API schema only exposes "http", "stdio", and "sse" (search for the
connection_type enum entries and the oneOf variants that reference them and
delete the "inprocess" entry).
1579539 to
6c7a84b
Compare
4df30b5 to
1800642
Compare
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
docs/openapi/schemas/management/mcp.yaml (1)
72-82: Unresolved merge conflict markers will break YAML parsing.The file contains unresolved Git merge conflict markers that must be removed. This will cause the OpenAPI spec to be invalid.
🔧 Proposed fix
Resolve the conflict by keeping the appropriate version. Based on context, you likely want:
is_code_mode_client: type: boolean -<<<<<<< HEAD - is_ping_available: - type: boolean - default: true - description: | - Whether the MCP server supports ping for health checks. - If true, uses lightweight ping method for health checks. - If false, uses listTools method for health checks instead. -======= description: Whether this client is available in code mode ->>>>>>> 4e846193 (feat: plugin schema extensions for mcp plugins) connection_type: $ref: '#/MCPConnectionType'Or if
is_ping_availableshould be included, merge both:is_code_mode_client: type: boolean description: Whether this client is available in code mode + is_ping_available: + type: boolean + default: true + description: | + Whether the MCP server supports ping for health checks. + If true, uses lightweight ping method for health checks. + If false, uses listTools method for health checks instead. connection_type: $ref: '#/MCPConnectionType'framework/configstore/rdb.go (1)
921-932: UpdateMCPClientConfig does not persist auth_type or oauth_config_id.The update map excludes
auth_typeandoauth_config_id, meaning API requests to update these fields will be silently ignored. This inconsistency with the OpenAPI schema (which exposes these fields) andCreateMCPClientConfig(which does persist them) should be resolved.🔧 Proposed fix
Add the missing fields to the updates map:
updates := map[string]interface{}{ "name": clientConfigCopy.Name, "is_code_mode_client": clientConfigCopy.IsCodeModeClient, "tools_to_execute_json": string(toolsToExecuteJSON), "tools_to_auto_execute_json": string(toolsToAutoExecuteJSON), "headers_json": string(headersJSON), "tool_pricing_json": string(toolPricingJSON), "is_ping_available": clientConfigCopy.IsPingAvailable, + "auth_type": clientConfigCopy.AuthType, + "oauth_config_id": clientConfigCopy.OauthConfigID, "updated_at": time.Now(), }
🤖 Fix all issues with AI agents
In `@docs/mcp/oauth.mdx`:
- Around line 465-469: Docs say tokens are stored encrypted but the codebase
doesn't use encryption; update the implementation that persists and loads OAuth
tokens to call encrypt.Encrypt(...) before saving and encrypt.Decrypt(...) after
loading, or alternatively update the docs to remove the "stored encrypted"
claim. Locate the token persistence code paths (the functions/methods that save
and fetch OAuth token structs—search for methods named SaveToken,
StoreOAuthToken, GetToken, LoadOAuthToken or the repository/dao handling OAuth
tokens) and wrap outgoing saves with encrypt.Encrypt(...) and incoming reads
with encrypt.Decrypt(...), handling and logging errors from encrypt.* and
updating any related tests or docs (or change docs/mcp/oauth.mdx to accurately
state tokens are stored plaintext if you choose not to implement encryption).
In `@docs/openapi/openapi.json`:
- Around line 129379-129417: The DELETE operation revokeOAuthConfig is missing a
404 response for non-existent OAuth config IDs; update the OpenAPI spec for
operationId "revokeOAuthConfig" to add a 404 response entry alongside the
existing 200/500 responses that returns application/json with a clear schema
(e.g., status and message or an error object) and a description like "OAuth
config not found" so clients can distinguish not-found errors from success and
server errors.
In `@framework/configstore/rdb.go`:
- Around line 2712-2723: The OAuth getter methods return (nil, nil) on
record-not-found which is inconsistent with other getters; update
GetOauthConfigByID, GetOauthConfigByState, GetOauthTokenByID, and
GetOauthConfigByTokenID to return (nil, ErrNotFound) instead of (nil, nil) when
errors.Is(result.Error, gorm.ErrRecordNotFound) so callers relying on
ErrNotFound (like sync.go) behave consistently; locate these functions in rdb.go
and replace the nil-nil return paths with nil, ErrNotFound while preserving
existing error wrapping for other errors.
♻️ Duplicate comments (18)
framework/oauth2/discovery.go (1)
341-345: Eliminate modulo bias in PKCE verifier generation.The current
% len(charset)mapping introduces modulo bias. Use rejection sampling to keep uniform distribution. This was flagged earlier; keeping for completeness.framework/oauth2/main.go (6)
243-258: Cleanup goroutine needs a stop mechanism.
cleanupExpiredPendingClientsruns forever; if the provider is discarded, the goroutine leaks. Add a stop channel andClose()to shut it down.
117-131: Handle public-client refresh properly and guard missing refresh tokens.
exchangeRefreshTokenalways sendsclient_secret, and refresh attempts proceed even ifrefresh_tokenis empty. Public clients should omit empty secrets; missing refresh tokens should fail fast.Also applies to: 556-563
92-95: Avoid wrapping a nil error after refresh.
fmt.Errorf("...: %w", err)yields%!w(<nil>)whenerr == nilandtoken == nil. Split the checks for clarity.
442-446: Handle UpdateOauthConfig errors on expiry/failure paths.Both status-update calls ignore errors, which can leave stale state in storage. Capture and log/return the update error.
Also applies to: 465-468
480-480: Don’t ignore json.Marshal errors when persisting scopes.Even for
[]string, propagate the marshal error to keep consistency with other JSON writes.
509-523: Preserve existing query params in authorize URL.String concatenation (
authorizeURL + "?" + params.Encode()) breaks when the base URL already has query parameters. Parse and merge the query instead.docs/openapi/paths/management/mcp.yaml (1)
221-233:$refresponses can’t include siblingdescription.OpenAPI disallows sibling fields next to
$ref. Either inline the response or remove the custom description.framework/configstore/migrations.go (1)
3032-3035: Explicitly drop NOT NULL onauthorize_url/token_urlin migration.The migration relies on AutoMigrate to relax NOT NULL, but
triggerMigrationsdoesn’t run AutoMigrate. Add dialect-specific ALTER statements to ensure discovery-only configs can be saved on upgrade.docs/openapi/schemas/management/mcp.yaml (1)
189-204: Update schema exposes fields the backend doesn't persist.
MCPClientUpdateRequestincludesauth_typeandoauth_config_id, butUpdateMCPClientConfiginframework/configstore/rdb.go(lines 923-932) does not include these fields in the update map. Updates to these fields will be silently ignored.docs/openapi/schemas/management/oauth.yaml (2)
75-82: OAuthConfigStatus enum is missing statuses used by the backend.The server code sets
expiredandrevokedstatuses (inframework/oauth2/main.goandframework/oauth2/sync.go), but these are not included in the enum. This was flagged in a previous review.
104-132: OAuthToken schema exposes sensitive fields that are hidden in API responses.
TableOauthTokenusesjson:"-"tags onAccessTokenandRefreshToken, so these fields are never included in actual API responses. Exposing them in the OpenAPI schema is misleading and could confuse API consumers. This was flagged in a previous review.docs/openapi/openapi.json (6)
127494-127499: Removeinprocessfromconnection_typeenum in HTTP API schemas.The
inprocessconnection type is documented as "Go package only" (line 127860) and cannot be created via the HTTP API, yet it appears in all three oneOf variant enums. This may confuse API consumers and will cause validation issues since there's no corresponding discriminator mapping.
127852-127858: Fix discriminator mapping references — invalid format and missing schema definitions.The discriminator mappings use
#/MCPClientCreateRequestHTTPwhich is not a valid OpenAPI 3.1 JSON Pointer (missing/components/schemaspath segment). Additionally, the referenced schemas (MCPClientCreateRequestHTTP,MCPClientCreateRequestSSE,MCPClientCreateRequestSTDIO) don't exist incomponents/schemas.Either:
- Fix the mapping values to valid JSON Pointers and add the missing schema definitions, OR
- Remove the discriminator if tooling doesn't require it (the
oneOfwith constrainedconnection_typeenums per variant already provides discrimination)🔧 If keeping discriminator, expected format
"discriminator": { "propertyName": "connection_type", "mapping": { - "http": "#/MCPClientCreateRequestHTTP", - "sse": "#/MCPClientCreateRequestSSE", - "stdio": "#/MCPClientCreateRequestSTDIO" + "http": "#/components/schemas/MCPClientCreateRequestHTTP", + "sse": "#/components/schemas/MCPClientCreateRequestSSE", + "stdio": "#/components/schemas/MCPClientCreateRequestSTDIO" } }And define the corresponding schemas in
components/schemas.
129208-129293: Fix nested response structure in 404 error schema.The 404 response contains an invalid nested structure:
schema→content→application/json→schema. In OpenAPI 3.x, a schema object cannot contain acontentkey—that key belongs at the response level, not inside the schema.Use a
$refto theNotFoundresponse component (defined in hunk 8 at lines 147770-147850) instead.🔧 Proposed fix
"404": { - "description": "OAuth config not found", - "content": { - "application/json": { - "schema": { - "description": "Resource not found", - "content": { - "application/json": { - "schema": { ... } - } - } - } - } - } + "$ref": "#/components/responses/NotFound" }
166698-166704: Removeinprocessfromconnection_typeenum.Same issue as in the request body schema—
inprocessis Go-only and should not appear in the HTTP API schema.
167057-167063: Fix discriminator mapping references.Same issue as in the request body schema—invalid JSON Pointer format and missing schema definitions.
167249-167252: Clarifyserver_urlreference in OAuthConfigRequest docs.The
client_iddescription states "If not provided, the server_url must be set for OAuth discovery and dynamic registration" butserver_urlis not defined in this schema. Either add aserver_urlproperty or update the description to reference the actual property (e.g.,connection_stringfrom the parent MCP client request).
🧹 Nitpick comments (3)
framework/oauth2/main.go (1)
498-499: Preferbifrost.Ptr(...)for pointer creation.Replace
&tokenIDwithbifrost.Ptr(tokenID)to align with repo conventions. Based on learnings, ...docs/openapi/schemas/management/oauth.yaml (1)
3-10: MCPAuthType is duplicated across schema files.
MCPAuthTypeis defined both here and indocs/openapi/schemas/management/mcp.yaml. This duplication risks the definitions drifting apart. Based on learnings, consider defining it in one file and using$refto reference it from the other.♻️ Suggested approach
Keep
MCPAuthTypeinoauth.yaml(since it's OAuth-related) and reference it frommcp.yaml:In
mcp.yaml, replace the inline definition:# Remove the MCPAuthType definition from mcp.yaml and use a reference instead: auth_type: $ref: '../../schemas/management/oauth.yaml#/MCPAuthType'framework/configstore/rdb.go (1)
2797-2807: GetExpiringOauthTokens query may return already-expired tokens.The current query
expires_at < ?will return tokens that have already expired. For a token refresh worker, you likely want tokens that are about to expire (e.g., within a refresh window) but haven't expired yet.If the intent is to refresh tokens proactively before they expire, consider documenting the expected usage or adjusting the query:
// GetExpiringOauthTokens retrieves tokens expiring before the given time. // Callers should pass a time in the future (e.g., time.Now().Add(5*time.Minute)) // to get tokens that need proactive refresh.Alternatively, if you want to exclude already-expired tokens:
Where("expires_at < ? AND expires_at > ?", before, time.Now())
| ## Security Considerations | ||
|
|
||
| - **Token Storage** - Bifrost stores OAuth tokens in the database encrypted. Never log or expose tokens. | ||
| - **PKCE Requirement** - For public clients, PKCE is automatically enabled and verified. | ||
| - **State Parameter** - CSRF protection via state parameter is enforced in OAuth flows. |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Search for OAuth token storage and encryption-related code
rg -i "oauth.*token" --type js --type ts --type python -l | head -20Repository: maximhq/bifrost
Length of output: 89
🏁 Script executed:
# Look for token storage implementations
rg -i "store.*token\|save.*token\|token.*database" --type js --type ts --type python -A 3 | head -40Repository: maximhq/bifrost
Length of output: 89
🏁 Script executed:
# Search for encryption utilities and their usage with tokens
rg -i "encrypt.*token\|token.*encrypt" --type js --type ts --type python -A 3Repository: maximhq/bifrost
Length of output: 89
🏁 Script executed:
# Look for database models related to OAuth
rg -i "oauth.*model\|token.*model\|bifrost" --type js --type ts --type python -l | head -20Repository: maximhq/bifrost
Length of output: 89
🏁 Script executed:
# Check for any PR/stack context in the repository
git log --oneline --all -20Repository: maximhq/bifrost
Length of output: 1587
🏁 Script executed:
# Search for oauth token handling in the repository structure
find . -type f -name "*.ts" -o -name "*.js" -o -name "*.py" | grep -i oauth | head -20Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Find all files containing oauth in their name or content
find . -type f \( -name "*oauth*" -o -name "*token*" \) | grep -v node_modules | grep -v ".git" | head -20Repository: maximhq/bifrost
Length of output: 972
🏁 Script executed:
# Search for OAuth-related code without file type filter
rg "oauth" --type-add 'all:*' -l | grep -v node_modules | head -30Repository: maximhq/bifrost
Length of output: 118
🏁 Script executed:
# Look for token storage implementations
rg "store.*token\|token.*storage\|token.*database" -A 3 | head -50Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Search for encryption implementations
rg "encrypt\|crypto" -l | grep -v node_modules | head -20Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Check the recent OAuth commit for what was added
git show --stat 18006422Repository: maximhq/bifrost
Length of output: 50372
🏁 Script executed:
# Check the OAuth schema
cat -n core/schemas/oauth.goRepository: maximhq/bifrost
Length of output: 4046
🏁 Script executed:
# Check the configstore OAuth table
cat -n framework/configstore/tables/oauth.goRepository: maximhq/bifrost
Length of output: 4616
🏁 Script executed:
# Check the OAuth2 handler
head -100 transports/bifrost-http/handlers/oauth2.goRepository: maximhq/bifrost
Length of output: 3546
🏁 Script executed:
# Search for encryption functions in the codebase
rg -i "encrypt\|cipher\|crypto" --no-heading -l | grep -v ".git\|node_modules" | head -30Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Search for encryption or cipher implementations more broadly
find . -type f \( -name "*.go" \) | xargs grep -l "encrypt\|Encrypt\|cipher\|Cipher" | grep -v ".git\|vendor" | head -20Repository: maximhq/bifrost
Length of output: 800
🏁 Script executed:
# Look for token encryption/decryption implementations
rg "AccessToken|RefreshToken" --type go -A 5 -B 2 | head -80Repository: maximhq/bifrost
Length of output: 5304
🏁 Script executed:
# Check if there's encryption middleware or utilities
find . -type f -name "*.go" -path "*/oauth*" -o -path "*/encrypt*" -o -path "*/crypto*" | head -20Repository: maximhq/bifrost
Length of output: 367
🏁 Script executed:
# Search for where tokens are stored/retrieved
rg "SaveOAuthToken|GetAccessToken|StoreToken" --type go -A 10 | head -100Repository: maximhq/bifrost
Length of output: 2161
🏁 Script executed:
# Check the encryption module
cat -n framework/encrypt/encrypt.goRepository: maximhq/bifrost
Length of output: 5707
🏁 Script executed:
# Check configstore methods for storing tokens
rg "SaveOAuthToken|CreateOAuthToken|UpdateOAuthToken" --type go -A 15 | head -100Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Look for encryption calls in configstore RDB
rg "Encrypt|Decrypt" framework/configstore/rdb.go -A 3 -B 3 | head -80Repository: maximhq/bifrost
Length of output: 996
🏁 Script executed:
# Search for where OAuth tokens are created/saved in configstore
find . -type f -name "*.go" -path "*/configstore/*" | xargs grep -l "oauth" | head -10Repository: maximhq/bifrost
Length of output: 205
🏁 Script executed:
# Look at the actual configstore implementation for OAuth operations
rg "CreateOAuthToken|SaveOAuthToken" --type go -B 5 -A 20Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# Check if Encrypt/Decrypt are called anywhere with OAuth tokens
rg "encrypt\.(Encrypt|Decrypt)" --type go -B 3 -A 3 | grep -A 10 -B 10 "token\|oauth"Repository: maximhq/bifrost
Length of output: 41
🏁 Script executed:
# List all usages of encrypt.Encrypt and encrypt.Decrypt
rg "encrypt\.(Encrypt|Decrypt)" --type go | grep -v "test"Repository: maximhq/bifrost
Length of output: 247
Docs claim encrypted token storage but encryption is not implemented — update docs or implement encryption before shipping.
The documentation states tokens are "stored encrypted," but the codebase does not encrypt OAuth tokens. The framework/encrypt/encrypt.go module header explicitly states: "We are not using it anywhere yet — we will introduce encryption for all the sensitive data in one go to avoid breaking changes." Tokens are stored as plain text in the database. Either implement encryption for OAuth tokens (using the existing encrypt.Encrypt() function when saving and encrypt.Decrypt() when retrieving) or update the docs to accurately reflect that tokens are stored unencrypted.
🤖 Prompt for AI Agents
In `@docs/mcp/oauth.mdx` around lines 465 - 469, Docs say tokens are stored
encrypted but the codebase doesn't use encryption; update the implementation
that persists and loads OAuth tokens to call encrypt.Encrypt(...) before saving
and encrypt.Decrypt(...) after loading, or alternatively update the docs to
remove the "stored encrypted" claim. Locate the token persistence code paths
(the functions/methods that save and fetch OAuth token structs—search for
methods named SaveToken, StoreOAuthToken, GetToken, LoadOAuthToken or the
repository/dao handling OAuth tokens) and wrap outgoing saves with
encrypt.Encrypt(...) and incoming reads with encrypt.Decrypt(...), handling and
logging errors from encrypt.* and updating any related tests or docs (or change
docs/mcp/oauth.mdx to accurately state tokens are stored plaintext if you choose
not to implement encryption).
| "delete": { | ||
| "operationId": "revokeOAuthConfig", | ||
| "summary": "Revoke OAuth config", | ||
| "description": "Revokes an OAuth configuration and its associated access token.\nAfter revocation, the MCP client will no longer be able to use this OAuth token.\n", | ||
| "tags": [ | ||
| "OAuth" | ||
| ], | ||
| "parameters": [ | ||
| { | ||
| "name": "id", | ||
| "in": "path", | ||
| "required": true, | ||
| "description": "OAuth config ID", | ||
| "schema": { | ||
| "type": "string" | ||
| } | ||
| } | ||
| ], | ||
| "responses": { | ||
| "200": { | ||
| "description": "OAuth token revoked successfully", | ||
| "content": { | ||
| "application/json": { | ||
| "schema": { | ||
| "type": "object", | ||
| "description": "Generic success response", | ||
| "properties": { | ||
| "status": { | ||
| "type": "string", | ||
| "example": "success" | ||
| }, | ||
| "message": { | ||
| "type": "string", | ||
| "example": "Operation completed successfully" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Add 404 response for DELETE endpoint when OAuth config doesn't exist.
The DELETE endpoint for revoking OAuth config only specifies 200 and 500 responses. It should also handle the case where the OAuth config ID doesn't exist, returning a 404 response.
🔧 Proposed addition
}
},
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
"500": {
"description": "Internal server error",🤖 Prompt for AI Agents
In `@docs/openapi/openapi.json` around lines 129379 - 129417, The DELETE operation
revokeOAuthConfig is missing a 404 response for non-existent OAuth config IDs;
update the OpenAPI spec for operationId "revokeOAuthConfig" to add a 404
response entry alongside the existing 200/500 responses that returns
application/json with a clear schema (e.g., status and message or an error
object) and a description like "OAuth config not found" so clients can
distinguish not-found errors from success and server errors.
| // GetOauthConfigByID retrieves an OAuth config by its ID | ||
| func (s *RDBConfigStore) GetOauthConfigByID(ctx context.Context, id string) (*tables.TableOauthConfig, error) { | ||
| var config tables.TableOauthConfig | ||
| result := s.db.WithContext(ctx).Where("id = ?", id).First(&config) | ||
| if result.Error != nil { | ||
| if errors.Is(result.Error, gorm.ErrRecordNotFound) { | ||
| return nil, nil | ||
| } | ||
| return nil, fmt.Errorf("failed to get oauth config: %w", result.Error) | ||
| } | ||
| return &config, nil | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# First, verify ErrNotFound is defined
echo "=== Checking ErrNotFound definition ==="
rg -n "ErrNotFound\s*=" framework/configstore/rdb.go | head -5
echo -e "\n=== Checking GetMCPClientByID around line 817-818 ==="
sed -n '810,830p' framework/configstore/rdb.go
echo -e "\n=== Checking the actual GetOauthConfigByID function ==="
sed -n '2712,2723p' framework/configstore/rdb.go
echo -e "\n=== Checking other OAuth getter methods ==="
rg -n "func.*GetOauthConfig|func.*GetOauthToken" framework/configstore/rdb.go | head -20Repository: maximhq/bifrost
Length of output: 2047
🏁 Script executed:
#!/bin/bash
# Check all return patterns in getter methods
echo "=== Checking return nil, nil patterns ==="
rg -B5 "return nil, nil" framework/configstore/rdb.go | grep -E "(func|return nil, nil)" | head -30
echo -e "\n=== Checking return nil, ErrNotFound patterns ==="
rg -B5 "return nil, ErrNotFound" framework/configstore/rdb.go | grep -E "(func|return nil, ErrNotFound)" | head -30Repository: maximhq/bifrost
Length of output: 3265
🏁 Script executed:
#!/bin/bash
# Check git history for context on this file
echo "=== Recent changes to this file ==="
git log --oneline -10 -- framework/configstore/rdb.go
echo -e "\n=== Check if there's a stack of PRs ==="
git log --oneline --all | head -20Repository: maximhq/bifrost
Length of output: 1704
🏁 Script executed:
#!/bin/bash
# Check the other OAuth getter methods
echo "=== GetOauthConfigByState (line 2727) ==="
sed -n '2727,2740p' framework/configstore/rdb.go
echo -e "\n=== GetOauthTokenByID (line 2740) ==="
sed -n '2740,2753p' framework/configstore/rdb.go
echo -e "\n=== GetOauthConfigByTokenID (line 2810) ==="
sed -n '2810,2823p' framework/configstore/rdb.go
echo -e "\n=== Search for ErrNotFound definition ==="
rg -n "var\s+ErrNotFound|ErrNotFound\s*:=|const.*ErrNotFound" framework/configstore/Repository: maximhq/bifrost
Length of output: 1928
🏁 Script executed:
#!/bin/bash
# Check where ErrNotFound is defined in configstore package
echo "=== All files in configstore ==="
ls -la framework/configstore/
echo -e "\n=== Search ErrNotFound across configstore package ==="
rg -n "ErrNotFound" framework/configstore/ | grep -E "(=|:=|var|const)" | head -10Repository: maximhq/bifrost
Length of output: 1289
🏁 Script executed:
#!/bin/bash
# Check if this is consistent with the design in the codebase
echo "=== Checking usage patterns - who calls these OAuth methods ==="
rg -n "GetOauthConfigByID|GetOauthConfigByState|GetOauthTokenByID|GetOauthConfigByTokenID" --type-list | grep -v rdb.go | head -20Repository: maximhq/bifrost
Length of output: 529
🏁 Script executed:
#!/bin/bash
# Search for callers of OAuth getter methods
echo "=== Searching for calls to GetOauthConfigByID ==="
rg "GetOauthConfigByID" --type go | grep -v "func (s \*RDBConfigStore) GetOauthConfigByID" | head -20
echo -e "\n=== Searching for calls to GetOauthConfigByState ==="
rg "GetOauthConfigByState" --type go | grep -v "func (s \*RDBConfigStore) GetOauthConfigByState" | head -20
echo -e "\n=== Searching for calls to GetOauthTokenByID ==="
rg "GetOauthTokenByID" --type go | grep -v "func (s \*RDBConfigStore) GetOauthTokenByID" | head -20
echo -e "\n=== Searching for calls to GetOauthConfigByTokenID ==="
rg "GetOauthConfigByTokenID" --type go | grep -v "func (s \*RDBConfigStore) GetOauthConfigByTokenID" | head -20Repository: maximhq/bifrost
Length of output: 2779
🏁 Script executed:
#!/bin/bash
# Check the errors.go file to understand the purpose of ErrNotFound
echo "=== Contents of errors.go ==="
cat framework/configstore/errors.go
echo -e "\n=== Methods using ErrNotFound vs methods using (nil, nil) ==="
echo "Methods returning ErrNotFound:"
rg "return nil, ErrNotFound" framework/configstore/rdb.go | wc -l
echo "Methods returning (nil, nil):"
rg "return nil, nil" framework/configstore/rdb.go | wc -lRepository: maximhq/bifrost
Length of output: 588
🏁 Script executed:
#!/bin/bash
# Check if there's a pattern - do MCP/Provider methods differ from OAuth in other ways?
echo "=== MCP methods ==="
rg -A8 "func.*GetMCP" framework/configstore/rdb.go | head -40
echo -e "\n=== Provider methods ==="
rg -A8 "func.*GetProvider\(" framework/configstore/rdb.go | head -40Repository: maximhq/bifrost
Length of output: 1575
🏁 Script executed:
#!/bin/bash
# Check how the callers handle the OAuth method results
echo "=== Check oauth2.go handler - how it handles GetOauthConfigByID ==="
rg -A5 "GetOauthConfigByID" transports/bifrost-http/handlers/oauth2.go | head -30
echo -e "\n=== Check oauth2 plugin - how it handles GetOauthConfigByID ==="
rg -A5 "GetOauthConfigByID" framework/oauth2/main.go | head -40
echo -e "\n=== Check how sync.go handles GetOauthConfigByTokenID ==="
rg -A5 "GetOauthConfigByTokenID" framework/oauth2/sync.go | head -20Repository: maximhq/bifrost
Length of output: 1533
🏁 Script executed:
#!/bin/bash
# Verify when these methods were added
echo "=== Check git blame for OAuth methods ==="
git blame -L 2713,2723 framework/configstore/rdb.go
echo -e "\n=== Check git blame for MCP methods ==="
git blame -L 817,825 framework/configstore/rdb.goRepository: maximhq/bifrost
Length of output: 1949
Fix inconsistent error handling for OAuth getter methods.
OAuth getter methods (GetOauthConfigByID, GetOauthConfigByState, GetOauthTokenByID, GetOauthConfigByTokenID) return (nil, nil) when a record is not found, while similar methods like GetMCPClientByID and GetProvider return (nil, ErrNotFound). This inconsistency within the same feature commit creates an error-handling gap: callers in sync.go only check if err != nil and will miss not-found cases, while callers in oauth2/main.go must defensively check both if err != nil and if oauthConfig == nil.
🔧 Proposed fix
Replace return nil, nil with return nil, ErrNotFound in all four OAuth getter methods to match the pattern used in MCP and Provider methods:
func (s *RDBConfigStore) GetOauthConfigByID(ctx context.Context, id string) (*tables.TableOauthConfig, error) {
var config tables.TableOauthConfig
result := s.db.WithContext(ctx).Where("id = ?", id).First(&config)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
- return nil, nil
+ return nil, ErrNotFound
}
return nil, fmt.Errorf("failed to get oauth config: %w", result.Error)
}
return &config, nil
}Apply the same fix to GetOauthConfigByState, GetOauthTokenByID, and GetOauthConfigByTokenID.
🤖 Prompt for AI Agents
In `@framework/configstore/rdb.go` around lines 2712 - 2723, The OAuth getter
methods return (nil, nil) on record-not-found which is inconsistent with other
getters; update GetOauthConfigByID, GetOauthConfigByState, GetOauthTokenByID,
and GetOauthConfigByTokenID to return (nil, ErrNotFound) instead of (nil, nil)
when errors.Is(result.Error, gorm.ErrRecordNotFound) so callers relying on
ErrNotFound (like sync.go) behave consistently; locate these functions in rdb.go
and replace the nil-nil return paths with nil, ErrNotFound while preserving
existing error wrapping for other errors.
1800642 to
4a60c86
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
core/mcp/toolmanager.go (1)
469-505: Tool calls now use de-sanitized names; this breaks the MCP naming invariant.Line 470–481 switches
CallToolParams.NametooriginalMCPToolName. The MCP stack expects sanitized names (-→_) end-to-end; reversing can cause tool lookup failures or mismatched telemetry. Unless the MCP server contract has changed across the stack, keep sanitized names for calls (or add a fallback path).🐛 Proposed fix
- Name: originalMCPToolName, + Name: sanitizedToolName,Based on learnings, MCP tool names should remain sanitized throughout the system.
docs/openapi/schemas/management/mcp.yaml (1)
72-82: Critical: Unresolved merge conflict markers in file.There are unresolved Git merge conflict markers (
<<<<<<< HEAD,=======,>>>>>>>) in the file. This will cause YAML parsing failures and must be resolved before merge.🛠️ Suggested fix (keep both features)
is_code_mode_client: type: boolean -<<<<<<< HEAD - is_ping_available: - type: boolean - default: true - description: | - Whether the MCP server supports ping for health checks. - If true, uses lightweight ping method for health checks. - If false, uses listTools method for health checks instead. -======= description: Whether this client is available in code mode ->>>>>>> 4e846193 (feat: plugin schema extensions for mcp plugins) + is_ping_available: + type: boolean + default: true + description: | + Whether the MCP server supports ping for health checks. + If true, uses lightweight ping method for health checks. + If false, uses listTools method for health checks instead.transports/bifrost-http/handlers/mcp.go (1)
259-301: Propagateis_ping_availableinto pending and final MCP configs.
OAuth flows currently drop this flag, defaulting tofalseeven when the UI settrue.🛠️ Suggested fix
pendingConfig := schemas.MCPClientConfig{ ID: req.ClientID, Name: req.Name, IsCodeModeClient: req.IsCodeModeClient, 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, + IsPingAvailable: req.IsPingAvailable, } @@ schemasConfig := schemas.MCPClientConfig{ ID: req.ClientID, Name: req.Name, IsCodeModeClient: req.IsCodeModeClient, ConnectionType: schemas.MCPConnectionType(req.ConnectionType), ConnectionString: req.ConnectionString, StdioConfig: req.StdioConfig, AuthType: schemas.MCPAuthType(req.AuthType), OauthConfigID: nil, ToolsToExecute: req.ToolsToExecute, ToolsToAutoExecute: req.ToolsToAutoExecute, Headers: req.Headers, + IsPingAvailable: req.IsPingAvailable, }
🤖 Fix all issues with AI agents
In `@core/mcp/codemodeexecutecode.go`:
- Around line 786-790: The code replaced uses of sanitizedToolName with
originalMCPToolName, which breaks internal MCP resolution; revert
tool-resolution and metadata usage to sanitizedToolName (the value produced by
stripClientPrefix) throughout the code paths that call tools (including direct
and plugin-backed calls where originalMCPToolName is now used), and implement a
fallback: attempt resolution with sanitizedToolName first and only
query/getOriginalToolName (via getOriginalToolName) and retry with
originalMCPToolName if the sanitized lookup fails. Ensure stripClientPrefix,
sanitizedToolName, getOriginalToolName, and originalMCPToolName are used exactly
as described so metadata and primary call paths keep sanitized names.
In `@ui/app/workspace/mcp-gateway/views/mcpClientForm.tsx`:
- Around line 228-241: The headers object is always included regardless of auth
selection, which can persist/send secrets when auth_type is "oauth" or "none";
update the headers assignment in the object construction so headers are only set
when form.auth_type indicates header-based auth (e.g. form.auth_type ===
"headers") and form.headers has keys (replace the current headers: form.headers
&& Object.keys(form.headers).length > 0 ? form.headers : undefined with a
conditional that also checks form.auth_type === "headers"); keep all other
behavior (oauth_config, scopes parsing, server_url) unchanged.
♻️ Duplicate comments (35)
docs/openapi/bundle.py (1)
435-453: Guard against--inlineclobbering the input file when--outputmatches--input.Inline mode still writes YAML to
input_pathand then writes JSON tooutput_pathwithout a collision check. If both resolve to the same file, the JSON write overwrites the YAML. This was raised previously; please add a path collision guard.framework/configstore/migrations.go (1)
3032-3035: Explicitly drop NOT NULL onauthorize_url/token_urlfor discovery configs.
AutoMigrate isn’t invoked here, so existing NOT NULL constraints will persist and break discovery-only configs.docs/openapi/paths/management/mcp.yaml (1)
221-232:$refresponses can’t include siblingdescription.
OpenAPI requires either a pure$refor a fully inlined response object.transports/bifrost-http/lib/config.go (1)
1596-1601: Guard OAuth provider/refresh worker init when ConfigStore is disabled.
Unconditional initialization can pass a nil store and lead to later nil derefs in OAuth flows.Also applies to: 1956-1961
framework/configstore/tables/oauth.go (2)
11-30: LGTM for schema structure, but encryption concern remains from prior review.The table schema correctly models OAuth configuration with appropriate fields for PKCE (
CodeVerifier,CodeChallenge), discovery (UseDiscovery,ServerURL), and state management. TheBeforeSavehook properly defaultsStatusto"pending".However, the prior review correctly identified that sensitive fields (
ClientSecret,CodeVerifier) are stored in plaintext despite comments claiming encryption. This must be addressed before merge.
46-58: Token table structure looks correct.The
TableOauthTokenmodel appropriately stores OAuth tokens with expiry tracking and refresh timestamps. TheBeforeSavehook correctly defaultsTokenTypeto"Bearer".Same encryption concern applies to
AccessTokenandRefreshTokenfields as noted in the prior review.docs/openapi/schemas/management/mcp.yaml (1)
189-197: Update schema exposes fields the backend may not persist.
MCPClientUpdateRequestincludesauth_typeandoauth_config_id, but verify thatframework/configstore/rdb.go'sUpdateMCPClientConfigactually persists these fields. If not, updates will silently no-op.#!/bin/bash # Check if UpdateMCPClientConfig handles auth_type and oauth_config_id rg -n "UpdateMCPClient" -g "*.go" -A 20 | grep -E "(auth_type|oauth_config_id|AuthType|OauthConfigID)" | head -20docs/openapi/paths/management/oauth.yaml (2)
18-35: Makecodeoptional to handle OAuth error redirects.When OAuth authorization fails, providers redirect with
erroranderror_descriptionbut without acode. Havingcodeasrequired: truewill fail request validation before the handler can process error responses.🛠️ Suggested fix
- name: code in: query - required: true + required: false description: Authorization code from the OAuth provider
74-81: Fix inconsistent$refusage for 404 response.Line 79 references a response object under
schema:, but this path points to a response object, not a schema. Line 81 correctly uses$refat the response level for 500.🛠️ Suggested fix
'404': - description: OAuth config not found - content: - application/json: - schema: - $ref: '../../openapi.yaml#/components/responses/NotFound' + $ref: '../../openapi.yaml#/components/responses/NotFound'framework/oauth2/sync.go (2)
38-44: MakeStop()idempotent to prevent panic on multiple calls.Closing a channel twice panics. Use
sync.Onceto guard the close operation, consistent with patterns already established in the codebase (e.g.,framework/tracing/store.go).🔧 Suggested fix
import ( "context" + "sync" "time" "github.com/maximhq/bifrost/core/schemas" ) type TokenRefreshWorker struct { provider *OAuth2Provider refreshInterval time.Duration lookAheadWindow time.Duration stopCh chan struct{} + stopOnce sync.Once logger schemas.Logger } func (w *TokenRefreshWorker) Stop() { - close(w.stopCh) + w.stopOnce.Do(func() { close(w.stopCh) }) if w.logger != nil { w.logger.Info("Token refresh worker stopped") } }
126-134: Race condition: setters modify fields while worker may be running.
SetRefreshIntervalandSetLookAheadWindowmodify struct fields without synchronization, while therungoroutine readsw.refreshIntervalwhen creating the ticker. If these setters are called afterStart(), there's a data race.If these are only for testing before starting the worker, add documentation. Otherwise, add mutex protection.
🔧 Documentation fix (if test-only)
// SetRefreshInterval updates the refresh check interval (for testing) +// Must be called before Start() to avoid data races. func (w *TokenRefreshWorker) SetRefreshInterval(interval time.Duration) { w.refreshInterval = interval } // SetLookAheadWindow updates the look-ahead window for token expiry (for testing) +// Must be called before Start() to avoid data races. func (w *TokenRefreshWorker) SetLookAheadWindow(window time.Duration) { w.lookAheadWindow = window }framework/oauth2/discovery.go (4)
340-346: Modulo bias in PKCE verifier generation.Using
int(randomBytes[i]) % len(charset)introduces slight bias since 256 is not evenly divisible by 66 (charset length). While the security impact is minimal for a 128-character verifier, consider using rejection sampling for uniform distribution.🔧 Suggested fix using rejection sampling
// Convert to allowed charset b := make([]byte, length) - for i := range b { - b[i] = charset[int(randomBytes[i])%len(charset)] + charsetLen := byte(len(charset)) + maxValid := byte(256 - (256 % int(charsetLen))) // Largest multiple of charsetLen < 256 + for i := 0; i < length; { + if randomBytes[i] < maxValid { + b[i] = charset[randomBytes[i]%charsetLen] + i++ + } else { + // Generate new random byte for rejected position + if _, err := rand.Read(randomBytes[i : i+1]); err != nil { + return "", "", fmt.Errorf("failed to generate random bytes: %w", err) + } + } }
53-136: Nil pointer dereference: Logger not checked before use throughout discovery.Line 54 guards
logger.Debug, but subsequent calls at lines 74, 84, 87, 98, 100, 106, 111, 127-133 do not check for nil. This will panic ifDiscoverOAuthMetadatais called beforeSetLogger().🔧 Suggested fix - add logger guard at function entry
func DiscoverOAuthMetadata(ctx context.Context, serverURL string) (*OAuthMetadata, error) { + if logger == nil { + logger = bifrost.NewDefaultLogger(schemas.LogLevelInfo) + } if logger != nil { logger.Debug(fmt.Sprintf("[OAuth Discovery] Starting discovery for server: %s", serverURL)) }This pattern is already used in
NewOauthProviderin main.go. Alternatively, remove all individual nil checks and rely on the guard at function entry.
397-454: Add logger nil guard for RegisterDynamicClient.This function has multiple logger calls (lines 398-399, 431, 446-450) without nil checks. Since this is an exported function, add a guard at the entry point similar to
DiscoverOAuthMetadata.
326-355: Add logger nil guard for GeneratePKCEChallenge.Line 352 calls
logger.Debugwithout a nil check. Since this is an exported function that may be called beforeSetLogger(), add a guard at the function entry.framework/oauth2/main.go (9)
38-53: Goroutine leak:cleanupExpiredPendingClientshas no shutdown mechanism.The cleanup goroutine started at line 50 runs indefinitely with no way to stop it. If the provider is discarded, the goroutine will leak. Add a stop channel and a
Close()method.🔧 Suggested fix
type OAuth2Provider struct { configStore configstore.ConfigStore mu sync.RWMutex pendingMCPClients map[string]*PendingMCPClient + stopCh chan struct{} } func NewOAuth2Provider(configStore configstore.ConfigStore, logger schemas.Logger) *OAuth2Provider { if logger == nil { logger = bifrost.NewDefaultLogger(schemas.LogLevelInfo) } SetLogger(logger) p := &OAuth2Provider{ configStore: configStore, pendingMCPClients: make(map[string]*PendingMCPClient), + stopCh: make(chan struct{}), } go p.cleanupExpiredPendingClients() return p } +func (p *OAuth2Provider) Close() { + close(p.stopCh) +}And update
cleanupExpiredPendingClientsto select on the stop channel (see lines 244-259).
92-95: Improve error message when token is nil after refresh.When
tokenisnilbuterris alsonil,fmt.Errorfwith%won anilerror produces a confusing message ending with%!w(<nil>).🔧 Suggested fix
token, err = p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) - if err != nil || token == nil { - return "", fmt.Errorf("failed to reload token after refresh: %w", err) + if err != nil { + return "", fmt.Errorf("failed to reload token after refresh: %w", err) + } + if token == nil { + return "", fmt.Errorf("token not found after refresh") }
556-565: Handle refresh flows for public clients.Per RFC 6749,
exchangeRefreshTokenmust not sendclient_secretwhen empty—public clients cannot maintain secret confidentiality and some OAuth servers reject empty secrets. TheexchangeCodeForTokensandexchangeCodeForTokensWithPKCEmethods correctly guardclient_secretwithif clientSecret != "", butexchangeRefreshTokendoes not follow this pattern.🔧 Suggested fix
func (p *OAuth2Provider) exchangeRefreshToken(tokenURL, clientID, clientSecret, refreshToken string) (*schemas.OAuth2TokenExchangeResponse, error) { data := url.Values{} data.Set("grant_type", "refresh_token") data.Set("refresh_token", refreshToken) data.Set("client_id", clientID) - data.Set("client_secret", clientSecret) + if clientSecret != "" { + data.Set("client_secret", clientSecret) + } return p.callTokenEndpoint(tokenURL, data) }
243-259: Add shutdown mechanism to cleanup goroutine.This goroutine runs indefinitely. Modify it to select on both
ticker.Cand a stop channel.🔧 Suggested fix
func (p *OAuth2Provider) cleanupExpiredPendingClients() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() - for range ticker.C { + for { + select { + case <-ticker.C: + p.mu.Lock() + now := time.Now() + for mcpClientID, pending := range p.pendingMCPClients { + if now.Sub(pending.CreatedAt) > 5*time.Minute { + delete(p.pendingMCPClients, mcpClientID) + logger.Debug("Cleaned up expired pending MCP client", "mcp_client_id", mcpClientID) + } + } + p.mu.Unlock() + case <-p.stopCh: + return + } + } - p.mu.Lock() - now := time.Now() - for mcpClientID, pending := range p.pendingMCPClients { - if now.Sub(pending.CreatedAt) > 5*time.Minute { - delete(p.pendingMCPClients, mcpClientID) - logger.Debug("Cleaned up expired pending MCP client", "mcp_client_id", mcpClientID) - } - } - p.mu.Unlock() - } }
443-447: Error fromUpdateOauthConfigis silently ignored.When the OAuth flow expires, the status update error is not checked. This could leave the database in an inconsistent state.
🔧 Suggested fix
if time.Now().After(oauthConfig.ExpiresAt) { oauthConfig.Status = "expired" - p.configStore.UpdateOauthConfig(ctx, oauthConfig) + if err := p.configStore.UpdateOauthConfig(ctx, oauthConfig); err != nil { + logger.Error("Failed to update expired oauth config status", "error", err, "oauth_config_id", oauthConfig.ID) + } return fmt.Errorf("oauth flow expired") }
465-473: Error fromUpdateOauthConfigon failure is also ignored.When token exchange fails, the config status update error is not checked.
🔧 Suggested fix
if err != nil { oauthConfig.Status = "failed" - p.configStore.UpdateOauthConfig(ctx, oauthConfig) + if updateErr := p.configStore.UpdateOauthConfig(ctx, oauthConfig); updateErr != nil { + logger.Error("Failed to update oauth config status", "error", updateErr, "oauth_config_id", oauthConfig.ID) + } logger.Error("Token exchange failed",
480-480: Ignoredjson.Marshalerror.The error from
json.Marshal(scopes)is discarded with a blank identifier. While unlikely to fail for a[]string, this is inconsistent with line 382 which properly handles the same operation.🔧 Suggested fix
- scopesJSON, _ := json.Marshal(scopes) + scopesJSON, err := json.Marshal(scopes) + if err != nil { + return fmt.Errorf("failed to serialize token scopes: %w", err) + }
509-523: Handle authorize URLs that already contain query parameters.
buildAuthorizeURLWithPKCEappends?via simple string concatenation, which breaks URLs that already contain query parameters. Useurl.Parseto safely merge existing and new query parameters.🔧 Suggested fix
func (p *OAuth2Provider) buildAuthorizeURLWithPKCE(authorizeURL, clientID, redirectURI, state, codeChallenge string, scopes []string) string { params := url.Values{} params.Set("response_type", "code") params.Set("client_id", clientID) params.Set("redirect_uri", redirectURI) params.Set("state", state) params.Set("code_challenge", codeChallenge) params.Set("code_challenge_method", "S256") if len(scopes) > 0 { params.Set("scope", strings.Join(scopes, " ")) } - return authorizeURL + "?" + params.Encode() + u, err := url.Parse(authorizeURL) + if err != nil { + return authorizeURL + "?" + params.Encode() + } + q := u.Query() + for k, vs := range params { + for _, v := range vs { + q.Set(k, v) + } + } + u.RawQuery = q.Encode() + return u.String() }
117-132: Guard against missing refresh token.
RefreshAccessTokenshould check iftoken.RefreshTokenis empty before attempting the refresh exchange. This provides a clearer error message and avoids unnecessary network calls.🔧 Suggested fix
// Load oauth_token token, err := p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) if err != nil || token == nil { return fmt.Errorf("oauth token not found: %w", err) } + if token.RefreshToken == "" { + return fmt.Errorf("refresh token missing; re-authorize the OAuth flow") + } // Call OAuth provider's token endpoint with refresh_tokendocs/openapi/openapi.json (7)
127852-127858: Fix discriminator mapping references — schemas do not exist in components/schemas.The discriminator mappings use
#/MCPClientCreateRequestHTTPformat which is invalid. Per OpenAPI 3.1 spec, mapping values must be either schema component names or valid URI references like#/components/schemas/SchemaName. Additionally, the referenced schemas (MCPClientCreateRequestHTTP,MCPClientCreateRequestSSE,MCPClientCreateRequestSTDIO) don't exist incomponents/schemas.Either add the variant schemas to
components/schemasand fix the URI format, or remove the discriminator if not needed.
127494-127499: Removeinprocessfromconnection_typeenum.The enum includes
inprocessbut there's no corresponding discriminator mapping or oneOf variant. Sinceinprocessconnections can only be created programmatically via Go code (not via HTTP API), it should be removed from the API schema.
129208-129293: Fix invalid nested response structure in 404 error schema.The 404 response contains an invalid nested structure:
schema→content→application/json→schema. In OpenAPI 3.x, a schema object cannot contain acontentkey—that's a response-level construct. Use theNotFoundresponse component instead.🔧 Proposed fix
"404": { - "description": "OAuth config not found", - "content": { - "application/json": { - "schema": { - "description": "Resource not found", - "content": { - "application/json": { - "schema": { - ... - } - } - } - } - } - } + "$ref": "#/components/responses/NotFound" },
129379-129418: Add 404 response for DELETE endpoint when OAuth config doesn't exist.The DELETE endpoint only specifies 200 and 500 responses. Add a 404 response for the case where the OAuth config ID doesn't exist.
🔧 Proposed fix
} }, + "404": { + "$ref": "#/components/responses/NotFound" + }, "500": {
167057-167063: Fix discriminator mapping references.Same issue as the inline schema: invalid URI format and non-existent schema references.
166698-166704: Removeinprocessfromconnection_typeenum.Same issue:
inprocesshas no discriminator mapping and cannot be created via HTTP API.
167249-167252: Clarifyserver_urlreference in OAuthConfigRequest docs.The
client_iddescription states "the server_url must be set for OAuth discovery" butserver_urlis not defined in this schema. Update the wording to reference the actual property (likelyconnection_stringfrom the parent MCP client request) or add the missing field.transports/bifrost-http/handlers/oauth2.go (2)
61-63: Use the request ctx for OAuth provider/store calls.
context.Background()drops cancellation/tracing and ignores the fasthttp request lifecycle.Based on learnings, keep `fasthttp.RequestCtx` as the context carrier.🛠️ Suggested fix (apply to all occurrences)
- if err := h.oauthProvider.CompleteOAuthFlow(context.Background(), state, code); err != nil { + if err := h.oauthProvider.CompleteOAuthFlow(ctx, state, code); err != nil { SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("OAuth flow completion failed: %v", err)) return } @@ - oauthConfig, err := h.store.ConfigStore.GetOauthConfigByState(context.Background(), state) + oauthConfig, err := h.store.ConfigStore.GetOauthConfigByState(ctx, state) @@ - h.store.ConfigStore.UpdateOauthConfig(context.Background(), oauthConfig) + h.store.ConfigStore.UpdateOauthConfig(ctx, oauthConfig) @@ - oauthConfig, err := h.store.ConfigStore.GetOauthConfigByID(context.Background(), configID) + oauthConfig, err := h.store.ConfigStore.GetOauthConfigByID(ctx, configID) @@ - token, err := h.store.ConfigStore.GetOauthTokenByID(context.Background(), *oauthConfig.TokenID) + token, err := h.store.ConfigStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) @@ - if err := h.oauthProvider.RevokeToken(context.Background(), configID); err != nil { + if err := h.oauthProvider.RevokeToken(ctx, configID); err != nil {Also applies to: 100-104, 145-168, 182-183
110-137: Escape OAuth error strings before injecting into HTML/JS.
erroranderror_descriptionare user-controlled; rendering them raw enables reflected XSS in the popup.🔒 Suggested fix
import ( "context" "fmt" + "html" + "html/template" @@ errorMsg := errorParam if errorDescription != "" { errorMsg = fmt.Sprintf("%s: %s", errorParam, errorDescription) } + escapedHTML := html.EscapeString(errorMsg) + escapedJS := template.JSEscapeString(errorMsg) ctx.SetBodyString(fmt.Sprintf(` @@ - window.opener.postMessage({ type: 'oauth_failed', error: '%s' }, '*'); + window.opener.postMessage({ type: 'oauth_failed', error: '%s' }, '*'); @@ - <p>%s</p> + <p>%s</p> @@ - `, errorMsg, errorMsg)) + `, escapedJS, escapedHTML))transports/bifrost-http/handlers/mcp.go (1)
218-223: GuardConnectionStringbefore callingGetValue()to avoid panics.
Ifconnection_stringis omitted, this will panic.🛠️ Suggested fix
- if req.OauthConfig.ClientID == "" { - // If no client_id, we need server URL for discovery - if req.ConnectionString.GetValue() == "" { + if req.OauthConfig.ClientID == "" { + // If no client_id, we need server URL for discovery + if req.ConnectionString == nil || req.ConnectionString.GetValue() == "" { SendError(ctx, fasthttp.StatusBadRequest, "Either client_id must be provided, or server URL must be set for OAuth discovery and dynamic client registration") return } } @@ - ServerURL: req.ConnectionString.GetValue(), // MCP server URL for OAuth discovery + ServerURL: req.ConnectionString.GetValue(), // MCP server URL for OAuth discoveryAlso applies to: 250-251
framework/configstore/rdb.go (1)
2712-2818: ReturnErrNotFoundfor missing OAuth records for consistency.
Other getters in this package returnErrNotFound; returning(nil, nil)here makes callers miss missing-record cases.🛠️ Suggested fix
- if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, nil - } + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + }Apply the same change to
GetOauthConfigByState,GetOauthTokenByID, andGetOauthConfigByTokenID.
🧹 Nitpick comments (6)
core/schemas/mcp.go (1)
118-149: Consider allowing extra headers to accompany OAuth.
Right nowAuthType=oauthignoresHeaders. If you want custom headers in addition toAuthorization, you can mergeHeadersfirst and then overrideAuthorizationfrom the token.♻️ Suggested tweak
- case MCPAuthTypeOauth: + case MCPAuthTypeOauth: + for key, value := range c.Headers { + headers[key] = value.GetValue() + } if c.OauthConfigID == nil { return nil, ErrOAuth2ConfigNotFound } @@ - headers["Authorization"] = "Bearer " + accessToken + headers["Authorization"] = "Bearer " + accessTokenui/app/workspace/mcp-gateway/views/mcpClientsTable.tsx (1)
218-231: Add a fallback class for unknown MCP states (Line 218).With OAuth-related states in the stack,
MCP_STATUS_COLORS[c.state]can be undefined. A fallback keeps the badge readable and avoids unstyled UI.♻️ Suggested tweak
- <Badge className={MCP_STATUS_COLORS[c.state]}> + <Badge className={MCP_STATUS_COLORS[c.state] ?? MCP_STATUS_COLORS.disconnected}>docs/openapi/paths/management/oauth.yaml (1)
83-106: Consider adding 404 response to DELETE operation.The DELETE operation only defines 200 and 500 responses, but should also handle the case where the OAuth config ID doesn't exist.
🛠️ Suggested fix
responses: '200': description: OAuth token revoked successfully content: application/json: schema: $ref: '../../schemas/management/common.yaml#/SuccessResponse' + '404': + $ref: '../../openapi.yaml#/components/responses/NotFound' '500': $ref: '../../openapi.yaml#/components/responses/InternalError'ui/lib/types/mcp.ts (1)
4-4:MCPConnectionTypeis missinginprocessvariant.The OpenAPI schema (
docs/openapi/schemas/management/mcp.yamlline 14) definesMCPConnectionTypeas[http, stdio, sse, inprocess], but this TypeScript type only includes"http" | "stdio" | "sse". Whileinprocessmay only be usable programmatically (Go package), the type should match the API schema for consistency.🛠️ Suggested fix
-export type MCPConnectionType = "http" | "stdio" | "sse"; +export type MCPConnectionType = "http" | "stdio" | "sse" | "inprocess";core/mcp/clientmanager.go (1)
66-159: Extract shared helper forAddClient/AddClientInMemory.
The bodies are effectively identical; a shared internal helper would reduce drift risk.transports/bifrost-http/handlers/mcp.go (1)
267-268: Preferbifrost.Ptrfor pointer creation.
This keeps pointer construction consistent across the codebase.Based on learnings, prefer `bifrost.Ptr(...)` for pointer creation.🧩 Suggested tweak
- OauthConfigID: &flowInitiation.OauthConfigID, + OauthConfigID: bifrost.Ptr(flowInitiation.OauthConfigID),
core/mcp/codemodeexecutecode.go
Outdated
| // Strip the client name prefix from tool name to get sanitized name | ||
| // Then look up the original MCP tool name from the mapping | ||
| sanitizedToolName := stripClientPrefix(toolName, clientName) | ||
| originalMCPToolName := getOriginalToolName(sanitizedToolName, client) | ||
|
|
There was a problem hiding this comment.
Code-mode tool calls now bypass sanitization; likely to break tool resolution.
Line 786 onward switches to originalMCPToolName across direct and plugin-backed calls (including Line 913 and Line 974). The MCP system relies on sanitized names for internal resolution; reversing them risks mismatches or call failures. If the server now requires original names, add a fallback path; otherwise keep sanitized names for the call and metadata.
🐛 Proposed fix (primary call path)
- toolNameToCall := originalMCPToolName
+ toolNameToCall := sanitizedToolNameBased on learnings, MCP tool names should remain sanitized throughout the system.
Also applies to: 799-800, 852-853, 859-860, 913-975
🤖 Prompt for AI Agents
In `@core/mcp/codemodeexecutecode.go` around lines 786 - 790, The code replaced
uses of sanitizedToolName with originalMCPToolName, which breaks internal MCP
resolution; revert tool-resolution and metadata usage to sanitizedToolName (the
value produced by stripClientPrefix) throughout the code paths that call tools
(including direct and plugin-backed calls where originalMCPToolName is now
used), and implement a fallback: attempt resolution with sanitizedToolName first
and only query/getOriginalToolName (via getOriginalToolName) and retry with
originalMCPToolName if the sanitized lookup fails. Ensure stripClientPrefix,
sanitizedToolName, getOriginalToolName, and originalMCPToolName are used exactly
as described so metadata and primary call paths keep sanitized names.
| oauth_config: | ||
| form.auth_type === "oauth" | ||
| ? { | ||
| client_id: form.oauth_config?.client_id || "", // Can be empty for dynamic registration | ||
| client_secret: form.oauth_config?.client_secret || undefined, | ||
| authorize_url: form.oauth_config?.authorize_url || undefined, | ||
| token_url: form.oauth_config?.token_url || undefined, | ||
| registration_url: form.oauth_config?.registration_url || undefined, | ||
| scopes: scopesText.trim() ? parseArrayFromText(scopesText) : undefined, | ||
| server_url: form.connection_string?.value || undefined, // Set server_url from connection_string | ||
| } | ||
| : undefined, | ||
| headers: form.headers && Object.keys(form.headers).length > 0 ? form.headers : undefined, | ||
| tools_to_execute: ["*"], |
There was a problem hiding this comment.
Gate headers by auth_type to avoid persisting secrets for OAuth/none.
Right now headers are sent even if the user switches from “Headers” to OAuth/None; that can unintentionally store/send secrets despite the new auth type.
🛠️ Suggested fix
- headers: form.headers && Object.keys(form.headers).length > 0 ? form.headers : undefined,
+ headers:
+ form.auth_type === "headers" && form.headers && Object.keys(form.headers).length > 0
+ ? form.headers
+ : undefined,📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| oauth_config: | |
| form.auth_type === "oauth" | |
| ? { | |
| client_id: form.oauth_config?.client_id || "", // Can be empty for dynamic registration | |
| client_secret: form.oauth_config?.client_secret || undefined, | |
| authorize_url: form.oauth_config?.authorize_url || undefined, | |
| token_url: form.oauth_config?.token_url || undefined, | |
| registration_url: form.oauth_config?.registration_url || undefined, | |
| scopes: scopesText.trim() ? parseArrayFromText(scopesText) : undefined, | |
| server_url: form.connection_string?.value || undefined, // Set server_url from connection_string | |
| } | |
| : undefined, | |
| headers: form.headers && Object.keys(form.headers).length > 0 ? form.headers : undefined, | |
| tools_to_execute: ["*"], | |
| oauth_config: | |
| form.auth_type === "oauth" | |
| ? { | |
| client_id: form.oauth_config?.client_id || "", // Can be empty for dynamic registration | |
| client_secret: form.oauth_config?.client_secret || undefined, | |
| authorize_url: form.oauth_config?.authorize_url || undefined, | |
| token_url: form.oauth_config?.token_url || undefined, | |
| registration_url: form.oauth_config?.registration_url || undefined, | |
| scopes: scopesText.trim() ? parseArrayFromText(scopesText) : undefined, | |
| server_url: form.connection_string?.value || undefined, // Set server_url from connection_string | |
| } | |
| : undefined, | |
| headers: | |
| form.auth_type === "headers" && form.headers && Object.keys(form.headers).length > 0 | |
| ? form.headers | |
| : undefined, | |
| tools_to_execute: ["*"], |
🤖 Prompt for AI Agents
In `@ui/app/workspace/mcp-gateway/views/mcpClientForm.tsx` around lines 228 - 241,
The headers object is always included regardless of auth selection, which can
persist/send secrets when auth_type is "oauth" or "none"; update the headers
assignment in the object construction so headers are only set when
form.auth_type indicates header-based auth (e.g. form.auth_type === "headers")
and form.headers has keys (replace the current headers: form.headers &&
Object.keys(form.headers).length > 0 ? form.headers : undefined with a
conditional that also checks form.auth_type === "headers"); keep all other
behavior (oauth_config, scopes parsing, server_url) unchanged.
6c7a84b to
fa01e38
Compare
4a60c86 to
b31b58f
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
docs/openapi/schemas/management/mcp.yaml (1)
72-82: Resolve merge conflict markers (invalid YAML).The conflict markers will break parsing and documentation builds.
🔧 Suggested fix
is_code_mode_client: type: boolean -<<<<<<< HEAD - is_ping_available: - type: boolean - default: true - description: | - Whether the MCP server supports ping for health checks. - If true, uses lightweight ping method for health checks. - If false, uses listTools method for health checks instead. -======= - description: Whether this client is available in code mode ->>>>>>> 4e846193 (feat: plugin schema extensions for mcp plugins) + description: Whether this client is available in code mode + is_ping_available: + type: boolean + default: true + description: | + Whether the MCP server supports ping for health checks. + If true, uses lightweight ping method for health checks. + If false, uses listTools method for health checks instead.framework/configstore/rdb.go (1)
838-864: DefaultAuthTypewhen empty to preserve expected behavior.
IfAuthTypeis empty, this writes an empty string and bypasses the DB default (headers), which can break auth handling for callers that omit the field.💡 Proposed fix
- dbClient := tables.TableMCPClient{ + authType := clientConfigCopy.AuthType + if authType == "" { + authType = schemas.MCPAuthType("headers") + } + 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), + AuthType: string(authType), OauthConfigID: clientConfigCopy.OauthConfigID, ToolsToExecute: clientConfigCopy.ToolsToExecute, ToolsToAutoExecute: clientConfigCopy.ToolsToAutoExecute, Headers: clientConfigCopy.Headers, IsPingAvailable: clientConfigCopy.IsPingAvailable, }
🤖 Fix all issues with AI agents
In `@framework/oauth2/discovery.go`:
- Around line 388-454: The function RegisterDynamicClient currently dereferences
req without checking for nil; add an early nil-guard at the start of
RegisterDynamicClient (before json.Marshal) that returns a clear error (e.g.,
fmt.Errorf("registration request is nil")) if req == nil, referencing the
DynamicClientRegistrationRequest input parameter to prevent a panic and surface
a useful error to callers.
♻️ Duplicate comments (25)
core/schemas/oauth.go (1)
37-46: Consider removing JSON tags from sensitive OAuth2Token fields.The
access_tokenandrefresh_tokenfields have JSON tags that could expose sensitive credentials if this struct is ever serialized in API responses. While current handlers may not expose this directly, the tags pose a latent risk.As noted in a previous review, consider using
json:"-"or a redacted DTO pattern consistent withGetProviderConfigRedactedelsewhere in the codebase.docs/openapi/paths/management/mcp.yaml (1)
227-232: Invalid response objects:$refcannot be combined withdescription.OpenAPI 3.0 disallows sibling fields next to
$ref. Lines 228-229 and 231-232 combine a customdescriptionwith a$ref, which is invalid.✅ Minimal fix (remove custom descriptions)
'400': - description: OAuth not authorized yet or MCP client not found in pending OAuth clients $ref: '../../openapi.yaml#/components/responses/BadRequest' '404': - description: MCP client not found in pending OAuth clients or OAuth config not found $ref: '../../openapi.yaml#/components/responses/NotFound'core/mcp/codemodeexecutecode.go (1)
786-790: Code-mode tool calls use original MCP tool names instead of sanitized names.The code now derives
originalMCPToolNameviagetOriginalToolName()and uses it for all MCP server calls (Lines 799, 852, 859, 915) and response metadata (Line 974). This is consistent with the parallel changes intoolmanager.go.This approach reverses the previous pattern where sanitized names were used throughout the MCP system. If this is intentional (e.g., to support MCP servers that require hyphenated tool names), ensure the
ToolNameMappingis correctly populated during tool discovery for all client types.framework/configstore/tables/oauth.go (1)
11-58: OAuth sensitive fields stored in plaintext despite "Encrypted" comments.The field comments on Lines 14, 21, 50-51 indicate these fields should be encrypted (
// Encrypted OAuth client secret,// Encrypted access token, etc.), but theBeforeSavehooks only set default values without performing encryption.The codebase has AES-256-GCM encryption infrastructure in
framework/encrypt/encrypt.go, but it's not integrated here. Before merging, either:
- Integrate encryption calls in the creation/update paths for these sensitive fields
- Update the comments to accurately reflect that fields are stored in plaintext
- Document the security model if plaintext storage is intentional for this phase
This is especially important for
AccessToken,RefreshToken,ClientSecret, andCodeVerifierwhich are high-value secrets.framework/configstore/store.go (1)
42-43: Verify all ConfigStore implementations/mocks updated for new methods.Adding these interface methods requires every ConfigStore implementation (including mocks) to add the new signatures. Please verify the mocks are updated to avoid compile failures.
#!/bin/bash # Locate ConfigStore implementations and verify new methods exist rg -n "type ConfigStore interface" --type go -A 80 rg -n "var _ .*ConfigStore" --type go # Check RDBConfigStore (or other concrete stores) implement new methods rg -n "func .*ConfigStore.*GetMCPClientByID" --type go rg -n "func .*ConfigStore.*GetOauthConfigByID" --type go rg -n "func .*ConfigStore.*GetOauthConfigByState" --type go rg -n "func .*ConfigStore.*GetOauthConfigByTokenID" --type go rg -n "func .*ConfigStore.*GetOauthTokenByID" --type go rg -n "func .*ConfigStore.*GetExpiringOauthTokens" --type go rg -n "func .*ConfigStore.*CreateOauthToken" --type go rg -n "func .*ConfigStore.*UpdateOauthToken" --type go rg -n "func .*ConfigStore.*DeleteOauthToken" --type go # Check known mock file (path referenced in prior reviews) rg -n "type MockConfigStore" transports/bifrost-http/lib/config_test.go -A 200 rg -n "func \(m \*MockConfigStore\) GetMCPClientByID" transports/bifrost-http/lib/config_test.go rg -n "func \(m \*MockConfigStore\) GetOauthConfigByID" transports/bifrost-http/lib/config_test.go rg -n "func \(m \*MockConfigStore\) GetOauthConfigByState" transports/bifrost-http/lib/config_test.go rg -n "func \(m \*MockConfigStore\) GetOauthConfigByTokenID" transports/bifrost-http/lib/config_test.go rg -n "func \(m \*MockConfigStore\) GetOauthTokenByID" transports/bifrost-http/lib/config_test.go rg -n "func \(m \*MockConfigStore\) GetExpiringOauthTokens" transports/bifrost-http/lib/config_test.go rg -n "func \(m \*MockConfigStore\) CreateOauthToken" transports/bifrost-http/lib/config_test.go rg -n "func \(m \*MockConfigStore\) UpdateOauthToken" transports/bifrost-http/lib/config_test.go rg -n "func \(m \*MockConfigStore\) DeleteOauthToken" transports/bifrost-http/lib/config_test.goAlso applies to: 186-198
docs/openapi/paths/management/oauth.yaml (2)
18-33: Makecodeoptional to allow OAuth error callbacks.Providers often return
error/error_descriptionwithoutcode, so validation will fail ifcodeis required.🔧 Suggested fix
- name: code in: query - required: true + required: false description: Authorization code from the OAuth provider schema: type: string
74-79: Fix 404 response$refplacement.The
$refpoints to a response object and should be referenced at the response level, not underschema.🔧 Suggested fix
'404': - description: OAuth config not found - content: - application/json: - schema: - $ref: '../../openapi.yaml#/components/responses/NotFound' + $ref: '../../openapi.yaml#/components/responses/NotFound'framework/oauth2/sync.go (2)
38-43: MakeStop()idempotent to avoid panics.Closing a channel twice will panic if
Stop()is called multiple times.🔧 Suggested fix
import ( "context" + "sync" "time" "github.com/maximhq/bifrost/core/schemas" ) @@ type TokenRefreshWorker struct { provider *OAuth2Provider refreshInterval time.Duration lookAheadWindow time.Duration // How far ahead to look for expiring tokens stopCh chan struct{} + stopOnce sync.Once logger schemas.Logger } @@ func (w *TokenRefreshWorker) Stop() { - close(w.stopCh) + w.stopOnce.Do(func() { close(w.stopCh) }) if w.logger != nil { w.logger.Info("Token refresh worker stopped") } }
126-134: Guard setters against concurrent use.These setters can race with the running goroutine if called after
Start(). If they’re test-only, document that requirement; otherwise guard with a mutex.🔧 Suggested fix (documentation approach)
-// SetRefreshInterval updates the refresh check interval (for testing) +// SetRefreshInterval updates the refresh check interval (for testing) +// Must be called before Start() to avoid data races. func (w *TokenRefreshWorker) SetRefreshInterval(interval time.Duration) { w.refreshInterval = interval } -// SetLookAheadWindow updates the look-ahead window for token expiry (for testing) +// SetLookAheadWindow updates the look-ahead window for token expiry (for testing) +// Must be called before Start() to avoid data races. func (w *TokenRefreshWorker) SetLookAheadWindow(window time.Duration) { w.lookAheadWindow = window }framework/oauth2/discovery.go (2)
53-134: Guard logger usage to avoid nil panics.
Only the first call is gated at Line 54; subsequentlogger.Debug/Warncalls (e.g., Line 74 onward) are unconditional. IfSetLoggerwasn’t called, this will panic. Initialize a default logger at function entry (or guard every call) so discovery can operate silently when no logger is provided.
326-355: Avoid modulo bias in PKCE verifier generation.
Line 344 usesrandomBytes[i] % len(charset), which skews distribution; use rejection sampling for uniformity.framework/oauth2/main.go (7)
38-52: Provide a shutdown path for the cleanup goroutine.
cleanupExpiredPendingClientsruns forever; if the provider is discarded, the goroutine leaks. Add a stop channel and aClose()method.
55-96: Avoid wrapping nil error when token reload returns nil.
Iferr == nilandtoken == nil, the%wwrap yields%!w(<nil>). Split the branches.🐛 Proposed fix
- token, err = p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) - if err != nil || token == nil { - return "", fmt.Errorf("failed to reload token after refresh: %w", err) - } + token, err = p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) + if err != nil { + return "", fmt.Errorf("failed to reload token after refresh: %w", err) + } + if token == nil { + return "", fmt.Errorf("token not found after refresh") + }
102-132: Check refresh token presence before refresh.
If the stored token has an emptyrefresh_token, the refresh attempt will fail in a less obvious way. Return a clear error first.🐛 Proposed fix
// Load oauth_token token, err := p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID) if err != nil || token == nil { return fmt.Errorf("oauth token not found: %w", err) } + if token.RefreshToken == "" { + return fmt.Errorf("refresh token missing; re-authorize the OAuth flow") + }
442-472: HandleUpdateOauthConfigerrors on expiry/failure.
Both the expiry path (Line 445) and token‑exchange failure path (Line 467) ignore update errors, risking stale status in DB.
475-481: Handlejson.Marshal(scopes)errors.
Line 480 discards the error, which is inconsistent with earlier scope serialization.💡 Proposed fix
- scopesJSON, _ := json.Marshal(scopes) + scopesJSON, err := json.Marshal(scopes) + if err != nil { + return fmt.Errorf("failed to serialize token scopes: %w", err) + }
509-523: Handle authorize URLs that already contain query params.
Simple concatenation with?can break URLs that already include a query string; merge parameters instead.
556-565: Only includeclient_secretwhen non‑empty.
Public clients may not have a secret; sending an empty secret can cause token endpoint rejection.🐛 Proposed fix
data.Set("grant_type", "refresh_token") data.Set("refresh_token", refreshToken) data.Set("client_id", clientID) - data.Set("client_secret", clientSecret) + if clientSecret != "" { + data.Set("client_secret", clientSecret) + }framework/configstore/rdb.go (1)
2712-2819: ReturnErrNotFoundconsistently for OAuth getters.
These methods return(nil, nil)on record‑not‑found, which is inconsistent with other getters and forces callers to double‑checknilseparately.docs/openapi/openapi.json (6)
127492-127499: Removeinprocessfrom the HTTP-facing enum.
inprocessis Go-only and has no discriminator variant for the HTTP API.🔧 Suggested tweak
- "sse", - "inprocess" + "sse"
127852-127858: Fix discriminator mapping references to valid schema pointers.
The mapping values are not valid OpenAPI references and point to non-existent component schemas.🔧 Example fix (ensure referenced schemas exist)
- "http": "#/MCPClientCreateRequestHTTP", - "sse": "#/MCPClientCreateRequestSSE", - "stdio": "#/MCPClientCreateRequestSTDIO" + "http": "#/components/schemas/MCPClientCreateRequestHTTP", + "sse": "#/components/schemas/MCPClientCreateRequestSSE", + "stdio": "#/components/schemas/MCPClientCreateRequestSTDIO"#!/bin/bash python3 - <<'PY' import json spec = json.load(open('docs/openapi/openapi.json')) def walk(obj): if isinstance(obj, dict): disc = obj.get('discriminator') if isinstance(disc, dict) and 'mapping' in disc: print("mapping:", disc['mapping']) for v in obj.values(): walk(v) elif isinstance(obj, list): for v in obj: walk(v) walk(spec) PY
129208-129293: Fix invalid nestedcontentinside the 404 schema.
A schema object cannot containcontent; reference the reusableNotFoundresponse instead.🔧 Proposed fix
- "404": { - "description": "OAuth config not found", - "content": { - "application/json": { - "schema": { - "description": "Resource not found", - "content": { - "application/json": { - "schema": { ... } - } - } - } - } - } - }, + "404": { + "$ref": "#/components/responses/NotFound" + },#!/bin/bash python3 - <<'PY' import json spec = json.load(open('docs/openapi/openapi.json')) bad = [] def walk(obj, path=""): if isinstance(obj, dict): if 'schema' in obj and isinstance(obj['schema'], dict) and 'content' in obj['schema']: bad.append(path) for k, v in obj.items(): walk(v, f"{path}/{k}") elif isinstance(obj, list): for i, v in enumerate(obj): walk(v, f"{path}[{i}]") walk(spec) print("nested content in schema at:", *bad, sep="\n") PY
129379-129419: Add a 404 response for missing OAuth config IDs.
The revoke operation should distinguish not-found from success.🔧 Proposed addition
"responses": { "200": { "description": "OAuth token revoked successfully", "content": { "application/json": { "schema": { "type": "object", "description": "Generic success response", "properties": { "status": { "type": "string", "example": "success" }, "message": { "type": "string", "example": "Operation completed successfully" } } } } } }, + "404": { + "$ref": "#/components/responses/NotFound" + }, "500": { "description": "Internal server error",
166698-167063: Align discriminator mapping and enum with actual variants.
The mapping still points to invalid refs andinprocessis exposed in the HTTP schema.🔧 Suggested direction
- "inprocess" + // remove from HTTP API surface- "http": "#/MCPClientCreateRequestHTTP", - "sse": "#/MCPClientCreateRequestSSE", - "stdio": "#/MCPClientCreateRequestSTDIO" + "http": "#/components/schemas/MCPClientCreateRequestHTTP", + "sse": "#/components/schemas/MCPClientCreateRequestSSE", + "stdio": "#/components/schemas/MCPClientCreateRequestSTDIO"#!/bin/bash python3 - <<'PY' import json spec = json.load(open('docs/openapi/openapi.json')) disc = spec['components']['schemas']['MCPClientCreateRequest'].get('discriminator', {}) print(disc.get('mapping')) PY
167249-167252: Clarify theserver_urlreference in OAuthConfigRequest docs.
server_urlisn’t a property in this schema; either add it or update the text to reference existing fields.✏️ Example wording fix
- "description": "OAuth client ID. Optional if client supports dynamic client registration (RFC 7591).\nIf not provided, the server_url must be set for OAuth discovery and dynamic registration.\n" + "description": "OAuth client ID. Optional if client supports dynamic client registration (RFC 7591).\nIf not provided, authorize_url/token_url/registration_url must be set for OAuth discovery.\n"
🧹 Nitpick comments (1)
framework/oauth2/main.go (1)
135-142: Usebifrost.Ptrfor pointer fields for consistency.
Thebifrost.Ptrhelper is the established pattern in the codebase for creating pointers to values. Apply it toLastRefreshedAtat line 141 andTokenIDat line 498.♻️ Proposed refactor
- token.LastRefreshedAt = &now + token.LastRefreshedAt = bifrost.Ptr(now)- oauthConfig.TokenID = &tokenID + oauthConfig.TokenID = bifrost.Ptr(tokenID)
| // RegisterDynamicClient performs dynamic client registration with the OAuth provider (RFC 7591) | ||
| // This allows Bifrost to automatically register as an OAuth client without manual setup. | ||
| // | ||
| // Parameters: | ||
| // - ctx: Context for the registration request | ||
| // - registrationURL: The registration endpoint (discovered or user-provided) | ||
| // - req: Client registration details | ||
| // | ||
| // Returns client_id and optional client_secret that can be used for OAuth flows. | ||
| func RegisterDynamicClient(ctx context.Context, registrationURL string, req *DynamicClientRegistrationRequest) (*DynamicClientRegistrationResponse, error) { | ||
| logger.Debug(fmt.Sprintf("[Dynamic Registration] Registering client at: %s", registrationURL)) | ||
| logger.Debug(fmt.Sprintf("[Dynamic Registration] Client name: %s, Redirect URIs: %v", req.ClientName, req.RedirectURIs)) | ||
|
|
||
| // Serialize request | ||
| reqBody, err := json.Marshal(req) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to marshal registration request: %w", err) | ||
| } | ||
|
|
||
| // Create HTTP request | ||
| httpReq, err := http.NewRequestWithContext(ctx, "POST", registrationURL, strings.NewReader(string(reqBody))) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to create registration request: %w", err) | ||
| } | ||
| httpReq.Header.Set("Content-Type", "application/json") | ||
| httpReq.Header.Set("Accept", "application/json") | ||
|
|
||
| // Send request | ||
| client := &http.Client{Timeout: 15 * time.Second} | ||
| resp, err := client.Do(httpReq) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("registration request failed: %w", err) | ||
| } | ||
| defer resp.Body.Close() | ||
|
|
||
| // Read response | ||
| respBody, err := io.ReadAll(resp.Body) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to read registration response: %w", err) | ||
| } | ||
|
|
||
| // Check status code (201 Created or 200 OK are both valid per RFC 7591) | ||
| if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { | ||
| logger.Error(fmt.Sprintf("[Dynamic Registration] Failed with status %d: %s", resp.StatusCode, string(respBody))) | ||
| return nil, fmt.Errorf("registration failed with status %d: %s", resp.StatusCode, string(respBody)) | ||
| } | ||
|
|
||
| // Parse response | ||
| var regResp DynamicClientRegistrationResponse | ||
| if err := json.Unmarshal(respBody, ®Resp); err != nil { | ||
| return nil, fmt.Errorf("failed to parse registration response: %w", err) | ||
| } | ||
|
|
||
| // Validate response | ||
| if regResp.ClientID == "" { | ||
| return nil, fmt.Errorf("registration response missing client_id") | ||
| } | ||
|
|
||
| logger.Debug(fmt.Sprintf("[Dynamic Registration] Successfully registered client_id: %s", regResp.ClientID)) | ||
| if regResp.ClientSecret != "" { | ||
| logger.Debug("[Dynamic Registration] Client secret provided by server") | ||
| } else { | ||
| logger.Debug("[Dynamic Registration] No client secret provided (public client)") | ||
| } | ||
|
|
||
| return ®Resp, nil | ||
| } |
There was a problem hiding this comment.
Add a nil guard for registration request.
req is dereferenced at Line 399 and marshaled at Line 402; a nil input will panic. Return a clear error up front.
🐛 Proposed fix
func RegisterDynamicClient(ctx context.Context, registrationURL string, req *DynamicClientRegistrationRequest) (*DynamicClientRegistrationResponse, error) {
+ if req == nil {
+ return nil, fmt.Errorf("registration request cannot be nil")
+ }
logger.Debug(fmt.Sprintf("[Dynamic Registration] Registering client at: %s", registrationURL))
logger.Debug(fmt.Sprintf("[Dynamic Registration] Client name: %s, Redirect URIs: %v", req.ClientName, req.RedirectURIs))📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // RegisterDynamicClient performs dynamic client registration with the OAuth provider (RFC 7591) | |
| // This allows Bifrost to automatically register as an OAuth client without manual setup. | |
| // | |
| // Parameters: | |
| // - ctx: Context for the registration request | |
| // - registrationURL: The registration endpoint (discovered or user-provided) | |
| // - req: Client registration details | |
| // | |
| // Returns client_id and optional client_secret that can be used for OAuth flows. | |
| func RegisterDynamicClient(ctx context.Context, registrationURL string, req *DynamicClientRegistrationRequest) (*DynamicClientRegistrationResponse, error) { | |
| logger.Debug(fmt.Sprintf("[Dynamic Registration] Registering client at: %s", registrationURL)) | |
| logger.Debug(fmt.Sprintf("[Dynamic Registration] Client name: %s, Redirect URIs: %v", req.ClientName, req.RedirectURIs)) | |
| // Serialize request | |
| reqBody, err := json.Marshal(req) | |
| if err != nil { | |
| return nil, fmt.Errorf("failed to marshal registration request: %w", err) | |
| } | |
| // Create HTTP request | |
| httpReq, err := http.NewRequestWithContext(ctx, "POST", registrationURL, strings.NewReader(string(reqBody))) | |
| if err != nil { | |
| return nil, fmt.Errorf("failed to create registration request: %w", err) | |
| } | |
| httpReq.Header.Set("Content-Type", "application/json") | |
| httpReq.Header.Set("Accept", "application/json") | |
| // Send request | |
| client := &http.Client{Timeout: 15 * time.Second} | |
| resp, err := client.Do(httpReq) | |
| if err != nil { | |
| return nil, fmt.Errorf("registration request failed: %w", err) | |
| } | |
| defer resp.Body.Close() | |
| // Read response | |
| respBody, err := io.ReadAll(resp.Body) | |
| if err != nil { | |
| return nil, fmt.Errorf("failed to read registration response: %w", err) | |
| } | |
| // Check status code (201 Created or 200 OK are both valid per RFC 7591) | |
| if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { | |
| logger.Error(fmt.Sprintf("[Dynamic Registration] Failed with status %d: %s", resp.StatusCode, string(respBody))) | |
| return nil, fmt.Errorf("registration failed with status %d: %s", resp.StatusCode, string(respBody)) | |
| } | |
| // Parse response | |
| var regResp DynamicClientRegistrationResponse | |
| if err := json.Unmarshal(respBody, ®Resp); err != nil { | |
| return nil, fmt.Errorf("failed to parse registration response: %w", err) | |
| } | |
| // Validate response | |
| if regResp.ClientID == "" { | |
| return nil, fmt.Errorf("registration response missing client_id") | |
| } | |
| logger.Debug(fmt.Sprintf("[Dynamic Registration] Successfully registered client_id: %s", regResp.ClientID)) | |
| if regResp.ClientSecret != "" { | |
| logger.Debug("[Dynamic Registration] Client secret provided by server") | |
| } else { | |
| logger.Debug("[Dynamic Registration] No client secret provided (public client)") | |
| } | |
| return ®Resp, nil | |
| } | |
| // RegisterDynamicClient performs dynamic client registration with the OAuth provider (RFC 7591) | |
| // This allows Bifrost to automatically register as an OAuth client without manual setup. | |
| // | |
| // Parameters: | |
| // - ctx: Context for the registration request | |
| // - registrationURL: The registration endpoint (discovered or user-provided) | |
| // - req: Client registration details | |
| // | |
| // Returns client_id and optional client_secret that can be used for OAuth flows. | |
| func RegisterDynamicClient(ctx context.Context, registrationURL string, req *DynamicClientRegistrationRequest) (*DynamicClientRegistrationResponse, error) { | |
| if req == nil { | |
| return nil, fmt.Errorf("registration request cannot be nil") | |
| } | |
| logger.Debug(fmt.Sprintf("[Dynamic Registration] Registering client at: %s", registrationURL)) | |
| logger.Debug(fmt.Sprintf("[Dynamic Registration] Client name: %s, Redirect URIs: %v", req.ClientName, req.RedirectURIs)) | |
| // Serialize request | |
| reqBody, err := json.Marshal(req) | |
| if err != nil { | |
| return nil, fmt.Errorf("failed to marshal registration request: %w", err) | |
| } | |
| // Create HTTP request | |
| httpReq, err := http.NewRequestWithContext(ctx, "POST", registrationURL, strings.NewReader(string(reqBody))) | |
| if err != nil { | |
| return nil, fmt.Errorf("failed to create registration request: %w", err) | |
| } | |
| httpReq.Header.Set("Content-Type", "application/json") | |
| httpReq.Header.Set("Accept", "application/json") | |
| // Send request | |
| client := &http.Client{Timeout: 15 * time.Second} | |
| resp, err := client.Do(httpReq) | |
| if err != nil { | |
| return nil, fmt.Errorf("registration request failed: %w", err) | |
| } | |
| defer resp.Body.Close() | |
| // Read response | |
| respBody, err := io.ReadAll(resp.Body) | |
| if err != nil { | |
| return nil, fmt.Errorf("failed to read registration response: %w", err) | |
| } | |
| // Check status code (201 Created or 200 OK are both valid per RFC 7591) | |
| if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { | |
| logger.Error(fmt.Sprintf("[Dynamic Registration] Failed with status %d: %s", resp.StatusCode, string(respBody))) | |
| return nil, fmt.Errorf("registration failed with status %d: %s", resp.StatusCode, string(respBody)) | |
| } | |
| // Parse response | |
| var regResp DynamicClientRegistrationResponse | |
| if err := json.Unmarshal(respBody, ®Resp); err != nil { | |
| return nil, fmt.Errorf("failed to parse registration response: %w", err) | |
| } | |
| // Validate response | |
| if regResp.ClientID == "" { | |
| return nil, fmt.Errorf("registration response missing client_id") | |
| } | |
| logger.Debug(fmt.Sprintf("[Dynamic Registration] Successfully registered client_id: %s", regResp.ClientID)) | |
| if regResp.ClientSecret != "" { | |
| logger.Debug("[Dynamic Registration] Client secret provided by server") | |
| } else { | |
| logger.Debug("[Dynamic Registration] No client secret provided (public client)") | |
| } | |
| return ®Resp, nil | |
| } |
🤖 Prompt for AI Agents
In `@framework/oauth2/discovery.go` around lines 388 - 454, The function
RegisterDynamicClient currently dereferences req without checking for nil; add
an early nil-guard at the start of RegisterDynamicClient (before json.Marshal)
that returns a clear error (e.g., fmt.Errorf("registration request is nil")) if
req == nil, referencing the DynamicClientRegistrationRequest input parameter to
prevent a panic and surface a useful error to callers.
b31b58f to
595b1ed
Compare
fa01e38 to
74dc25d
Compare
There was a problem hiding this comment.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
core/mcp/utils.go (1)
125-174: Avoid reverse‑sanitizing MCP tool names; keep sanitized names end‑to‑end.The new
toolNameMapping+getOriginalToolNamerestores original MCP names and risks diverging from the existing convention where sanitized names (hyphen → underscore) are used throughout tool selection, execution, and reporting. This can re‑introduce mismatches in filtering and CallToolParams.Name. Please keep sanitized names end‑to‑end or update the full pipeline/docs/tests to a new contract. Based on learnings, the sanitized name should remain the canonical tool identifier.
Based on learnings, please keep sanitized names as the canonical identifiers.Also applies to: 640-661
docs/openapi/schemas/management/mcp.yaml (1)
72-82: Resolve merge‑conflict markers; the schema is invalid.The
<<<<<<< HEAD/=======/>>>>>>>block breaks YAML parsing and will fail OpenAPI bundling. Please resolve the conflict (decide betweenis_ping_availablevs. theis_code_mode_clientdescription) and remove the markers.framework/configstore/rdb.go (1)
923-932: The comment explaining field immutability should document both connection and auth field exclusions.The
updatesmap correctly excludesauth_typeandoauth_config_id, consistent with the application layer'sEditMCPClientfunction. However, the comment only justifies the exclusion of connection fields (ConnectionType,ConnectionString,StdioConfig) as read-only, leaving the auth field exclusion undocumented. Update the comment to clarify that auth fields are also immutable after creation—if users need to change authentication (e.g., switching from headers to OAuth), they must delete and recreate the client. This aligns the documented rationale with the implemented behavior.
🧹 Nitpick comments (5)
core/mcp/clientmanager.go (3)
122-159: AddClientInMemory duplicates AddClient logic.
AddClientInMemory(lines 122-159) is nearly identical toAddClient(lines 75-112). The only intended difference per the docstring is thatAddClientInMemoryshould not persist to the database, but both implementations in this file are identical. Consider extracting the common logic into a private helper to reduce duplication.♻️ Suggested refactor
+// addClientInternal is the shared implementation for AddClient and AddClientInMemory +func (m *MCPManager) addClientInternal(config schemas.MCPClientConfig) error { + if err := validateMCPClientConfig(&config); err != nil { + return fmt.Errorf("invalid MCP client configuration: %w", err) + } + configCopy := config + m.mu.Lock() + if _, ok := m.clientMap[config.ID]; ok { + m.mu.Unlock() + return fmt.Errorf("client %s already exists", config.Name) + } + m.clientMap[config.ID] = &schemas.MCPClientState{ + Name: config.Name, + ExecutionConfig: config, + ToolMap: make(map[string]schemas.ChatTool), + ToolNameMapping: make(map[string]string), + } + m.mu.Unlock() + if err := m.connectToMCPClient(configCopy); err != nil { + m.mu.Lock() + delete(m.clientMap, config.ID) + m.mu.Unlock() + return fmt.Errorf("failed to connect to MCP client %s: %w", config.Name, err) + } + return nil +} + func (m *MCPManager) AddClient(config schemas.MCPClientConfig) error { - // ... current implementation + return m.addClientInternal(config) } func (m *MCPManager) AddClientInMemory(config schemas.MCPClientConfig) error { - // ... current implementation + return m.addClientInternal(config) }
647-679: Unusedctxparameter increateSTDIOConnection.The
ctx context.Contextparameter is accepted but never used within the function body. If it's reserved for future use (e.g., passing to transport creation), consider adding a brief comment. Otherwise, this may cause linter warnings about unused parameters.
712-729: Unusedctxparameter increateInProcessConnection.Similar to
createSTDIOConnection, thectxparameter is unused. If this is intentional for API consistency with othercreate*Connectionmethods, a brief comment would clarify the design intent.core/schemas/oauth.go (1)
23-35: Consider protectingClientSecretfrom accidental JSON serialization.
OAuth2Config.ClientSecrethas a JSON tag that could expose secrets if the struct is marshaled. SinceClientSecretis sensitive, consider usingjson:"-"to prevent accidental leakage.♻️ Proposed fix
type OAuth2Config struct { ID string `json:"id"` ClientID string `json:"client_id,omitempty"` - ClientSecret string `json:"client_secret,omitempty"` + ClientSecret string `json:"-"` AuthorizeURL string `json:"authorize_url,omitempty"` TokenURL string `json:"token_url,omitempty"` RegistrationURL *string `json:"registration_url,omitempty"` RedirectURI string `json:"redirect_uri"` Scopes []string `json:"scopes,omitempty"` ServerURL string `json:"server_url"` UseDiscovery bool `json:"use_discovery,omitempty"` }framework/configstore/rdb.go (1)
2752-2768: CreateOauthConfig and CreateOauthToken properly use parseGormError.Minor inconsistency: These methods wrap errors with
fmt.Errorfbut don't uses.parseGormError()which provides user-friendly messages for unique constraint violations. Consider usingparseGormErrorfor consistency with other Create methods.♻️ Proposed fix
func (s *RDBConfigStore) CreateOauthConfig(ctx context.Context, config *tables.TableOauthConfig) error { result := s.db.WithContext(ctx).Create(config) if result.Error != nil { - return fmt.Errorf("failed to create oauth config: %w", result.Error) + return s.parseGormError(result.Error) } return nil } func (s *RDBConfigStore) CreateOauthToken(ctx context.Context, token *tables.TableOauthToken) error { result := s.db.WithContext(ctx).Create(token) if result.Error != nil { - return fmt.Errorf("failed to create oauth token: %w", result.Error) + return s.parseGormError(result.Error) } return nil }
There was a problem hiding this comment.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
core/mcp/clientmanager.go (1)
429-450: Existing client state is unconditionally overwritten after cleanup.At line 442, a new
MCPClientStateis always created, even whenexistingClientexists. This discards any modifications made to the existing client (lines 429-439) and loses existingToolMap/ToolNameMappingdata that may have been populated from prior connections. The existing client cleanup block modifiesexistingClientbut then the code immediately replaces it with a fresh struct.🐛 Suggested fix
// Initialize or validate client entry if existingClient, exists := m.clientMap[config.ID]; exists { // Client entry exists from config, check for existing connection, if it does then close if existingClient.CancelFunc != nil { existingClient.CancelFunc() existingClient.CancelFunc = nil } if existingClient.Conn != nil { existingClient.Conn.Close() } // Update connection type for this connection attempt existingClient.ConnectionInfo.Type = config.ConnectionType + existingClient.Name = config.Name + existingClient.ExecutionConfig = config + existingClient.ToolMap = make(map[string]schemas.ChatTool) + existingClient.ToolNameMapping = make(map[string]string) + } else { + // Create new client entry with configuration + m.clientMap[config.ID] = &schemas.MCPClientState{ + Name: config.Name, + ExecutionConfig: config, + ToolMap: make(map[string]schemas.ChatTool), + ToolNameMapping: make(map[string]string), + ConnectionInfo: schemas.MCPClientConnectionInfo{ + Type: config.ConnectionType, + }, + } } - // Create new client entry with configuration - m.clientMap[config.ID] = &schemas.MCPClientState{ - Name: config.Name, - ExecutionConfig: config, - ToolMap: make(map[string]schemas.ChatTool), - ToolNameMapping: make(map[string]string), - ConnectionInfo: schemas.MCPClientConnectionInfo{ - Type: config.ConnectionType, - }, - }core/mcp/toolmanager.go (1)
469-504: Don’t reverse-sanitize MCP tool names for server calls.The MCP tooling invariant in this repo is to use sanitized names end‑to‑end (including
CallToolParams.Name). Re-mapping to the original name risks “tool not found” failures and breaks the established naming contract. Keep the sanitized name for MCP calls and drop the reverse mapping. Based on learnings, rely on sanitized names throughout.🔧 Suggested fix
-// The MCP server expects the original tool name (with hyphens), not the sanitized version -sanitizedToolName := stripClientPrefix(toolName, client.ExecutionConfig.Name) -originalMCPToolName := getOriginalToolName(sanitizedToolName, client) +sanitizedToolName := stripClientPrefix(toolName, client.ExecutionConfig.Name) ... -Name: originalMCPToolName, +Name: sanitizedToolName,docs/openapi/schemas/management/mcp.yaml (1)
70-82: Resolve merge conflict markers in schema.The conflict markers make the YAML invalid and will break OpenAPI bundling.
🧩 Proposed resolution
is_code_mode_client: type: boolean -<<<<<<< HEAD - - is_ping_available: - type: boolean - default: true - description: | - Whether the MCP server supports ping for health checks. - If true, uses lightweight ping method for health checks. - If false, uses listTools method for health checks instead. -======= - description: Whether this client is available in code mode ->>>>>>> 4e846193 (feat: plugin schema extensions for mcp plugins) + description: Whether this client is available in code mode + is_ping_available: + type: boolean + default: true + description: | + Whether the MCP server supports ping for health checks. + If true, uses lightweight ping method for health checks. + If false, uses listTools method for health checks instead.framework/configstore/rdb.go (1)
921-932: AuthType and OauthConfigID are silently ignored during updates despite being accepted by the API.The API endpoint accepts
AuthTypeandOauthConfigIDfrom clients (handlers/mcp.go:322), butEditMCPClientdoes not propagate these fields to the database. Unlike connection fields (ConnectionType, ConnectionString, StdioConfig) which are explicitly marked read-only and preserved from the existing config, auth fields are neither updated nor preserved. Either mark these fields as immutable and validate the API to reject update attempts, or include them in the updates map (lines 923–932) to allow modification.
🧹 Nitpick comments (4)
core/mcp/clientmanager.go (2)
75-159: Consider extracting shared logic betweenAddClientandAddClientInMemory.Both methods have nearly identical implementations (validation, placeholder creation, connection attempt, cleanup on failure). The only difference appears to be semantic (DB persistence vs in-memory only), but the code is duplicated.
♻️ Suggested refactor to reduce duplication
Extract the common logic into a private helper method:
func (m *MCPManager) addClientInternal(config schemas.MCPClientConfig) error { if err := validateMCPClientConfig(&config); err != nil { return fmt.Errorf("invalid MCP client configuration: %w", err) } configCopy := config m.mu.Lock() if _, ok := m.clientMap[config.ID]; ok { m.mu.Unlock() return fmt.Errorf("client %s already exists", config.Name) } m.clientMap[config.ID] = &schemas.MCPClientState{ Name: config.Name, ExecutionConfig: config, ToolMap: make(map[string]schemas.ChatTool), ToolNameMapping: make(map[string]string), } m.mu.Unlock() if err := m.connectToMCPClient(configCopy); err != nil { m.mu.Lock() delete(m.clientMap, config.ID) m.mu.Unlock() return fmt.Errorf("failed to connect to MCP client %s: %w", config.Name, err) } return nil }Then both public methods can call this helper.
647-679: Unusedctxparameter in STDIO connection.The
ctxparameter is accepted but not used. This is likely for interface consistency with HTTP/SSE methods, but consider adding a comment to clarify the intent, or using_ context.Contextto explicitly indicate it's intentionally unused.-func (m *MCPManager) createSTDIOConnection(ctx context.Context, config schemas.MCPClientConfig) (*client.Client, schemas.MCPClientConnectionInfo, error) { +func (m *MCPManager) createSTDIOConnection(_ context.Context, config schemas.MCPClientConfig) (*client.Client, schemas.MCPClientConnectionInfo, error) {core/schemas/mcp.go (1)
83-86: Clarify the purpose ofStatefield vsMCPConnectionState.The
Statefield (string) onMCPClientConfigcould be confused withMCPConnectionState(enum used inMCPClientState.State). Consider renaming to clarify its purpose (e.g.,OAuthStateif it's for OAuth state parameter, orConnectionStatusif it represents a different concept).Based on context, if this is for tracking OAuth flow state (the OAuth
stateparameter for CSRF protection), consider:- State string `json:"state,omitempty"` // Connection state (connected, disconnected, error) + OAuthFlowState string `json:"oauth_flow_state,omitempty"` // OAuth state parameter for CSRF protection during OAuth flowcore/bifrost.go (1)
234-238: Prefer Ptr helper for pooled MCP request pointers.This keeps pointer creation consistent with repo conventions for new pooled values.
♻️ Suggested tweak
bifrost.mcpRequestPool = sync.Pool{ New: func() interface{} { - return &schemas.BifrostMCPRequest{} + return Ptr(schemas.BifrostMCPRequest{}) }, } ... - bifrost.mcpRequestPool.Put(&schemas.BifrostMCPRequest{}) + bifrost.mcpRequestPool.Put(Ptr(schemas.BifrostMCPRequest{}))Based on learnings, prefer Ptr helper for pointer creation.
Also applies to: 250-252
595b1ed to
e3b6ca5
Compare
74dc25d to
1c04269
Compare
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
docs/openapi/schemas/management/mcp.yaml (1)
72-82: Critical: Unresolved merge conflict markers in YAML file.The file contains unresolved Git merge conflict markers (
<<<<<<< HEAD,=======,>>>>>>> 4e846193). This will cause YAML parsing failures and break the OpenAPI spec bundling.Resolve the conflict by choosing one of the options:
- Keep the
is_ping_availablefield from HEAD, or- Keep just the
descriptionline from the other branch🐛 Proposed fix (assuming is_ping_available should be kept)
is_code_mode_client: type: boolean -<<<<<<< HEAD - is_ping_available: - type: boolean - default: true - description: | - Whether the MCP server supports ping for health checks. - If true, uses lightweight ping method for health checks. - If false, uses listTools method for health checks instead. -======= description: Whether this client is available in code mode ->>>>>>> 4e846193 (feat: plugin schema extensions for mcp plugins) + is_ping_available: + type: boolean + default: true + description: | + Whether the MCP server supports ping for health checks. + If true, uses lightweight ping method for health checks. + If false, uses listTools method for health checks instead.framework/configstore/rdb.go (1)
921-932: auth_type and oauth_config_id not persisted on update.The
UpdateMCPClientConfigmethod does not includeauth_typeoroauth_config_idin the updates map. This means the OpenAPI schema allows updating these fields, but they will be silently ignored. This is inconsistent withCreateMCPClientConfigwhich does persist these fields.🐛 Proposed fix
updates := map[string]interface{}{ "name": clientConfigCopy.Name, "is_code_mode_client": clientConfigCopy.IsCodeModeClient, "tools_to_execute_json": string(toolsToExecuteJSON), "tools_to_auto_execute_json": string(toolsToAutoExecuteJSON), "headers_json": string(headersJSON), "tool_pricing_json": string(toolPricingJSON), "is_ping_available": clientConfigCopy.IsPingAvailable, + "auth_type": clientConfigCopy.AuthType, + "oauth_config_id": clientConfigCopy.OauthConfigID, "updated_at": time.Now(), }
🤖 Fix all issues with AI agents
In `@core/mcp/utils.go`:
- Around line 640-660: The getOriginalToolName function reintroduces original
MCP names and breaks the canonical sanitization convention; remove or neutralize
reverse-sanitization by changing getOriginalToolName to always return the
sanitized name (i.e., ignore client.ToolNameMapping), and update any callers to
stop relying on reverse mapping; specifically, eliminate usage of
client.ToolNameMapping in getOriginalToolName (and consider removing the
ToolNameMapping read paths in schemas.MCPClientState if unused) so sanitized
names remain canonical end-to-end.
In `@docs/openapi/openapi.json`:
- Around line 129084-129091: The OpenAPI parameter "code" (query param name
"code") is incorrectly marked required which will reject OAuth error callbacks;
update the parameter definition for "code" to not be required (either remove the
"required" property or set it to false) and keep its description/schema intact
so the endpoint can accept requests that include "error" and "error_description"
instead of "code".
- Around line 128572-128775: The reconnectMCPClient operation (path
"/api/mcp/client/{id}/reconnect", operationId reconnectMCPClient) is missing a
404 response for nonexistent MCP client IDs; add a "404" response entry
mirroring the 400/500 error structure (description like "MCP client not found"
and the same error schema with
event_id/type/is_bifrost_error/status_code/error/extra_fields) so clients
receive a proper Not Found response when the given id does not exist.
🧹 Nitpick comments (8)
plugins/semanticcache/plugin_vectorstore_test.go (6)
103-106: Error message references old method name.The method was renamed to
PreLLMHook, but the error message on line 105 still says"PreHook failed". This inconsistency could cause confusion when debugging test failures.Suggested fix
modifiedReq, shortCircuit, err := setup.Plugin.PreLLMHook(ctx, request) if err != nil { - t.Fatalf("[%s] PreHook failed: %v", tc.Name, err) + t.Fatalf("[%s] PreLLMHook failed: %v", tc.Name, err) }
144-147: Error message references old method name.Same issue: error message says
"PostHook failed"but the method is nowPostLLMHook.Suggested fix
_, _, err = setup.Plugin.PostLLMHook(ctx, response, nil) if err != nil { - t.Fatalf("[%s] PostHook failed: %v", tc.Name, err) + t.Fatalf("[%s] PostLLMHook failed: %v", tc.Name, err) }
159-162: Error message references old method name.Error message says
"Second PreHook failed"but the method isPreLLMHook.Suggested fix
_, shortCircuit2, err := setup.Plugin.PreLLMHook(ctx2, request) if err != nil { - t.Fatalf("[%s] Second PreHook failed: %v", tc.Name, err) + t.Fatalf("[%s] Second PreLLMHook failed: %v", tc.Name, err) }
309-312: Error message references old method name.Error message says
"First PreHook failed"but the method isPreLLMHook.Suggested fix
_, shortCircuit1, err := setup.Plugin.PreLLMHook(ctx, request1) if err != nil { - t.Fatalf("[%s] First PreHook failed: %v", tc.Name, err) + t.Fatalf("[%s] First PreLLMHook failed: %v", tc.Name, err) }
341-344: Error message references old method name.Error message says
"PostHook failed"but the method isPostLLMHook.Suggested fix
_, _, err = setup.Plugin.PostLLMHook(ctx, response, nil) if err != nil { - t.Fatalf("[%s] PostHook failed: %v", tc.Name, err) + t.Fatalf("[%s] PostLLMHook failed: %v", tc.Name, err) }
375-378: Error message references old method name.Error message says
"Second PreHook failed"but the method isPreLLMHook.Suggested fix
_, shortCircuit2, err := setup.Plugin.PreLLMHook(ctx2, request2) if err != nil { - t.Fatalf("[%s] Second PreHook failed: %v", tc.Name, err) + t.Fatalf("[%s] Second PreLLMHook failed: %v", tc.Name, err) }core/mcp/clientmanager.go (2)
91-112: Consider extracting shared logic between AddClient and AddClientInMemory.
AddClientandAddClientInMemoryshare nearly identical implementation (validation, placeholder creation, unlock-connect-cleanup pattern). The only difference is thatAddClientis intended for new clients whileAddClientInMemoryis for clients already persisted in the database.Consider extracting the shared logic into a private helper method to reduce duplication and ensure both paths stay synchronized.
♻️ Suggested refactor
// addClientInternal handles the common logic for adding MCP clients func (m *MCPManager) addClientInternal(config schemas.MCPClientConfig) error { if err := validateMCPClientConfig(&config); err != nil { return fmt.Errorf("invalid MCP client configuration: %w", err) } configCopy := config m.mu.Lock() if _, ok := m.clientMap[config.ID]; ok { m.mu.Unlock() return fmt.Errorf("client %s already exists", config.Name) } m.clientMap[config.ID] = &schemas.MCPClientState{ Name: config.Name, ExecutionConfig: config, ToolMap: make(map[string]schemas.ChatTool), ToolNameMapping: make(map[string]string), } m.mu.Unlock() if err := m.connectToMCPClient(configCopy); err != nil { m.mu.Lock() delete(m.clientMap, config.ID) m.mu.Unlock() return fmt.Errorf("failed to connect to MCP client %s: %w", config.Name, err) } return nil } func (m *MCPManager) AddClient(config schemas.MCPClientConfig) error { return m.addClientInternal(config) } func (m *MCPManager) AddClientInMemory(config schemas.MCPClientConfig) error { return m.addClientInternal(config) }Also applies to: 122-159
647-647: Unusedctxparameter in connection methods.
createSTDIOConnectionandcreateInProcessConnectionaccept actx context.Contextparameter but don't use it. While this maintains a consistent function signature across connection methods, consider adding a brief comment explaining the parameter is included for API consistency, or use_ context.Contextto explicitly indicate it's unused.Also applies to: 712-712
| // getOriginalToolName retrieves the original MCP tool name from the sanitized name using the mapping. | ||
| // This function is used to restore the original tool name (with hyphens) that the MCP server expects. | ||
| // | ||
| // Parameters: | ||
| // - sanitizedToolName: Sanitized tool name (e.g., "notion_search") | ||
| // - client: The MCP client state containing the name mapping | ||
| // | ||
| // Returns: | ||
| // - string: Original MCP tool name (e.g., "notion-search"), or sanitizedToolName if not found in mapping | ||
| func getOriginalToolName(sanitizedToolName string, client *schemas.MCPClientState) string { | ||
| if client == nil || client.ToolNameMapping == nil { | ||
| return sanitizedToolName | ||
| } | ||
|
|
||
| // Look up the original MCP name in the mapping | ||
| if originalName, exists := client.ToolNameMapping[sanitizedToolName]; exists { | ||
| return originalName | ||
| } | ||
|
|
||
| // If not in mapping, return as-is (might not need mapping if names are the same) | ||
| return sanitizedToolName |
There was a problem hiding this comment.
Avoid reverse-sanitizing MCP tool names.
getOriginalToolName reintroduces original MCP names after sanitization, which conflicts with the established convention of using sanitized names end‑to‑end. This risks mismatches in tool filtering, execution, and UI display. Consider keeping sanitized names for all downstream usage, or strictly isolate any reverse mapping to the MCP wire layer only. Based on learnings, please keep sanitized names as the canonical form.
🤖 Prompt for AI Agents
In `@core/mcp/utils.go` around lines 640 - 660, The getOriginalToolName function
reintroduces original MCP names and breaks the canonical sanitization
convention; remove or neutralize reverse-sanitization by changing
getOriginalToolName to always return the sanitized name (i.e., ignore
client.ToolNameMapping), and update any callers to stop relying on reverse
mapping; specifically, eliminate usage of client.ToolNameMapping in
getOriginalToolName (and consider removing the ToolNameMapping read paths in
schemas.MCPClientState if unused) so sanitized names remain canonical
end-to-end.
| "/api/mcp/client/{id}/reconnect": { | ||
| "post": { | ||
| "operationId": "reconnectMCPClient", | ||
| "summary": "Reconnect MCP client", | ||
| "description": "Reconnects an MCP client that is in an error or disconnected state.", | ||
| "tags": [ | ||
| "MCP" | ||
| ], | ||
| "parameters": [ | ||
| { | ||
| "name": "id", | ||
| "in": "path", | ||
| "required": true, | ||
| "description": "MCP client ID", | ||
| "schema": { | ||
| "type": "string" | ||
| } | ||
| } | ||
| ], | ||
| "responses": { | ||
| "200": { | ||
| "description": "MCP client reconnected successfully", | ||
| "content": { | ||
| "application/json": { | ||
| "schema": { | ||
| "type": "object", | ||
| "description": "Generic success response", | ||
| "properties": { | ||
| "status": { | ||
| "type": "string", | ||
| "example": "success" | ||
| }, | ||
| "message": { | ||
| "type": "string", | ||
| "example": "Operation completed successfully" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| "400": { | ||
| "description": "Bad request", | ||
| "content": { | ||
| "application/json": { | ||
| "schema": { | ||
| "type": "object", | ||
| "description": "Error response from Bifrost", | ||
| "properties": { | ||
| "event_id": { | ||
| "type": "string" | ||
| }, | ||
| "type": { | ||
| "type": "string" | ||
| }, | ||
| "is_bifrost_error": { | ||
| "type": "boolean" | ||
| }, | ||
| "status_code": { | ||
| "type": "integer" | ||
| }, | ||
| "error": { | ||
| "type": "object", | ||
| "properties": { | ||
| "type": { | ||
| "type": "string" | ||
| }, | ||
| "code": { | ||
| "type": "string" | ||
| }, | ||
| "message": { | ||
| "type": "string" | ||
| }, | ||
| "param": { | ||
| "type": "string" | ||
| }, | ||
| "event_id": { | ||
| "type": "string" | ||
| } | ||
| } | ||
| }, | ||
| "extra_fields": { | ||
| "type": "object", | ||
| "properties": { | ||
| "provider": { | ||
| "type": "string", | ||
| "description": "AI model provider identifier", | ||
| "enum": [ | ||
| "openai", | ||
| "azure", | ||
| "anthropic", | ||
| "bedrock", | ||
| "cohere", | ||
| "vertex", | ||
| "mistral", | ||
| "ollama", | ||
| "groq", | ||
| "sgl", | ||
| "parasail", | ||
| "perplexity", | ||
| "cerebras", | ||
| "gemini", | ||
| "openrouter", | ||
| "elevenlabs", | ||
| "huggingface", | ||
| "nebius", | ||
| "xai" | ||
| ] | ||
| }, | ||
| "model_requested": { | ||
| "type": "string" | ||
| }, | ||
| "request_type": { | ||
| "type": "string" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| "500": { | ||
| "description": "Internal server error", | ||
| "content": { | ||
| "application/json": { | ||
| "schema": { | ||
| "type": "object", | ||
| "description": "Error response from Bifrost", | ||
| "properties": { | ||
| "event_id": { | ||
| "type": "string" | ||
| }, | ||
| "type": { | ||
| "type": "string" | ||
| }, | ||
| "is_bifrost_error": { | ||
| "type": "boolean" | ||
| }, | ||
| "status_code": { | ||
| "type": "integer" | ||
| }, | ||
| "error": { | ||
| "type": "object", | ||
| "properties": { | ||
| "type": { | ||
| "type": "string" | ||
| }, | ||
| "code": { | ||
| "type": "string" | ||
| }, | ||
| "message": { | ||
| "type": "string" | ||
| }, | ||
| "param": { | ||
| "type": "string" | ||
| }, | ||
| "event_id": { | ||
| "type": "string" | ||
| } | ||
| } | ||
| }, | ||
| "extra_fields": { | ||
| "type": "object", | ||
| "properties": { | ||
| "provider": { | ||
| "type": "string", | ||
| "description": "AI model provider identifier", | ||
| "enum": [ | ||
| "openai", | ||
| "azure", | ||
| "anthropic", | ||
| "bedrock", | ||
| "cohere", | ||
| "vertex", | ||
| "mistral", | ||
| "ollama", | ||
| "groq", | ||
| "sgl", | ||
| "parasail", | ||
| "perplexity", | ||
| "cerebras", | ||
| "gemini", | ||
| "openrouter", | ||
| "elevenlabs", | ||
| "huggingface", | ||
| "nebius", | ||
| "xai" | ||
| ] | ||
| }, | ||
| "model_requested": { | ||
| "type": "string" | ||
| }, | ||
| "request_type": { | ||
| "type": "string" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Add 404 response for reconnect endpoint when MCP client doesn't exist.
The reconnect endpoint only specifies 200, 400, and 500 responses. It should also handle the case where the MCP client ID doesn't exist, returning a 404 response.
🔧 Proposed addition after line 128693
},
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
"500": {
"description": "Internal server error",🤖 Prompt for AI Agents
In `@docs/openapi/openapi.json` around lines 128572 - 128775, The
reconnectMCPClient operation (path "/api/mcp/client/{id}/reconnect", operationId
reconnectMCPClient) is missing a 404 response for nonexistent MCP client IDs;
add a "404" response entry mirroring the 400/500 error structure (description
like "MCP client not found" and the same error schema with
event_id/type/is_bifrost_error/status_code/error/extra_fields) so clients
receive a proper Not Found response when the given id does not exist.
| { | ||
| "name": "code", | ||
| "in": "query", | ||
| "required": true, | ||
| "description": "Authorization code from the OAuth provider", | ||
| "schema": { | ||
| "type": "string" | ||
| } |
There was a problem hiding this comment.
code query parameter should not be required.
Per OAuth 2.0 RFC 6749 §4.1.2.1, when authorization fails, the provider redirects with error (and optionally error_description) instead of code. The endpoint description at line 129070 correctly mentions handling errors, but marking code as required will cause validation failures for error callbacks.
🔧 Proposed fix
{
"name": "code",
"in": "query",
- "required": true,
+ "required": false,
"description": "Authorization code from the OAuth provider",
"schema": {
"type": "string"
}
},📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| { | |
| "name": "code", | |
| "in": "query", | |
| "required": true, | |
| "description": "Authorization code from the OAuth provider", | |
| "schema": { | |
| "type": "string" | |
| } | |
| { | |
| "name": "code", | |
| "in": "query", | |
| "required": false, | |
| "description": "Authorization code from the OAuth provider", | |
| "schema": { | |
| "type": "string" | |
| } |
🤖 Prompt for AI Agents
In `@docs/openapi/openapi.json` around lines 129084 - 129091, The OpenAPI
parameter "code" (query param name "code") is incorrectly marked required which
will reject OAuth error callbacks; update the parameter definition for "code" to
not be required (either remove the "required" property or set it to false) and
keep its description/schema intact so the endpoint can accept requests that
include "error" and "error_description" instead of "code".
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
docs/openapi/schemas/management/mcp.yaml (1)
70-82: Resolve leftover merge conflict markers.The conflict markers will break schema parsing/bundling. Please clean them up and retain both the
is_code_mode_clientdescription and theis_ping_availablefield.🧩 Suggested resolution
is_code_mode_client: type: boolean -<<<<<<< HEAD - is_ping_available: - type: boolean - default: true - description: | - Whether the MCP server supports ping for health checks. - If true, uses lightweight ping method for health checks. - If false, uses listTools method for health checks instead. -======= - description: Whether this client is available in code mode ->>>>>>> 4e846193 (feat: plugin schema extensions for mcp plugins) + description: Whether this client is available in code mode + is_ping_available: + type: boolean + default: true + description: | + Whether the MCP server supports ping for health checks. + If true, uses lightweight ping method for health checks. + If false, uses listTools method for health checks instead.
🤖 Fix all issues with AI agents
In `@docs/openapi/openapi.json`:
- Around line 128591-128775: Add a 404 response entry for the reconnectMCPClient
endpoint: create a "404" sibling to the existing "200", "400", and "500" keys
under the operation's "responses" object named reconnectMCPClient, set
"description" to something like "MCP client not found", and provide an
"application/json" "content" schema matching the existing error response shape
(same properties as the "400" and "500" error schema including event_id, type,
is_bifrost_error, status_code, error, extra_fields with provider enum,
model_requested, request_type) so clients can parse a not-found error
consistently.
🧹 Nitpick comments (3)
docs/mcp/connecting-to-servers.mdx (1)
53-113: Clarify optional OAuth fields for PKCE/discovery.
The example always includesclient_secret,authorize_url, andtoken_url. A brief note about PKCE public clients and discovery-only configs would reduce confusion.✍️ Suggested doc tweak
```json { "name": "web-search", "connection_type": "http", "connection_string": "https://mcp-server.example.com/mcp", "auth_type": "oauth", "oauth_config": { "client_id": "your-client-id", "client_secret": "your-client-secret", "authorize_url": "https://auth.example.com/authorize", "token_url": "https://auth.example.com/token", "scopes": ["read", "write"] }, "tools_to_execute": ["*"] }
+
+For public clients using PKCE, omitclient_secret. If using discovery, set
+use_discovery: trueandserver_url, and omitauthorize_url/token_url
+(andregistration_urlif unsupported).
+</details> </blockquote></details> <details> <summary>framework/configstore/rdb.go (1)</summary><blockquote> `2788-2795`: **Consider returning ErrNotFound when no rows are affected.** The `DeleteOauthToken` method doesn't check if the delete operation affected any rows. If the token doesn't exist, the operation silently succeeds. For consistency with other delete patterns that fetch first or check `RowsAffected`, consider adding a check. <details> <summary>♻️ Optional: Check if token existed</summary> ```diff func (s *RDBConfigStore) DeleteOauthToken(ctx context.Context, id string) error { result := s.db.WithContext(ctx).Where("id = ?", id).Delete(&tables.TableOauthToken{}) if result.Error != nil { return fmt.Errorf("failed to delete oauth token: %w", result.Error) } + if result.RowsAffected == 0 { + return ErrNotFound + } return nil }framework/oauth2/main.go (1)
577-577: Consider reusinghttp.Clientfor connection pooling.Creating a new
http.Clienton every token endpoint call is inefficient and could lead to socket exhaustion under high load. Store a shared client with appropriate timeout on theOAuth2Providerstruct.♻️ Suggested approach
type OAuth2Provider struct { configStore configstore.ConfigStore mu sync.RWMutex pendingMCPClients map[string]*PendingMCPClient + httpClient *http.Client } func NewOAuth2Provider(configStore configstore.ConfigStore, logger schemas.Logger) *OAuth2Provider { // ... p := &OAuth2Provider{ configStore: configStore, pendingMCPClients: make(map[string]*PendingMCPClient), + httpClient: &http.Client{Timeout: 30 * time.Second}, } // ... } func (p *OAuth2Provider) callTokenEndpoint(tokenURL string, data url.Values) (*schemas.OAuth2TokenExchangeResponse, error) { // ... - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) + resp, err := p.httpClient.Do(req) // ... }
| "responses": { | ||
| "200": { | ||
| "description": "MCP client reconnected successfully", | ||
| "content": { | ||
| "application/json": { | ||
| "schema": { | ||
| "type": "object", | ||
| "description": "Generic success response", | ||
| "properties": { | ||
| "status": { | ||
| "type": "string", | ||
| "example": "success" | ||
| }, | ||
| "message": { | ||
| "type": "string", | ||
| "example": "Operation completed successfully" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| "400": { | ||
| "description": "Bad request", | ||
| "content": { | ||
| "application/json": { | ||
| "schema": { | ||
| "type": "object", | ||
| "description": "Error response from Bifrost", | ||
| "properties": { | ||
| "event_id": { | ||
| "type": "string" | ||
| }, | ||
| "type": { | ||
| "type": "string" | ||
| }, | ||
| "is_bifrost_error": { | ||
| "type": "boolean" | ||
| }, | ||
| "status_code": { | ||
| "type": "integer" | ||
| }, | ||
| "error": { | ||
| "type": "object", | ||
| "properties": { | ||
| "type": { | ||
| "type": "string" | ||
| }, | ||
| "code": { | ||
| "type": "string" | ||
| }, | ||
| "message": { | ||
| "type": "string" | ||
| }, | ||
| "param": { | ||
| "type": "string" | ||
| }, | ||
| "event_id": { | ||
| "type": "string" | ||
| } | ||
| } | ||
| }, | ||
| "extra_fields": { | ||
| "type": "object", | ||
| "properties": { | ||
| "provider": { | ||
| "type": "string", | ||
| "description": "AI model provider identifier", | ||
| "enum": [ | ||
| "openai", | ||
| "azure", | ||
| "anthropic", | ||
| "bedrock", | ||
| "cohere", | ||
| "vertex", | ||
| "mistral", | ||
| "ollama", | ||
| "groq", | ||
| "sgl", | ||
| "parasail", | ||
| "perplexity", | ||
| "cerebras", | ||
| "gemini", | ||
| "openrouter", | ||
| "elevenlabs", | ||
| "huggingface", | ||
| "nebius", | ||
| "xai" | ||
| ] | ||
| }, | ||
| "model_requested": { | ||
| "type": "string" | ||
| }, | ||
| "request_type": { | ||
| "type": "string" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| "500": { | ||
| "description": "Internal server error", | ||
| "content": { | ||
| "application/json": { | ||
| "schema": { | ||
| "type": "object", | ||
| "description": "Error response from Bifrost", | ||
| "properties": { | ||
| "event_id": { | ||
| "type": "string" | ||
| }, | ||
| "type": { | ||
| "type": "string" | ||
| }, | ||
| "is_bifrost_error": { | ||
| "type": "boolean" | ||
| }, | ||
| "status_code": { | ||
| "type": "integer" | ||
| }, | ||
| "error": { | ||
| "type": "object", | ||
| "properties": { | ||
| "type": { | ||
| "type": "string" | ||
| }, | ||
| "code": { | ||
| "type": "string" | ||
| }, | ||
| "message": { | ||
| "type": "string" | ||
| }, | ||
| "param": { | ||
| "type": "string" | ||
| }, | ||
| "event_id": { | ||
| "type": "string" | ||
| } | ||
| } | ||
| }, | ||
| "extra_fields": { | ||
| "type": "object", | ||
| "properties": { | ||
| "provider": { | ||
| "type": "string", | ||
| "description": "AI model provider identifier", | ||
| "enum": [ | ||
| "openai", | ||
| "azure", | ||
| "anthropic", | ||
| "bedrock", | ||
| "cohere", | ||
| "vertex", | ||
| "mistral", | ||
| "ollama", | ||
| "groq", | ||
| "sgl", | ||
| "parasail", | ||
| "perplexity", | ||
| "cerebras", | ||
| "gemini", | ||
| "openrouter", | ||
| "elevenlabs", | ||
| "huggingface", | ||
| "nebius", | ||
| "xai" | ||
| ] | ||
| }, | ||
| "model_requested": { | ||
| "type": "string" | ||
| }, | ||
| "request_type": { | ||
| "type": "string" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Add 404 response for reconnectMCPClient endpoint.
The reconnect endpoint only specifies 200, 400, and 500 responses. It should also handle the case where the MCP client ID doesn't exist with a 404 response.
🔧 Proposed addition
}
},
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
"500": {
"description": "Internal server error",🤖 Prompt for AI Agents
In `@docs/openapi/openapi.json` around lines 128591 - 128775, Add a 404 response
entry for the reconnectMCPClient endpoint: create a "404" sibling to the
existing "200", "400", and "500" keys under the operation's "responses" object
named reconnectMCPClient, set "description" to something like "MCP client not
found", and provide an "application/json" "content" schema matching the existing
error response shape (same properties as the "400" and "500" error schema
including event_id, type, is_bifrost_error, status_code, error, extra_fields
with provider enum, model_requested, request_type) so clients can parse a
not-found error consistently.

Summary
This PR adds OAuth 2.0 authentication support for MCP clients, enabling secure API access to external MCP servers without sharing API keys. It implements the full OAuth flow including authorization, token exchange, and automatic token refresh.
Changes
Type of change
Affected areas
How to test
Screenshots/Recordings
N/A
Breaking changes
Related issues
N/A
Security considerations
Checklist