+ The authorization link has expired, been used already, or had its
+ token stripped. Restart the connection from your MCP client to get a
+ fresh link.
+
+
+
+ );
+}
diff --git a/ui/app/workspace/config/views/mcpView.tsx b/ui/app/workspace/config/views/mcpView.tsx
index 0bfbc3b3e7..9795daea7c 100644
--- a/ui/app/workspace/config/views/mcpView.tsx
+++ b/ui/app/workspace/config/views/mcpView.tsx
@@ -48,11 +48,15 @@ export default function MCPView() {
mcp_tool_execution_timeout: string;
mcp_code_mode_binding_level: string;
mcp_tool_sync_interval: string;
+ oauth2_auth_code_ttl: string;
+ oauth2_access_token_ttl: string;
}>({
mcp_agent_depth: "10",
mcp_tool_execution_timeout: "30",
mcp_code_mode_binding_level: "server",
mcp_tool_sync_interval: "10",
+ oauth2_auth_code_ttl: "600",
+ oauth2_access_token_ttl: "600",
});
useEffect(() => {
@@ -66,6 +70,10 @@ export default function MCPView() {
config?.mcp_code_mode_binding_level || "server",
mcp_tool_sync_interval:
config?.mcp_tool_sync_interval?.toString() || "10",
+ oauth2_auth_code_ttl:
+ config?.oauth2_server_config?.auth_code_ttl?.toString() || "600",
+ oauth2_access_token_ttl:
+ config?.oauth2_server_config?.access_token_ttl?.toString() || "600",
});
}
}, [config, bifrostConfig]);
@@ -76,6 +84,10 @@ export default function MCPView() {
localConfig.mcp_external_client_url,
config.mcp_external_client_url,
);
+ const issuerURLChanged = !envVarEquals(
+ localConfig.oauth2_server_config?.issuer_url,
+ config.oauth2_server_config?.issuer_url,
+ );
return (
localConfig.mcp_agent_depth !== config.mcp_agent_depth ||
localConfig.mcp_tool_execution_timeout !==
@@ -88,7 +100,14 @@ export default function MCPView() {
(config.mcp_disable_auto_tool_inject ?? false) ||
localConfig.mcp_enable_temp_token_auth !==
(config.mcp_enable_temp_token_auth ?? false) ||
- clientURLChanged
+ clientURLChanged ||
+ (localConfig.mcp_server_auth_mode ?? "headers") !==
+ (config.mcp_server_auth_mode ?? "headers") ||
+ issuerURLChanged ||
+ (localConfig.oauth2_server_config?.auth_code_ttl ?? 600) !==
+ (config.oauth2_server_config?.auth_code_ttl ?? 600) ||
+ (localConfig.oauth2_server_config?.access_token_ttl ?? 600) !==
+ (config.oauth2_server_config?.access_token_ttl ?? 600)
);
}, [config, localConfig]);
@@ -147,6 +166,41 @@ export default function MCPView() {
setLocalConfig((prev) => ({ ...prev, mcp_external_client_url: value }));
}, []);
+ const handleAuthModeChange = useCallback((value: string) => {
+ if (value === "headers" || value === "both" || value === "oauth") {
+ setLocalConfig((prev) => ({ ...prev, mcp_server_auth_mode: value }));
+ }
+ }, []);
+
+ const handleIssuerURLChange = useCallback((value: EnvVar) => {
+ setLocalConfig((prev) => ({
+ ...prev,
+ oauth2_server_config: { ...prev.oauth2_server_config, issuer_url: value },
+ }));
+ }, []);
+
+ const handleAuthCodeTTLChange = useCallback((value: string) => {
+ setLocalValues((prev) => ({ ...prev, oauth2_auth_code_ttl: value }));
+ const num = Number.parseInt(value);
+ if (!isNaN(num) && num >= 60) {
+ setLocalConfig((prev) => ({
+ ...prev,
+ oauth2_server_config: { ...prev.oauth2_server_config, auth_code_ttl: num },
+ }));
+ }
+ }, []);
+
+ const handleAccessTokenTTLChange = useCallback((value: string) => {
+ setLocalValues((prev) => ({ ...prev, oauth2_access_token_ttl: value }));
+ const num = Number.parseInt(value);
+ if (!isNaN(num) && num >= 60) {
+ setLocalConfig((prev) => ({
+ ...prev,
+ oauth2_server_config: { ...prev.oauth2_server_config, access_token_ttl: num },
+ }));
+ }
+ }, []);
+
const handleSave = useCallback(async () => {
try {
const agentDepth = Number.parseInt(localValues.mcp_agent_depth);
@@ -164,6 +218,29 @@ export default function MCPView() {
return;
}
+ // The TTL fields are only shown (and only relevant) in OAuth modes; the
+ // backend likewise validates oauth2_server_config only then. Guard the
+ // checks so a stale value can't dead-end the save after switching back to
+ // headers mode, where the fields are hidden and unfixable.
+ const oauthModeActive =
+ localConfig.mcp_server_auth_mode === "both" ||
+ localConfig.mcp_server_auth_mode === "oauth";
+
+ const authCodeTTL = Number.parseInt(localValues.oauth2_auth_code_ttl);
+ const accessTokenTTL = Number.parseInt(
+ localValues.oauth2_access_token_ttl,
+ );
+
+ if (oauthModeActive && (isNaN(authCodeTTL) || authCodeTTL < 60)) {
+ toast.error("Authorization code TTL must be at least 60 seconds.");
+ return;
+ }
+
+ if (oauthModeActive && (isNaN(accessTokenTTL) || accessTokenTTL < 60)) {
+ toast.error("Access token TTL must be at least 60 seconds.");
+ return;
+ }
+
if (!bifrostConfig) {
toast.error("Configuration not loaded. Please refresh and try again.");
return;
@@ -428,6 +505,168 @@ export default function MCPView() {
+ {/* MCP Server Auth Mode */}
+
+
+
+ Controls how inbound MCP clients (e.g. Claude Code, Cursor)
+ authenticate to the /mcp{" "}
+ endpoint.{" "}
+ headers (default) - VK / api-key / session headers
+ only, OAuth discovery disabled.{" "}
+ both - accepts header credentials and Bifrost-issued
+ JWTs; existing integrations are unaffected.{" "}
+ oauth - JWTs only; VK and header access is disabled.
+
+
+ {/* oauth: VK/header access disabled */}
+ {localConfig.mcp_server_auth_mode === "oauth" && (
+
+
+ VK / header MCP access will be disabled
+
+ All existing MCP integrations that use a virtual key,
+ api-key, or session header will stop working immediately.
+ Clients must re-authenticate via the OAuth consent flow to
+ obtain a JWT before they can connect.
+
+
+ )}
+
+ {/* headers: warn if downgrading from oauth-enabled mode */}
+ {localConfig.mcp_server_auth_mode === "headers" &&
+ (config?.mcp_server_auth_mode === "both" ||
+ config?.mcp_server_auth_mode === "oauth") && (
+
+
+ OAuth discovery will be disabled
+
+ All MCP clients that authenticated via the OAuth consent
+ flow will lose access — their JWTs will be rejected and
+ their refresh tokens will become unusable. They will need
+ to reconfigure using a virtual key or api-key header.
+
+
+ )}
+
+ {/* both: informational note about additive nature */}
+ {localConfig.mcp_server_auth_mode === "both" &&
+ (config?.mcp_server_auth_mode ?? "headers") !== "both" && (
+
+
+ Existing VK / header integrations continue to work
+ unchanged. New MCP clients can connect via OAuth - they'll
+ be redirected to the consent page to pick an identity.
+
+
+ )}
+
+
+ {/* OAuth2 AS Settings — only shown when auth mode is not headers */}
+ {(localConfig.mcp_server_auth_mode === "both" ||
+ localConfig.mcp_server_auth_mode === "oauth") && (
+
+
OAuth2 Server Settings
+
+ {/* Issuer URL */}
+
+
+
+ Stable public URL advertised in discovery documents and
+ embedded as the iss claim
+ in every JWT. Leave blank to derive it from the request{" "}
+ Host header (sufficient
+ for most deployments). Multi-host or reverse-proxy
+ deployments might need this. Supports env var syntax (e.g.{" "}
+ env.BIFROST_ISSUER_URL).
+
+
+
+
+ {/* Token TTLs */}
+
+
+
+
+ How long the one-time code is valid after the consent
+ page redirects back to the MCP client (default: 600).
+
+ Lifetime of issued JWT Bearer tokens. Clients silently
+ refresh when expired (default: 600 = 10 min). Also bounds
+ how long a revoked grant keeps working before it is cut off.
+