Skip to content

feat: add OAuth 2.0 support for MCP clients with discovery and PKCE#1430

Closed
Pratham-Mishra04 wants to merge 5 commits intographite-base/1430from
01-24-feat_add_oauth_support_to_mcp
Closed

feat: add OAuth 2.0 support for MCP clients with discovery and PKCE#1430
Pratham-Mishra04 wants to merge 5 commits intographite-base/1430from
01-24-feat_add_oauth_support_to_mcp

Conversation

@Pratham-Mishra04
Copy link
Collaborator

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

  • Added OAuth provider implementation with database persistence for tokens
  • Implemented OAuth discovery to automatically detect authorization endpoints
  • Added PKCE support for enhanced security with public clients
  • Created UI components for OAuth authorization flow
  • Added database migrations for OAuth configuration and token storage
  • Implemented automatic token refresh for expiring tokens
  • Added support for dynamic client registration (RFC 7591)

Type of change

  • Feature
  • Bug fix
  • Refactor
  • Documentation
  • Chore/CI

Affected areas

  • Core (Go)
  • Transports (HTTP)
  • Providers/Integrations
  • Plugins
  • UI (Next.js)
  • Docs

How to test

  1. Start Bifrost with the HTTP transport
  2. Navigate to the MCP Gateway page
  3. Add a new MCP client with auth_type="oauth"
  4. Complete the OAuth flow in the popup window
  5. Verify the MCP client connects successfully with OAuth authentication
# Core/Transports
go test ./framework/oauth/...

# UI
cd ui
pnpm i
pnpm dev

Screenshots/Recordings

N/A

Breaking changes

  • Yes
  • No

Related issues

N/A

Security considerations

  • OAuth tokens are stored in the database
  • PKCE is used for enhanced security with public clients
  • Token refresh is handled automatically for expiring tokens
  • OAuth state parameter is used to prevent CSRF attacks

Checklist

  • I added/updated tests where appropriate
  • I verified builds succeed (Go and UI)
  • I verified the CI pipeline passes locally if applicable

Copy link
Collaborator Author

Pratham-Mishra04 commented Jan 23, 2026

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.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 23, 2026

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added OAuth 2.0 authentication support for MCP HTTP and SSE connections.
    • Introduced automatic tool synchronization with configurable refresh intervals.
    • Added Python/Starlark-based code execution environment with new documentation retrieval tool.
  • Enhancements

    • Expanded MCP client configuration UI with authentication type selection and OAuth flow integration.
    • Added new MCP registry and authentication configuration pages.
  • Documentation

    • Updated code mode documentation to reflect Python/Starlark runtime and new tooling capabilities.

✏️ Tip: You can customize this high-level summary in your review settings.

Walkthrough

This 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

Cohort / File(s) Summary
OAuth2 Framework
framework/oauth2/main.go, framework/oauth2/discovery.go, framework/oauth2/sync.go, framework/oauth2/init.go
New OAuth2 provider implementation with token lifecycle (GetAccessToken, RefreshAccessToken, ValidateToken, RevokeToken), OAuth flow orchestration (InitiateOAuthFlow, CompleteOAuthFlow), PKCE support, OAuth discovery, dynamic client registration, and background token refresh worker with configurable intervals.
OAuth2 Database & Schema
framework/configstore/migrations.go, framework/configstore/tables/oauth.go, framework/configstore/rdb.go, framework/configstore/store.go, core/schemas/oauth.go
New OAuth configuration and token tables, schema types (OAuth2Config, OAuth2Token, OAuth2Provider interface), database CRUD operations, migrations for oauth_configs/oauth_tokens tables, and tool sync interval columns.
MCP OAuth Integration
core/schemas/mcp.go, core/schemas/bifrost.go, core/bifrost.go, core/mcp/mcp.go, core/mcp/clientmanager.go
Extended MCPClientConfig with auth_type enum, OauthConfigID, Headers, and ToolSyncInterval fields; updated MCPConfig to support ClientConfigs as pointers; wired OAuth2Provider into Bifrost and MCPManager; modified client connection methods to accept context and use OAuth headers.
Starlark Code Mode
core/mcp/codemode/starlark/starlark.go, core/mcp/codemode/starlark/executecode.go, core/mcp/codemode/starlark/getdocs.go, core/mcp/codemode/starlark/listfiles.go, core/mcp/codemode/starlark/readfile.go, core/mcp/codemode/starlark/utils.go, core/mcp/codemode/starlark/init.go, core/mcp/codemode.go
Complete replacement of TypeScript code mode with Starlark/Python-based execution; added CodeMode interface, StarlarkCodeMode implementation, meta-tools (listToolFiles, readToolFile, getToolDocs, executeToolCode), Starlark-to-Go value conversion, error hint generation, and tool binding.
Code Mode Abstraction
core/mcp/toolmanager.go, core/mcp/agent.go, core/mcp/interface.go
Refactored code execution to use pluggable CodeMode interface; updated ToolsManager to support code-mode delegation; added MCPManagerInterface as public contract; changed PluginPipeline to abstract plugin hook execution.
Tool Synchronization
core/mcp/toolsync.go, core/mcp/utils.go
New ToolSyncManager and ClientToolSyncer for background tool synchronization; added tool name mapping (sanitized to original MCP names); updated retrieveExternalTools to return name mappings; configurable per-client and global sync intervals.
MCP Client Lifecycle
core/mcp/clientmanager.go
Refactored client connection methods to accept *MCPClientConfig pointers; added AddClientInMemory method; updated connection helpers (HTTP, SSE, STDIO, in-process) to accept context and propagate OAuth headers; added ToolNameMapping to MCPClientState; improved cleanup paths.
Config Store & Persistence
transports/bifrost-http/lib/config.go, transports/bifrost-http/lib/config_test.go, framework/configstore/clientconfig.go, framework/configstore/tables/clientconfig.go
Added MCPToolSyncInterval to global client config; migrated MCP config types from table-based to schema-based; wired OAuth2Provider initialization and TokenRefreshWorker startup; updated MCP client CRUD to work with pointer-based schemas.
HTTP Transport & Handlers
transports/bifrost-http/handlers/mcp.go, transports/bifrost-http/handlers/oauth2.go, transports/bifrost-http/server/server.go, transports/bifrost-http/server/plugins.go, transports/bifrost-http/handlers/providers.go
Added OAuth2 handler with routes (callback, status, revoke); extended MCP handler with OAuth client creation flow and pending OAuth state; wired OAuth provider into server callbacks; renamed InstantiatePlugins to LoadPlugins; updated plugin type inference to include MCPPlugin; simplified ProviderHandler initialization.
UI: OAuth & MCP Components
ui/app/workspace/mcp-registry/views/mcpClientForm.tsx, ui/app/workspace/mcp-registry/views/mcpClientSheet.tsx, ui/app/workspace/mcp-registry/views/oauth2Authorizer.tsx, ui/components/ui/mcpServerSelector.tsx, ui/components/ui/mcpToolSelector.tsx
New OAuth2 authorization UI, MCP client form with auth type selection and OAuth flow initiation, client edit sheet with tool/pricing management, OAuth authorizer popup with polling, and multi-select components for server/tool selection.
UI: Configuration & Pages
ui/app/workspace/mcp-gateway/views/mcpClientForm.tsx, ui/app/workspace/config/views/mcpView.tsx, ui/app/workspace/mcp-auth-config/page.tsx, ui/app/workspace/mcp-tool-groups/page.tsx, ui/components/sidebar.tsx
Updated MCP client form to support OAuth auth type; added tool sync interval config UI; new MCP auth config and tool groups pages; extended sidebar with MCP catalog, tool groups, and auth config navigation items.
API & Schema Definitions
docs/openapi/openapi.json, docs/openapi/paths/management/mcp.yaml, docs/openapi/paths/management/oauth.yaml, docs/openapi/schemas/management/mcp.yaml, docs/openapi/schemas/management/oauth.yaml
Extended OpenAPI spec with OAuth endpoints (callback, config status, revoke), updated MCP client create/update schemas with auth_type and oauth_config fields, added discriminator-based connection type variants, new OAuth type definitions (MCPAuthType, OAuthConfigRequest, OAuthFlowInitiation, OAuthConfigStatus, OAuthToken).
Documentation
docs/mcp/oauth.mdx, docs/mcp/overview.mdx, docs/mcp/connecting-to-servers.mdx, docs/architecture/core/mcp.mdx, docs/mcp/code-mode.mdx
New OAuth guide covering flow, PKCE, dynamic registration, and token management; updated architecture docs from TypeScript to Starlark; updated connecting-to-servers with auth method options; updated overview with OAuth capability; updated code-mode docs with Python/Starlark semantics.
Build & Dependencies
core/go.mod, docs/openapi/bundle.py, ui/next.config.ts
Removed TypeScript/Goja dependencies (github.com/clarkmcc/go-typescript, github.com/dop251/goja); added go.starlark.net; added --inline CLI flag to OpenAPI bundler; added API rewrites proxy for dev.
Test Updates
core/internal/mcptests/*.go, plugins/semanticcache/plugin_vectorstore_test.go
Updated test files to pass pointers to MCPClientConfig in AddClient/EditClient calls; integrated Starlark code mode in test fixtures; renamed PreHook/PostHook to PreLLMHook/PostLLMHook.
Cleanup
core/mcp/codemodeexecutecode.go, core/mcp/codemodereadfile.go
Removed entire TypeScript code mode implementation including ExecutionResult, ExecutionEnvironment, code execution pipeline, Goja VM bindings, and TypeScript-to-tool binding logic.
Minor Updates
core/mcp/healthmonitor.go, core/utils.go, transports/bifrost-http/handlers/middlewares.go, ui/lib/store/apis/baseApi.ts, ui/lib/types/config.ts, ui/lib/types/schemas.ts, ui/lib/store/apis/mcpApi.ts, ui/lib/types/mcp.ts
Updated OAuth/code mode utility calls; added /api/oauth/callback whitelist; added MCPToolGroups RTK Query tag; extended config/schema types with tool sync interval and auth fields.

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
Loading
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}
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • v1.4.0 #1153: Overlapping MCP subsystem refactoring including MCPManager interface changes, CodeMode/Starlark implementation, and provider/context signature updates

Suggested reviewers

  • danpiths

🐰 Hoppy times ahead with OAuth and Python code!
OAuth flows now bloom with PKCE's care,
Starlark winds replace TypeScript's air,
Tool sync tickers dance with time to spare,
Code Mode's roots run deeper, crisp and fair! 🌟

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: add OAuth 2.0 support for MCP clients with discovery and PKCE' clearly and concisely summarizes the main feature being added: OAuth 2.0 support with discovery and PKCE for MCP clients.
Description check ✅ Passed The PR description is comprehensive and well-structured. It includes a clear summary, detailed list of changes, proper type of change selection, affected areas marked, testing instructions with commands, security considerations, and a complete checklist. All major required sections are present and filled out appropriately.
Docstring Coverage ✅ Passed Docstring coverage is 93.10% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch 01-24-feat_add_oauth_support_to_mcp

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 AuthType or State are 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 using MCPConnectionState type for the State field.

The State field is typed as string, but there's an MCPConnectionState type defined at line 167 with specific constants. However, the existing constants (connected, disconnected, error) don't include pending_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.MCPClientStatePendingOAuth
framework/oauth/discovery_test.go (1)

61-68: Prefer bifrost.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 of context.Background().

Passing ctx preserves cancellation, deadlines, and trace propagation across handler calls. Based on learnings, please pass *fasthttp.RequestCtx directly where a context.Context is 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: Prefer bifrost.Ptr for optional RegistrationURL.

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/Warn calls are unguarded; if DiscoverOAuthMetadata runs before SetLogger, 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: Use bifrost.Ptr for OauthConfigID.

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]

🔧 Suggested update
-	mcpClient, err := h.store.ConfigStore.GetMCPClientByID(context.Background(), id)
+	mcpClient, err := h.store.ConfigStore.GetMCPClientByID(ctx, id)
framework/oauth/main.go (1)

431-433: Prefer bifrost.Ptr for 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)

Comment on lines 12 to 55
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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 -A10

Repository: 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.go

Repository: 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.go

Repository: 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.go

Repository: 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 -30

Repository: 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:342 passes unencrypted oauthConfigRecord to CreateOauthConfig()
  • framework/oauth/main.go:427 passes unencrypted tokenRecord to CreateOauthToken()
  • BeforeSave hooks 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.

Comment on lines 88 to 131
// 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)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's find and examine the OAuth file
find . -path "*/framework/oauth/main.go" -type f

Repository: maximhq/bifrost

Length of output: 84


🏁 Script executed:

# Also check what files exist in framework/oauth
find . -path "*/framework/oauth*" -type f | head -20

Repository: 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_secret confidential. [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_secret in 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_secret as 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.go

Repository: 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 -10

Repository: 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.

Comment on lines 324 to 425
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),
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Check for TableOauth references and encryption/decryption usage
rg "TableOauth(Config|Token)" --type go -n

Repository: 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 -n

Repository: maximhq/bifrost

Length of output: 620


🏁 Script executed:

# Broader search for any encryption/decryption in configstore
rg "Encrypt|Decrypt" framework/configstore -n --type go

Repository: maximhq/bifrost

Length of output: 1018


🏁 Script executed:

# Check the structure of the oauth tables
find framework/configstore -name "*oauth*" -type f

Repository: maximhq/bifrost

Length of output: 96


🏁 Script executed:

# View the BeforeSave hooks and surrounding code in oauth.go
cat -n framework/configstore/tables/oauth.go

Repository: 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 -n

Repository: 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 go

Repository: 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.

Comment on lines 10 to 44
// 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")
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the full sync.go file to understand the complete context
cat -n framework/oauth/sync.go

Repository: 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 -A2

Repository: maximhq/bifrost

Length of output: 13105


🏁 Script executed:

# Check the overall structure and imports in the file
head -n 50 framework/oauth/sync.go

Repository: 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 -A2

Repository: 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.

Comment on lines +108 to +137
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))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.

Comment on lines 1560 to 1562
// Initialize OAuth provider
config.OAuthProvider = oauth.NewOauthProvider(config.ConfigStore, logger)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines 142 to 158
// 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]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd ui/app/workspace/mcp-gateway/views && wc -l oauthAuthorizer.tsx

Repository: maximhq/bifrost

Length of output: 82


🏁 Script executed:

cd ui/app/workspace/mcp-gateway/views && cat -n oauthAuthorizer.tsx

Repository: 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.

Suggested change
// 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.

Comment on lines +23 to +31
// Proxy API requests to backend in development
async rewrites() {
return [
{
source: "/api/:path*",
destination: "http://localhost:8080/api/:path*",
},
];
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the full Next.js config file
cat -n ui/next.config.ts

Repository: 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/headers do not apply with output: '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 -20

Repository: 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 -20

Repository: 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.

Suggested change
// 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.

@Pratham-Mishra04 Pratham-Mishra04 force-pushed the 01-24-feat_add_oauth_support_to_mcp branch from 712b0f4 to 2126c29 Compare January 23, 2026 20:29
@Pratham-Mishra04 Pratham-Mishra04 force-pushed the 01-12-feat_mcp_tests_added branch from 8bc9079 to 432b607 Compare January 23, 2026 20:33
@Pratham-Mishra04 Pratham-Mishra04 force-pushed the 01-24-feat_add_oauth_support_to_mcp branch from 2126c29 to 1e5ab07 Compare January 23, 2026 20:33
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 BeforeSave hooks or elsewhere in this file. These comments are misleading and should either be:

  1. Updated to reflect the actual state (plaintext storage), or
  2. 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.

exchangeRefreshToken should not send an empty client_secret, and RefreshAccessToken should fail fast if refresh_token is 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 from AddClient and AddClientInMemory.

These two methods share ~90% of their implementation (validation, locking, placeholder creation, connection, cleanup). The only difference is that AddClient presumably 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 the State field in MCPClientConfig.

The MCPClientConfig.State field (line 85, type string) appears to overlap with MCPClientState.State (line 184, type MCPConnectionState).

Based on the PR objectives mentioning "OAuth state parameter to mitigate CSRF," this State field likely stores the OAuth state parameter for the authorization flow, not connection state. Consider renaming to OAuthState or adding a comment to clarify its purpose and distinguish it from connection state.

framework/oauth/main.go (1)

135-142: Use bifrost.Ptr(...) for pointer fields.

This keeps pointer creation consistent with repo conventions.

♻️ Suggested tweak
- token.LastRefreshedAt = &now
+ token.LastRefreshedAt = bifrost.Ptr(now)

- oauthConfig.TokenID = &tokenID
+ oauthConfig.TokenID = bifrost.Ptr(tokenID)
Based on learnings, prefer bifrost.Ptr for pointer creation.

Also applies to: 497-499

Comment on lines +53 to +134
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))

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's see the full discovery.go file to understand the logger setup
fd discovery.go --type f

Repository: 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 1

Repository: 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 2

Repository: 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 -20

Repository: maximhq/bifrost

Length of output: 988


🏁 Script executed:

# Search for SetLogger implementation across the codebase
rg "func\s+SetLogger" --type go -B 2 -A 5

Repository: maximhq/bifrost

Length of output: 2174


🏁 Script executed:

# Read the full discovery.go file to understand logger usage
cat -n framework/oauth/discovery.go

Repository: maximhq/bifrost

Length of output: 20168


🏁 Script executed:

# Check the init.go file for logger setup
cat -n framework/oauth/init.go

Repository: maximhq/bifrost

Length of output: 262


🏁 Script executed:

# Check bifrost core package to understand NewDefaultLogger
rg "NewDefaultLogger|type Logger" core/schemas/ -B 2 -A 5

Repository: 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 -50

Repository: maximhq/bifrost

Length of output: 3508


🏁 Script executed:

# Check if NewDefaultLogger exists
rg "NewDefaultLogger" --type go -B 2 -A 5

Repository: maximhq/bifrost

Length of output: 36882


🏁 Script executed:

# Check the core package structure
find core -name "*.go" -type f | head -20

Repository: maximhq/bifrost

Length of output: 566


🏁 Script executed:

# Search for default logger implementations
rg "func.*Logger|type.*Logger" core/ --type go | grep -i default

Repository: 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 -50

Repository: 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 -l

Repository: 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 -50

Repository: 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.

Comment on lines 509 to 523
// 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()
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, verify the file exists and check its location
fd -t f "main.go" | grep oauth

Repository: 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"
fi

Repository: 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 15

Repository: maximhq/bifrost

Length of output: 1325


🏁 Script executed:

# Search for all usages of buildAuthorizeURLWithPKCE in the codebase
rg -n "buildAuthorizeURLWithPKCE" --type go

Repository: maximhq/bifrost

Length of output: 414


🏁 Script executed:

# Check git status and context for PR stack
git log --oneline -10

Repository: 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 -n

Repository: 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 2

Repository: 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 3

Repository: 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 -80

Repository: maximhq/bifrost

Length of output: 41


🏁 Script executed:

# Look for tests related to buildAuthorizeURLWithPKCE
rg -n "buildAuthorizeURLWithPKCE\|TestBuildAuthorizeURL" --type go

Repository: 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 -20

Repository: 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.go

Repository: 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 -100

Repository: 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 -l

Repository: 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 5

Repository: 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 -20

Repository: 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 2

Repository: 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 -n

Repository: 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 -50

Repository: maximhq/bifrost

Length of output: 41


🏁 Script executed:

# Check git status to see all changes in this PR
git status

Repository: 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 -20

Repository: 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 -150

Repository: maximhq/bifrost

Length of output: 245


🏁 Script executed:

# Look for other URL building patterns in the codebase
rg -n "\"?\"\s*\+\s*\"?" framework/oauth/ --type go

Repository: 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 2

Repository: 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 2

Repository: 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)
EOF

Repository: 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.

Suggested 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()
}
// 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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, and RefreshToken are 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: Make Stop() idempotent to avoid panic on double-close.

Closing stopCh twice will panic; guard with sync.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_secret and doesn’t guard empty refresh_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 &tokenID to match repo conventions (Line 498).

♻️ Suggested change
- oauthConfig.TokenID = &tokenID
+ oauthConfig.TokenID = bifrost.Ptr(tokenID)

Based on learnings, prefer bifrost.Ptr for pointer creation in this codebase.

Comment on lines 2986 to 3026
// 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +53 to +134
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))

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines 509 to 523
// 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()
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 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 3

Repository: 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 -100

Repository: 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.go

Repository: 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 -30

Repository: 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.go

Repository: maximhq/bifrost

Length of output: 41


🏁 Script executed:

# Get exact call site context
sed -n '460,495p' framework/oauth/main.go

Repository: 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.go

Repository: 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.

Suggested 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()
}
// 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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: Make Stop() idempotent to prevent panic on multiple calls.

Closing a channel twice causes a panic. Use sync.Once to 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_token to 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, but exchangeCodeForTokens and exchangeCodeForTokensWithPKCE correctly guard it with if 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.

SetRefreshInterval and SetLookAheadWindow are documented "for testing" but won't update an already-running ticker. This is acceptable for test setup before Start(), 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.NewReader accepts a string, but you can use bytes.NewReader directly with reqBody to 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, nil when a record is not found, whereas other methods in this file (e.g., GetMCPClientByID, GetVirtualKey, GetBudget) return nil, ErrNotFound. While the calling code in oauth/main.go handles 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, and GetOauthConfigByTokenID.


2741-2748: Missing parseGormError usage 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.

GetExpiringOauthTokens retrieves 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.status is 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 OauthProvider is 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 ...
 	}
 }

Comment on lines +53 to +136
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
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Comment on lines +443 to +447
if time.Now().After(oauthConfig.ExpiresAt) {
oauthConfig.Status = "expired"
p.configStore.UpdateOauthConfig(ctx, oauthConfig)
return fmt.Errorf("oauth flow expired")
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

@Pratham-Mishra04 Pratham-Mishra04 force-pushed the 01-12-feat_mcp_tests_added branch from 432b607 to 34d5d04 Compare January 23, 2026 21:55
@Pratham-Mishra04 Pratham-Mishra04 force-pushed the 01-24-feat_add_oauth_support_to_mcp branch from 1e5ab07 to 8256d98 Compare January 23, 2026 21:55
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 triggerMigrations doesn'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 nil
framework/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 DiscoverOAuthMetadata is invoked before SetLogger() 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) and RegisterDynamicClient (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 stopCh more than once will panic; guard the close with sync.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:

  1. RefreshAccessToken does not guard against an empty refresh_token before attempting refresh
  2. exchangeRefreshToken (line 562) unconditionally sends client_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. The BeforeSave hooks in TableOauthConfig and TableOauthToken do not actually encrypt these fields despite comments claiming they are encrypted.


443-447: Error from UpdateOauthConfig is 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: exchangeRefreshToken unconditionally sends client_secret.

Unlike exchangeCodeForTokens and exchangeCodeForTokensWithPKCE, this function always includes client_secret even 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 AddClient closely; a small internal helper would reduce drift over time.

framework/configstore/rdb.go (1)

2701-2809: Consider using bifrost.Ptr(...) for OAuth getters to match repo pointer style.

The new getters return &config / &token; switching to bifrost.Ptr(...) keeps pointer creation consistent with the established convention. Based on learnings, consider applying this in GetOauthConfigByID, GetOauthConfigByState, GetOauthTokenByID, and GetOauthConfigByTokenID.

framework/oauth/main.go (4)

38-53: Background goroutine lacks shutdown mechanism.

The cleanupExpiredPendingClients goroutine runs indefinitely with no way to stop it when the OauthProvider is 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 cleanupExpiredPendingClients to listen on p.done.


152-170: Consider returning errors for better diagnostics.

ValidateToken returns (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.

callTokenEndpoint creates 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 using http.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.

Comment on lines +34 to +37
// 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Check OauthConfigID/oauth_config_id usage patterns
rg -n "OauthConfigID|oauth_config_id" --type=go -C3 | head -100

Repository: 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 -50

Repository: 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 -60

Repository: 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.

Comment on lines +92 to +95
token, err = p.configStore.GetOauthTokenByID(ctx, *oauthConfig.TokenID)
if err != nil || token == nil {
return "", fmt.Errorf("failed to reload token after refresh: %w", err)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

Comment on lines +465 to +473
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)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: Include AuthType and OauthConfigID in the update operations.

AuthType and OauthConfigID are persisted during CreateMCPClientConfig but are missing from the UpdateMCPClientConfig updates 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, CodeVerifier in TableOauthConfig; AccessToken, RefreshToken in TableOauthToken) 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. Use sync.Once to guard the close operation.

framework/oauth/main.go (3)

102-145: Avoid refresh with empty refresh token and omit empty client_secret.
exchangeRefreshToken always sends client_secret, and RefreshAccessToken does not guard against missing refresh_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 between AddClient and AddClientInMemory.

AddClientInMemory duplicates the entire body of AddClient (validation, placeholder entry creation, unlock-connect-cleanup pattern). The only difference is that AddClient would 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: Prefer bifrost.Ptr for 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 &value in this repo.

Comment on lines +186 to +198
// 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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 -20

Repository: 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 -n

Repository: 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.go

Repository: 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 -5

Repository: 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.

Comment on lines +126 to +134
// 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
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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:

  1. Adding a mutex to protect these fields
  2. 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.

Suggested change
// 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.

@Pratham-Mishra04 Pratham-Mishra04 force-pushed the 01-24-feat_add_oauth_support_to_mcp branch from 8256d98 to ddbaed6 Compare January 24, 2026 09:53
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 NewOAuthHandler doesn't guard against a nil OAuthProvider. Multiple handler methods (RevokeToken, StorePendingMCPClient, GetPendingMCPClient, RemovePendingMCPClient) directly dereference h.oauthProvider without nil checks, which will cause a panic if OAuth is not configured and these endpoints are called.

Consider either:

  1. Adding nil checks in handler methods before using oauthProvider
  2. Conditionally registering OAuth routes only if oauthProvider is not nil
  3. 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 by auth_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 when auth_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:CASCADE can wipe MCP clients if a shared OAuth config is deleted. Prefer SET NULL (or RESTRICT) 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 relationship
GORM 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_url and token_url nullable, but triggerMigrations does 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 BeforeSave hook only sets defaults. ClientSecret, CodeVerifier, AccessToken, and RefreshToken will 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 requires GetMCPClientByID and 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
done

Also applies to: 186-198

transports/bifrost-http/lib/config.go (1)

1559-1564: Guard OAuth provider initialization when ConfigStore is disabled.
oauth2.NewOAuth2Provider and the refresh worker are started even when config.ConfigStore is 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 any oauth_success message, which allows any window/frame to spoof completion. Validate event.origin and event.source against 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: Make Stop() idempotent to prevent panics on multiple calls.
Closing a channel twice panics; guard with sync.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.
SetRefreshInterval and SetLookAheadWindow mutate fields read by the goroutine; either guard with a mutex or document that they must be called before Start().

✍️ 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.
error and error_description are 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: Guard ConnectionString before calling GetValue().

If connection_string is omitted, req.ConnectionString is nil and calling GetValue() 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>) when err is 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, and token_url are 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 duplicate MCPAuthType definitions across schema files.

MCPAuthType is also defined in docs/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 in HttpHeaders.
If GetAccessToken returns ErrOAuth2TokenExpired, 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: Prefer bifrost.Ptr over & for pointer creation.
Keeps pointer construction consistent with repo conventions. Based on learnings, use bifrost.Ptr here.

♻️ 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's Host header. In deployments behind a reverse proxy, ensure the proxy sets X-Forwarded-Host or that Bifrost is configured to trust only specific hosts. Otherwise, an attacker-controlled Host header could redirect the OAuth callback to an unintended destination.

docs/openapi/openapi.json (1)

166707-166739: Deduplicate OAuth config schema via $ref to prevent drift.

Line 166707-166739 defines an inline oauth_config object while Line 167225-167257 introduces OAuthConfigRequest. 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: Prefer bifrost.Ptr for pointer construction.

♻️ Suggested tweak
-	oauthConfig.TokenID = &tokenID
+	oauthConfig.TokenID = bifrost.Ptr(tokenID)
Based on learnings, prefer `bifrost.Ptr(...)` for pointer creation.

Comment on lines +37 to +46
// 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"`
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find all usages of OAuth2Token
rg -n --type=go 'OAuth2Token' -A 3 -B 1

Repository: 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 2

Repository: 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 2

Repository: 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 3

Repository: 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.go

Repository: maximhq/bifrost

Length of output: 777


🏁 Script executed:

# Check completeMCPClientOAuth implementation
rg -n --type=go -A 30 'func.*completeMCPClientOAuth' transports/bifrost-http/handlers/mcp.go

Repository: 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 2

Repository: 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 -45

Repository: 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 1

Repository: 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.go

Repository: 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.

Comment on lines +435 to +454
# 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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +127847 to +127853
"discriminator": {
"propertyName": "connection_type",
"mapping": {
"http": "#/MCPClientCreateRequestHTTP",
"sse": "#/MCPClientCreateRequestSSE",
"stdio": "#/MCPClientCreateRequestSTDIO"
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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:

  1. a schema component name, or
  2. 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: Dog

Because 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.json

Repository: maximhq/bifrost

Length of output: 376


🏁 Script executed:

# Check the other location mentioned
sed -n '167037,167043p' docs/openapi/openapi.json

Repository: maximhq/bifrost

Length of output: 320


🏁 Script executed:

# Search for the referenced schema components
rg 'MCPClientCreateRequestHTTP|MCPClientCreateRequestSSE|MCPClientCreateRequestSTDIO' docs/openapi/openapi.json | head -20

Repository: maximhq/bifrost

Length of output: 392


🏁 Script executed:

# Check what components exist in the file
rg '"MCPClientCreateRequest' docs/openapi/openapi.json | head -30

Repository: 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 -40

Repository: 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 -5

Repository: 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 -20

Repository: 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 -50

Repository: maximhq/bifrost

Length of output: 557


🏁 Script executed:

# Check the JSON structure: components section
rg -i '"components"' docs/openapi/openapi.json | head -5

Repository: 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}")
EOF

Repository: 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:

  1. Invalid URI reference format: The mappings use #/MCPClientCreateRequestHTTP (missing /components/schemas path segment), which is not a valid OpenAPI 3.1 reference.

  2. Referenced schemas do not exist: The schemas MCPClientCreateRequestHTTP, MCPClientCreateRequestSSE, and MCPClientCreateRequestSTDIO do not exist in components/schemas. Only MCPClientCreateRequest is 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.

Suggested change
"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.

Comment on lines +129198 to +129283
"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"
}
}
}
}
}
}
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's check the file exists and get its size
wc -l docs/openapi/openapi.json

Repository: 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 -100

Repository: maximhq/bifrost

Length of output: 3515


🏁 Script executed:

# Search for "NotFound" component in the responses section
rg -A 5 '"NotFound"' docs/openapi/openapi.json

Repository: 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.json

Repository: 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 -50

Repository: 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 -30

Repository: 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 -50

Repository: 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 -l

Repository: 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.json

Repository: 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.json

Repository: 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 -50

Repository: 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 -40

Repository: 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}")
EOF

Repository: 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: schemacontentapplication/jsonschema. 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.

Comment on lines +167229 to +167232
"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"
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +18 to +35
- 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +74 to +81
'404':
description: OAuth config not found
content:
application/json:
schema:
$ref: '../../openapi.yaml#/components/responses/NotFound'
'500':
$ref: '../../openapi.yaml#/components/responses/InternalError'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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').

Comment on lines +5 to +8
var logger schemas.Logger

func SetLogger(l schemas.Logger) {
logger = l
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 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 2

Repository: 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.go

Repository: 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 2

Repository: 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.

Comment on lines +61 to +63
if err := h.oauthProvider.CompleteOAuthFlow(context.Background(), state, code); err != nil {
SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("OAuth flow completion failed: %v", err))
return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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).

@akshaydeo akshaydeo mentioned this pull request Jan 26, 2026
7 tasks
@akshaydeo akshaydeo force-pushed the 01-24-feat_add_oauth_support_to_mcp branch from ddbaed6 to 30d19ca Compare January 26, 2026 10:19
@akshaydeo akshaydeo force-pushed the 01-12-feat_mcp_tests_added branch from 34d5d04 to ddb54ae Compare January 26, 2026 10:19
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: Default auth_type when omitted to avoid empty DB values.

clientConfigCopy.AuthType can 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 the is_code_mode_client description and the is_ping_available property.

🛠️ 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 --inline clobbering the input file when --output matches --input.

The inline mode writes YAML to input_path first, then writes JSON to output_path. If a user specifies --output with 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 on authorize_url / token_url.

triggerMigrations doesn’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, and RefreshToken are 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. Prefer json:"-" 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 200
framework/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. If SetLogger isn’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/...; #/MCPClientCreateRequestHTTP won’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 nests content inside schema.
OpenAPI responses should not place content under a schema object; reference the shared NotFound response 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_url is 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: Prefer bifrost.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.

Comment on lines +189 to 198
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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 MockConfigStore is missing the GetMCPClientByID method required by the ConfigStore interface (defined at framework/configstore/store.go:42), while RDBConfigStore implements it (framework/configstore/rdb.go:814). This creates an interface mismatch. Add the missing method to transports/bifrost-http/lib/config_test.go with 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 in CreateMCPClientConfig but excluded from the UpdateMCPClientConfig update 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 --inline clobbering the input file when --output matches --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:CASCADE can delete MCP client rows if OAuth configs are cleaned up/rotated independently. Prefer SET NULL (or RESTRICT) 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 #/MCPClientCreateRequestHTTP are 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 nests content inside schema.
This matches the earlier review: the schema object should not contain a nested content. 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_url referenced 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 200
framework/configstore/migrations.go (1)

2995-3036: Make authorize_url/token_url nullable explicitly (don’t rely on AutoMigrate).

triggerMigrations doesn’t run AutoMigrate, 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: Prefer MCPConnectionState for State typing.

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 deduplicating AddClient and AddClientInMemory.

The implementations are identical; a shared helper would reduce drift risk.

Comment on lines +221 to +233
'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':
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
'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.

@akshaydeo akshaydeo force-pushed the 01-12-feat_mcp_tests_added branch from ddb54ae to d848e91 Compare January 26, 2026 11:05
@akshaydeo akshaydeo force-pushed the 01-24-feat_add_oauth_support_to_mcp branch from 30d19ca to 0fcfd40 Compare January 26, 2026 11:05
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: Default AuthType when unset to avoid empty-string persistence.
If callers omit AuthType, 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: Persist auth_type and oauth_config_id on updates.
UpdateMCPClientConfig never 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.

triggerMigrations doesn’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.go
core/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:

  • #/MCPClientCreateRequestHTTP should be #/components/schemas/MCPClientCreateRequestHTTP
  • The schemas MCPClientCreateRequestHTTP, MCPClientCreateRequestSSE, and MCPClientCreateRequestSTDIO don't exist in components/schemas

This 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 oneOf schemas is problematic. Consider extracting the variants into named schemas.

🔧 Option 2: Add the missing schemas to components/schemas

Define MCPClientCreateRequestHTTP, MCPClientCreateRequestSSE, and MCPClientCreateRequestSTDIO in components/schemas and 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: schemacontentapplication/jsonschema. In OpenAPI 3.x, a schema object cannot contain a content key — this is malformed JSON that will fail validation.

Use the NotFound response 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: Clarify server_url reference in OAuthConfigRequest.

The client_id description states "the server_url must be set for OAuth discovery and dynamic registration" but server_url is not defined in this schema. Either:

  1. Add a server_url property to OAuthConfigRequest, or
  2. Update the description to reference the actual field used (e.g., connection_string from the parent MCP client request)
framework/oauth2/sync.go (2)

38-44: Make Stop() idempotent to prevent panic on multiple calls.

Closing a channel twice will panic. Use sync.Once to 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.

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.

🔧 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 empty client_secret.

Per RFC 6749, exchangeRefreshToken must not send client_secret when empty—public clients cannot maintain secret confidentiality and some OAuth servers reject empty secrets. The exchangeCodeForTokens and exchangeCodeForTokensWithPKCE methods 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 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")
 		}

443-447: Error from UpdateOauthConfig is 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 from UpdateOauthConfig on 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: Ignored json.Marshal error.

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.

buildAuthorizeURLWithPKCE appends ? via simple string concatenation, which breaks URLs that already contain query parameters. Use url.Parse to 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.RefreshToken is 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 PreLLMHook and PostLLMHook, 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/PostLLMHook for 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 revokeOAuthConfig DELETE 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).

Comment on lines +1 to +132
# 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Search for TableOauthConfig definition and status enum
rg -n "TableOauthConfig|type.*OAuthConfig" -g '*.go' -A 5 | head -100

Repository: 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 -100

Repository: 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 -50

Repository: maximhq/bifrost

Length of output: 4940


🏁 Script executed:

# Find OAuth-related handlers and endpoints
rg -n "oauth|OAuth" -g '*.go' -l | head -20

Repository: 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.go

Repository: 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 go

Repository: 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 revoked

Also 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.

Suggested change
# 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.

Comment on lines 244 to 259
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()
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

@akshaydeo akshaydeo force-pushed the 01-12-feat_mcp_tests_added branch from d848e91 to dd76b9e Compare January 26, 2026 11:33
@akshaydeo akshaydeo force-pushed the 01-24-feat_add_oauth_support_to_mcp branch from 0fcfd40 to ec62573 Compare January 26, 2026 11:34
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_available is 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.

MCPClientUpdateRequest includes auth_type and oauth_config_id, but per previous review comments, framework/configstore/rdb.go UpdateMCPClientConfig does not update those columns. This makes updates silently no-op. Either add them to the update map in the backend or mark them readOnly/remove from the update schema.

docs/openapi/paths/management/mcp.yaml (1)

227-232: Invalid response objects: $ref can't be combined with description.

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_token and refresh_token JSON tags pose a latent security risk if OAuth2Token is returned in API responses. Consider using json:"-" for sensitive fields or creating a redacted DTO for external responses, consistent with patterns like GetProviderConfigRedacted elsewhere 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 logger is 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.
mapping values 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).
schema must not contain a content object. 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_url is referenced but not defined.
The description mentions a server_url property 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 == nil path 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_token is empty, and client_secret should 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 authorizeURL already 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 triggerMigrations doesn’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, and CodeVerifier are 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 optional client_secret for public clients.

The HTTP OAuth example includes client_secret while 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_secret is optional when using PKCE for public clients.

core/schemas/mcp.go (1)

83-86: Consider type consistency for State field.

The State field is declared as string, but MCPConnectionState is a dedicated type defined later (lines 168-174). Meanwhile, MCPClientState.State (line 185) uses MCPConnectionState. 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.

AddClientInMemory is nearly identical to AddClient. The only difference is that AddClient would 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: Prefer bytes.NewReader over strings.NewReader(string(...)).

The current code performs an unnecessary byte-to-string conversion. Using bytes.NewReader(reqBody) directly is more efficient.

♻️ Suggested improvement

Add bytes to 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:

  • GetMCPClientByID returns ErrNotFound (line 818)
  • GetVirtualKey returns ErrNotFound (line 1294)
  • GetTeam returns ErrNotFound (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: Missing parseGormError and transaction parameter support.

The OAuth Create/Update methods don't use parseGormError for user-friendly constraint violation messages, and don't accept optional tx ...*gorm.DB parameters for transaction support. This differs from other methods like CreateMCPClientConfig (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: DeleteOauthToken silently 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 like DeleteVirtualKeyProviderConfig which returns ErrNotFound.

If idempotent deletes are desired, this is fine. Otherwise, consider adding a check.

Comment on lines +340 to +346

// Convert to allowed charset
b := make([]byte, length)
for i := range b {
b[i] = charset[int(randomBytes[i])%len(charset)]
}
verifier = string(b)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(...) with logDebug/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.go

Also 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 or json:"-".

🔒 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 2
framework/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_secret

Also 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: Handle json.Marshal error 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 $ref responses (OpenAPI violation).
This is the same issue flagged earlier — $ref objects can’t have sibling fields like description.

♻️ 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: code shouldn’t be required when OAuth returns an error.

Providers often redirect with error/error_description and no code, so a required code can fail validation before your handler runs. Consider making it optional or modeling success/error variants.


74-79: Use response-level $ref for the 404 response.

../../openapi.yaml#/components/responses/NotFound is 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 nests content inside schema.

Line 129213 embeds a content block inside a schema object, which is invalid in OpenAPI 3.1. Reuse the NotFound response 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_url is referenced but not defined in this schema.

Line 167251 mentions server_url, but OAuthConfigRequest doesn’t define it. Either add the field or update the description to reference the actual property used for discovery (likely the MCP client connection_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 using MCPConnectionState for State to avoid invalid values.
Right now State is 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 sets Authorization but drops Headers. 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 empty auth_type to headers to avoid blank API values.
If AuthType is 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 to AddClient to 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 $ref to MCPAuthType to avoid enum duplication.

Now that MCPAuthType exists, 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"
+              },

Comment on lines +166698 to +166704
"type": "string",
"enum": [
"http",
"stdio",
"sse",
"inprocess"
],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 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.json

Repository: 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.json

Repository: 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 -100

Repository: 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.json

Repository: 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 -20

Repository: maximhq/bifrost

Length of output: 134


🏁 Script executed:

# Search the codebase for inprocess handler/connection support
rg -n -i "inprocess" --type=go | head -30

Repository: 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.json

Repository: maximhq/bifrost

Length of output: 843


🏁 Script executed:

# Check the second discriminator as well
sed -n '167055,167070p' docs/openapi/openapi.json

Repository: 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.json

Repository: 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())}")
EOF

Repository: 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.json

Repository: 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'))
EOF

Repository: 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.

@akshaydeo akshaydeo force-pushed the 01-24-feat_add_oauth_support_to_mcp branch from ec62573 to 4df30b5 Compare January 26, 2026 12:24
@akshaydeo akshaydeo force-pushed the 01-12-feat_mcp_tests_added branch from dd76b9e to 1579539 Compare January 26, 2026 12:24
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 in MCPClientCreateRequestBase.

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/revoked statuses 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: Make code optional to allow OAuth error callbacks.

Providers often redirect with error/error_description and no code. Marking code as 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 $ref for the 404 response.

The current $ref points to a response object but is nested under schema. 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 persists auth_type and oauth_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 3

As 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.

logger can 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: schemacontentapplication/jsonschema. This was previously flagged. The fix should reference the NotFound response component: "$ref": "#/components/responses/NotFound".


166698-166704: inprocess should be removed from component schema enum.

Same issue as in the inline request body schema - inprocess is 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 MCPClientCreateRequest component schema has the same invalid references as the inline schema.


167249-167252: server_url reference in description still incorrect.

The client_id description references server_url which 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:CASCADE on OauthConfigID can wipe MCP client rows when an OAuth config is removed. Prefer SET NULL (or RESTRICT) 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 relationship
framework/configstore/tables/oauth.go (1)

12-57: Encrypt OAuth secrets before persistence.

ClientSecret, CodeVerifier, AccessToken, and RefreshToken are stored in plaintext. Please encrypt before saving and decrypt on read (or integrate into save hooks).

framework/oauth2/sync.go (2)

38-43: Make Stop() 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 MockConfigStore implements 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}/status endpoint 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 marking access_token and refresh_token as sensitive.

The OAuthToken schema exposes access_token and refresh_token fields. 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.

AddClient and AddClientInMemory are currently identical; extracting a shared helper will reduce duplication and future divergence.

Comment on lines +127494 to +127499
"enum": [
"http",
"stdio",
"sse",
"inprocess"
],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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).

@akshaydeo akshaydeo force-pushed the 01-12-feat_mcp_tests_added branch from 1579539 to 6c7a84b Compare January 26, 2026 13:01
@akshaydeo akshaydeo force-pushed the 01-24-feat_add_oauth_support_to_mcp branch from 4df30b5 to 1800642 Compare January 26, 2026 13:01
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_available should 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_type and oauth_config_id, meaning API requests to update these fields will be silently ignored. This inconsistency with the OpenAPI schema (which exposes these fields) and CreateMCPClientConfig (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.

cleanupExpiredPendingClients runs forever; if the provider is discarded, the goroutine leaks. Add a stop channel and Close() to shut it down.


117-131: Handle public-client refresh properly and guard missing refresh tokens.

exchangeRefreshToken always sends client_secret, and refresh attempts proceed even if refresh_token is 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>) when err == nil and token == 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: $ref responses can’t include sibling description.

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 on authorize_url / token_url in migration.

The migration relies on AutoMigrate to relax NOT NULL, but triggerMigrations doesn’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.

MCPClientUpdateRequest includes auth_type and oauth_config_id, but UpdateMCPClientConfig in framework/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 expired and revoked statuses (in framework/oauth2/main.go and framework/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.

TableOauthToken uses json:"-" tags on AccessToken and RefreshToken, 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: Remove inprocess from connection_type enum in HTTP API schemas.

The inprocess connection 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 #/MCPClientCreateRequestHTTP which is not a valid OpenAPI 3.1 JSON Pointer (missing /components/schemas path segment). Additionally, the referenced schemas (MCPClientCreateRequestHTTP, MCPClientCreateRequestSSE, MCPClientCreateRequestSTDIO) don't exist in components/schemas.

Either:

  1. Fix the mapping values to valid JSON Pointers and add the missing schema definitions, OR
  2. Remove the discriminator if tooling doesn't require it (the oneOf with constrained connection_type enums 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: schemacontentapplication/jsonschema. In OpenAPI 3.x, a schema object cannot contain a content key—that key belongs at the response level, not inside the schema.

Use a $ref to the NotFound response 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: Remove inprocess from connection_type enum.

Same issue as in the request body schema—inprocess is 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: Clarify server_url reference in OAuthConfigRequest docs.

The client_id description states "If not provided, the server_url must be set for OAuth discovery and dynamic registration" but server_url is not defined in this schema. Either add a server_url property or update the description to reference the actual property (e.g., connection_string from the parent MCP client request).

🧹 Nitpick comments (3)
framework/oauth2/main.go (1)

498-499: Prefer bifrost.Ptr(...) for pointer creation.

Replace &tokenID with bifrost.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.

MCPAuthType is defined both here and in docs/openapi/schemas/management/mcp.yaml. This duplication risks the definitions drifting apart. Based on learnings, consider defining it in one file and using $ref to reference it from the other.

♻️ Suggested approach

Keep MCPAuthType in oauth.yaml (since it's OAuth-related) and reference it from mcp.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())

Comment on lines +465 to +469
## 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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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 -20

Repository: 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 -40

Repository: 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 3

Repository: 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 -20

Repository: maximhq/bifrost

Length of output: 89


🏁 Script executed:

# Check for any PR/stack context in the repository
git log --oneline --all -20

Repository: 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 -20

Repository: 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 -20

Repository: 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 -30

Repository: maximhq/bifrost

Length of output: 118


🏁 Script executed:

# Look for token storage implementations
rg "store.*token\|token.*storage\|token.*database" -A 3 | head -50

Repository: maximhq/bifrost

Length of output: 41


🏁 Script executed:

# Search for encryption implementations
rg "encrypt\|crypto" -l | grep -v node_modules | head -20

Repository: maximhq/bifrost

Length of output: 41


🏁 Script executed:

# Check the recent OAuth commit for what was added
git show --stat 18006422

Repository: maximhq/bifrost

Length of output: 50372


🏁 Script executed:

# Check the OAuth schema
cat -n core/schemas/oauth.go

Repository: maximhq/bifrost

Length of output: 4046


🏁 Script executed:

# Check the configstore OAuth table
cat -n framework/configstore/tables/oauth.go

Repository: maximhq/bifrost

Length of output: 4616


🏁 Script executed:

# Check the OAuth2 handler
head -100 transports/bifrost-http/handlers/oauth2.go

Repository: 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 -30

Repository: 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 -20

Repository: maximhq/bifrost

Length of output: 800


🏁 Script executed:

# Look for token encryption/decryption implementations
rg "AccessToken|RefreshToken" --type go -A 5 -B 2 | head -80

Repository: 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 -20

Repository: maximhq/bifrost

Length of output: 367


🏁 Script executed:

# Search for where tokens are stored/retrieved
rg "SaveOAuthToken|GetAccessToken|StoreToken" --type go -A 10 | head -100

Repository: maximhq/bifrost

Length of output: 2161


🏁 Script executed:

# Check the encryption module
cat -n framework/encrypt/encrypt.go

Repository: maximhq/bifrost

Length of output: 5707


🏁 Script executed:

# Check configstore methods for storing tokens
rg "SaveOAuthToken|CreateOAuthToken|UpdateOAuthToken" --type go -A 15 | head -100

Repository: 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 -80

Repository: 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 -10

Repository: 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 20

Repository: 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).

Comment on lines +129379 to +129417
"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"
}
}
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +2712 to +2723
// 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
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 -20

Repository: 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 -30

Repository: 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 -20

Repository: 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 -10

Repository: 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 -20

Repository: 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 -20

Repository: 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 -l

Repository: 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 -40

Repository: 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 -20

Repository: 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.go

Repository: 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.

@Pratham-Mishra04 Pratham-Mishra04 force-pushed the 01-24-feat_add_oauth_support_to_mcp branch from 1800642 to 4a60c86 Compare January 26, 2026 14:13
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.Name to originalMCPToolName. 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: Propagate is_ping_available into pending and final MCP configs.
OAuth flows currently drop this flag, defaulting to false even when the UI set true.

🛠️ 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 --inline clobbering the input file when --output matches --input.

Inline mode still writes YAML to input_path and then writes JSON to output_path without 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 on authorize_url / token_url for 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: $ref responses can’t include sibling description.
OpenAPI requires either a pure $ref or 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. The BeforeSave hook properly defaults Status to "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 TableOauthToken model appropriately stores OAuth tokens with expiry tracking and refresh timestamps. The BeforeSave hook correctly defaults TokenType to "Bearer".

Same encryption concern applies to AccessToken and RefreshToken fields as noted in the prior review.

docs/openapi/schemas/management/mcp.yaml (1)

189-197: Update schema exposes fields the backend may not persist.

MCPClientUpdateRequest includes auth_type and oauth_config_id, but verify that framework/configstore/rdb.go's UpdateMCPClientConfig actually 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 -20
docs/openapi/paths/management/oauth.yaml (2)

18-35: Make code optional to handle OAuth error redirects.

When OAuth authorization fails, providers redirect with error and error_description but without a code. Having code as required: true will 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 $ref usage 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 $ref at 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: 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        *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.

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, 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 if DiscoverOAuthMetadata is called before SetLogger().

🔧 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 NewOauthProvider in 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.Debug without a nil check. Since this is an exported function that may be called before SetLogger(), add a guard at the function entry.

framework/oauth2/main.go (9)

38-53: Goroutine leak: cleanupExpiredPendingClients has 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 cleanupExpiredPendingClients to select on the stop channel (see lines 244-259).


92-95: 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")
 		}

556-565: Handle refresh flows for public clients.

Per RFC 6749, exchangeRefreshToken must not send client_secret when empty—public clients cannot maintain secret confidentiality and some OAuth servers reject empty secrets. The exchangeCodeForTokens and exchangeCodeForTokensWithPKCE methods correctly guard client_secret with if clientSecret != "", but exchangeRefreshToken does 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.C and 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 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
 	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 from UpdateOauthConfig on 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: Ignored json.Marshal error.

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.

buildAuthorizeURLWithPKCE appends ? via simple string concatenation, which breaks URLs that already contain query parameters. Use url.Parse to 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.

RefreshAccessToken should check if token.RefreshToken is 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_token
docs/openapi/openapi.json (7)

127852-127858: Fix discriminator mapping references — schemas do not exist in components/schemas.

The discriminator mappings use #/MCPClientCreateRequestHTTP format 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 in components/schemas.

Either add the variant schemas to components/schemas and fix the URI format, or remove the discriminator if not needed.


127494-127499: Remove inprocess from connection_type enum.

The enum includes inprocess but there's no corresponding discriminator mapping or oneOf variant. Since inprocess connections 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: schemacontentapplication/jsonschema. In OpenAPI 3.x, a schema object cannot contain a content key—that's a response-level construct. Use the 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"
          },

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: Remove inprocess from connection_type enum.

Same issue: inprocess has no discriminator mapping and cannot be created via HTTP API.


167249-167252: Clarify server_url reference in OAuthConfigRequest docs.

The client_id description states "the server_url must be set for OAuth discovery" but server_url is not defined in this schema. Update the wording to reference the actual property (likely connection_string from 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.

🛠️ 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 {
Based on learnings, keep `fasthttp.RequestCtx` as the context carrier.

Also applies to: 100-104, 145-168, 182-183


110-137: Escape OAuth error strings before injecting into HTML/JS.
error and error_description are 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: Guard ConnectionString before calling GetValue() to avoid panics.
If connection_string is 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 discovery

Also applies to: 250-251

framework/configstore/rdb.go (1)

2712-2818: Return ErrNotFound for missing OAuth records for consistency.
Other getters in this package return ErrNotFound; 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, and GetOauthConfigByTokenID.

🧹 Nitpick comments (6)
core/schemas/mcp.go (1)

118-149: Consider allowing extra headers to accompany OAuth.
Right now AuthType=oauth ignores Headers. If you want custom headers in addition to Authorization, you can merge Headers first and then override Authorization from 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 " + accessToken
ui/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: MCPConnectionType is missing inprocess variant.

The OpenAPI schema (docs/openapi/schemas/management/mcp.yaml line 14) defines MCPConnectionType as [http, stdio, sse, inprocess], but this TypeScript type only includes "http" | "stdio" | "sse". While inprocess may 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 for AddClient / AddClientInMemory.
The bodies are effectively identical; a shared internal helper would reduce drift risk.

transports/bifrost-http/handlers/mcp.go (1)

267-268: Prefer bifrost.Ptr for pointer creation.
This keeps pointer construction consistent across the codebase.

🧩 Suggested tweak
-			OauthConfigID:      &flowInitiation.OauthConfigID,
+			OauthConfigID:      bifrost.Ptr(flowInitiation.OauthConfigID),
Based on learnings, prefer `bifrost.Ptr(...)` for pointer creation.

Comment on lines 786 to 790
// 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)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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 := sanitizedToolName

Based 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.

Comment on lines 228 to 241
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: ["*"],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.

@akshaydeo akshaydeo force-pushed the 01-12-feat_mcp_tests_added branch from 6c7a84b to fa01e38 Compare January 26, 2026 15:49
@akshaydeo akshaydeo force-pushed the 01-24-feat_add_oauth_support_to_mcp branch from 4a60c86 to b31b58f Compare January 26, 2026 15:49
@akshaydeo akshaydeo mentioned this pull request Jan 26, 2026
16 tasks
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: Default AuthType when empty to preserve expected behavior.
If AuthType is 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_token and refresh_token fields 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 with GetProviderConfigRedacted elsewhere in the codebase.

docs/openapi/paths/management/mcp.yaml (1)

227-232: Invalid response objects: $ref cannot be combined with description.

OpenAPI 3.0 disallows sibling fields next to $ref. Lines 228-229 and 231-232 combine a custom description with 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 originalMCPToolName via getOriginalToolName() 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 in toolmanager.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 ToolNameMapping is 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 the BeforeSave hooks 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:

  1. Integrate encryption calls in the creation/update paths for these sensitive fields
  2. Update the comments to accurately reflect that fields are stored in plaintext
  3. Document the security model if plaintext storage is intentional for this phase

This is especially important for AccessToken, RefreshToken, ClientSecret, and CodeVerifier which 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.go

Also applies to: 186-198

docs/openapi/paths/management/oauth.yaml (2)

18-33: Make code optional to allow OAuth error callbacks.

Providers often return error/error_description without code, so validation will fail if code is 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 $ref placement.

The $ref points to a response object and should be referenced at the response level, not under schema.

🔧 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: Make Stop() 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; subsequent logger.Debug/Warn calls (e.g., Line 74 onward) are unconditional. If SetLogger wasn’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 uses randomBytes[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.
cleanupExpiredPendingClients runs forever; if the provider is discarded, the goroutine leaks. Add a stop channel and a Close() method.


55-96: Avoid wrapping nil error when token reload returns nil.
If err == nil and token == nil, the %w wrap 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 empty refresh_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: Handle UpdateOauthConfig errors 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: Handle json.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 include client_secret when 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: Return ErrNotFound consistently for OAuth getters.
These methods return (nil, nil) on record‑not‑found, which is inconsistent with other getters and forces callers to double‑check nil separately.

docs/openapi/openapi.json (6)

127492-127499: Remove inprocess from the HTTP-facing enum.
inprocess is 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 nested content inside the 404 schema.
A schema object cannot contain content; reference the reusable NotFound response 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 and inprocess is 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 the server_url reference in OAuthConfigRequest docs.
server_url isn’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: Use bifrost.Ptr for pointer fields for consistency.
The bifrost.Ptr helper is the established pattern in the codebase for creating pointers to values. Apply it to LastRefreshedAt at line 141 and TokenID at line 498.

♻️ Proposed refactor
-	token.LastRefreshedAt = &now
+	token.LastRefreshedAt = bifrost.Ptr(now)
-	oauthConfig.TokenID = &tokenID
+	oauthConfig.TokenID = bifrost.Ptr(tokenID)

Comment on lines +388 to +454
// 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, &regResp); 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 &regResp, nil
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
// 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, &regResp); 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 &regResp, 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, &regResp); 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 &regResp, 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.

@akshaydeo akshaydeo force-pushed the 01-24-feat_add_oauth_support_to_mcp branch from b31b58f to 595b1ed Compare January 26, 2026 19:19
@akshaydeo akshaydeo force-pushed the 01-12-feat_mcp_tests_added branch from fa01e38 to 74dc25d Compare January 26, 2026 19:19
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 + getOriginalToolName restores 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 between is_ping_available vs. the is_code_mode_client description) 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 updates map correctly excludes auth_type and oauth_config_id, consistent with the application layer's EditMCPClient function. 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 to AddClient (lines 75-112). The only intended difference per the docstring is that AddClientInMemory should 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: Unused ctx parameter in createSTDIOConnection.

The ctx context.Context parameter 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: Unused ctx parameter in createInProcessConnection.

Similar to createSTDIOConnection, the ctx parameter is unused. If this is intentional for API consistency with other create*Connection methods, a brief comment would clarify the design intent.

core/schemas/oauth.go (1)

23-35: Consider protecting ClientSecret from accidental JSON serialization.

OAuth2Config.ClientSecret has a JSON tag that could expose secrets if the struct is marshaled. Since ClientSecret is sensitive, consider using json:"-" 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.Errorf but don't use s.parseGormError() which provides user-friendly messages for unique constraint violations. Consider using parseGormError for 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
 }

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 MCPClientState is always created, even when existingClient exists. This discards any modifications made to the existing client (lines 429-439) and loses existing ToolMap/ToolNameMapping data that may have been populated from prior connections. The existing client cleanup block modifies existingClient but 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 AuthType and OauthConfigID from clients (handlers/mcp.go:322), but EditMCPClient does 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 between AddClient and AddClientInMemory.

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: Unused ctx parameter in STDIO connection.

The ctx parameter 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.Context to 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 of State field vs MCPConnectionState.

The State field (string) on MCPClientConfig could be confused with MCPConnectionState (enum used in MCPClientState.State). Consider renaming to clarify its purpose (e.g., OAuthState if it's for OAuth state parameter, or ConnectionStatus if it represents a different concept).

Based on context, if this is for tracking OAuth flow state (the OAuth state parameter 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 flow
core/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

@akshaydeo akshaydeo mentioned this pull request Jan 27, 2026
16 tasks
@akshaydeo akshaydeo force-pushed the 01-24-feat_add_oauth_support_to_mcp branch from 595b1ed to e3b6ca5 Compare January 27, 2026 18:09
@akshaydeo akshaydeo force-pushed the 01-12-feat_mcp_tests_added branch from 74dc25d to 1c04269 Compare January 27, 2026 18:09
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_available field from HEAD, or
  • Keep just the description line 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 UpdateMCPClientConfig method does not include auth_type or oauth_config_id in the updates map. This means the OpenAPI schema allows updating these fields, but they will be silently ignored. This is inconsistent with CreateMCPClientConfig which 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 now PostLLMHook.

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 is PreLLMHook.

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 is PreLLMHook.

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 is PostLLMHook.

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 is PreLLMHook.

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.

AddClient and AddClientInMemory share nearly identical implementation (validation, placeholder creation, unlock-connect-cleanup pattern). The only difference is that AddClient is intended for new clients while AddClientInMemory is 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: Unused ctx parameter in connection methods.

createSTDIOConnection and createInProcessConnection accept a ctx context.Context parameter 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.Context to explicitly indicate it's unused.

Also applies to: 712-712

Comment on lines +640 to +660
// 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +128572 to +128775
"/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"
}
}
}
}
}
}
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +129084 to +129091
{
"name": "code",
"in": "query",
"required": true,
"description": "Authorization code from the OAuth provider",
"schema": {
"type": "string"
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
{
"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".

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_client description and the is_ping_available field.

🧩 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 includes client_secret, authorize_url, and token_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, omit client_secret. If using discovery, set
+use_discovery: true and server_url, and omit authorize_url/token_url
+(and registration_url if 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 reusing http.Client for connection pooling.

Creating a new http.Client on every token endpoint call is inefficient and could lead to socket exhaustion under high load. Store a shared client with appropriate timeout on the OAuth2Provider struct.

♻️ 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)
 	// ...
 }

Comment on lines +128591 to +128775
"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"
}
}
}
}
}
}
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

@akshaydeo akshaydeo mentioned this pull request Jan 27, 2026
16 tasks
@akshaydeo akshaydeo changed the base branch from 01-12-feat_mcp_tests_added to graphite-base/1430 January 28, 2026 09:35
@akshaydeo akshaydeo closed this Jan 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants