diff --git a/backend/app/controller/tool_controller.py b/backend/app/controller/tool_controller.py
index 4a18857fe..c8eb581c3 100644
--- a/backend/app/controller/tool_controller.py
+++ b/backend/app/controller/tool_controller.py
@@ -2,6 +2,7 @@ from fastapi import APIRouter, HTTPException
from loguru import logger
from app.utils.toolkit.notion_mcp_toolkit import NotionMCPToolkit
from app.utils.toolkit.google_calendar_toolkit import GoogleCalendarToolkit
+from app.utils.oauth_state_manager import oauth_state_manager
router = APIRouter(tags=["task"])
@@ -60,20 +61,34 @@ async def install_tool(tool: str):
)
elif tool == "google_calendar":
try:
- # Use a dummy task_id for installation, as this is just for pre-authentication
- toolkit = GoogleCalendarToolkit("install_auth")
-
- # Get available tools to verify connection
- tools = [tool_func.func.__name__ for tool_func in toolkit.get_tools()]
- logger.info(f"Successfully pre-instantiated {tool} toolkit with {len(tools)} tools")
-
- return {
- "success": True,
- "tools": tools,
- "message": f"Successfully installed {tool} toolkit",
- "count": len(tools),
- "toolkit_name": "GoogleCalendarToolkit"
- }
+ # Try to initialize toolkit - will succeed if credentials exist
+ try:
+ toolkit = GoogleCalendarToolkit("install_auth")
+ tools = [tool_func.func.__name__ for tool_func in toolkit.get_tools()]
+ logger.info(f"Successfully initialized Google Calendar toolkit with {len(tools)} tools")
+
+ return {
+ "success": True,
+ "tools": tools,
+ "message": f"Successfully installed {tool} toolkit",
+ "count": len(tools),
+ "toolkit_name": "GoogleCalendarToolkit"
+ }
+ except ValueError as auth_error:
+ # No credentials - need authorization
+ logger.info(f"No credentials found, starting authorization: {auth_error}")
+
+ # Start background authorization in a new thread
+ logger.info("Starting background Google Calendar authorization")
+ GoogleCalendarToolkit.start_background_auth("install_auth")
+
+ return {
+ "success": False,
+ "status": "authorizing",
+ "message": "Authorization required. Browser should open automatically. Complete authorization and try installing again.",
+ "toolkit_name": "GoogleCalendarToolkit",
+ "requires_auth": True
+ }
except Exception as e:
logger.error(f"Failed to install {tool} toolkit: {e}")
raise HTTPException(
@@ -113,3 +128,61 @@ async def list_available_tools():
}
]
}
+
+
+@router.get("/oauth/status/{provider}", name="get oauth status")
+async def get_oauth_status(provider: str):
+ """
+ Get the current OAuth authorization status for a provider
+
+ Args:
+ provider: OAuth provider name (e.g., 'google_calendar')
+
+ Returns:
+ Current authorization status
+ """
+ state = oauth_state_manager.get_state(provider)
+
+ if not state:
+ return {
+ "provider": provider,
+ "status": "not_started",
+ "message": "No authorization in progress"
+ }
+
+ return state.to_dict()
+
+
+@router.post("/oauth/cancel/{provider}", name="cancel oauth")
+async def cancel_oauth(provider: str):
+ """
+ Cancel an ongoing OAuth authorization flow
+
+ Args:
+ provider: OAuth provider name (e.g., 'google_calendar')
+
+ Returns:
+ Cancellation result
+ """
+ state = oauth_state_manager.get_state(provider)
+
+ if not state:
+ raise HTTPException(
+ status_code=404,
+ detail=f"No authorization found for provider '{provider}'"
+ )
+
+ if state.status not in ["pending", "authorizing"]:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Cannot cancel authorization with status '{state.status}'"
+ )
+
+ state.cancel()
+ logger.info(f"Cancelled OAuth authorization for {provider}")
+
+ return {
+ "success": True,
+ "provider": provider,
+ "message": "Authorization cancelled successfully"
+ }
diff --git a/backend/app/utils/oauth_state_manager.py b/backend/app/utils/oauth_state_manager.py
new file mode 100644
index 000000000..894ee8c47
--- /dev/null
+++ b/backend/app/utils/oauth_state_manager.py
@@ -0,0 +1,109 @@
+"""
+OAuth authorization state manager for background authorization flows
+"""
+import threading
+from typing import Dict, Optional, Literal
+from datetime import datetime
+from loguru import logger
+
+AuthStatus = Literal["pending", "authorizing", "success", "failed", "cancelled"]
+
+
+class OAuthState:
+ """Represents the state of an OAuth authorization flow"""
+
+ def __init__(self, provider: str):
+ self.provider = provider
+ self.status: AuthStatus = "pending"
+ self.error: Optional[str] = None
+ self.thread: Optional[threading.Thread] = None
+ self.result: Optional[any] = None
+ self.started_at = datetime.now()
+ self.completed_at: Optional[datetime] = None
+ self._cancel_event = threading.Event()
+ self.server = None # Store the local server instance for forced shutdown
+
+ def is_cancelled(self) -> bool:
+ """Check if cancellation has been requested"""
+ return self._cancel_event.is_set()
+
+ def cancel(self):
+ """Request cancellation of the authorization flow"""
+ self._cancel_event.set()
+ self.status = "cancelled"
+ self.completed_at = datetime.now()
+
+ def to_dict(self) -> Dict:
+ """Convert state to dictionary for API response"""
+ return {
+ "provider": self.provider,
+ "status": self.status,
+ "error": self.error,
+ "started_at": self.started_at.isoformat(),
+ "completed_at": self.completed_at.isoformat() if self.completed_at else None,
+ }
+
+
+class OAuthStateManager:
+ """Manager for tracking OAuth authorization flows"""
+
+ def __init__(self):
+ self._states: Dict[str, OAuthState] = {}
+ self._lock = threading.Lock()
+
+ def create_state(self, provider: str) -> OAuthState:
+ """Create a new OAuth state for a provider"""
+ with self._lock:
+ # Cancel any existing authorization for this provider
+ if provider in self._states:
+ old_state = self._states[provider]
+ if old_state.status in ["pending", "authorizing"]:
+ old_state.cancel()
+ logger.info(f"Cancelled previous {provider} authorization")
+
+ state = OAuthState(provider)
+ self._states[provider] = state
+ return state
+
+ def get_state(self, provider: str) -> Optional[OAuthState]:
+ """Get the current state for a provider"""
+ with self._lock:
+ return self._states.get(provider)
+
+ def update_status(
+ self,
+ provider: str,
+ status: AuthStatus,
+ error: Optional[str] = None,
+ result: Optional[any] = None
+ ):
+ """Update the status of an authorization flow"""
+ with self._lock:
+ if provider in self._states:
+ state = self._states[provider]
+ state.status = status
+ state.error = error
+ state.result = result
+ if status in ["success", "failed", "cancelled"]:
+ state.completed_at = datetime.now()
+ logger.info(f"Updated {provider} OAuth status to {status}")
+
+ def cleanup_old_states(self, max_age_seconds: int = 3600):
+ """Clean up old completed states"""
+ with self._lock:
+ now = datetime.now()
+ to_remove = []
+ for provider, state in self._states.items():
+ if state.completed_at:
+ age = (now - state.completed_at).total_seconds()
+ if age > max_age_seconds:
+ to_remove.append(provider)
+
+ for provider in to_remove:
+ del self._states[provider]
+ logger.debug(f"Cleaned up old OAuth state for {provider}")
+
+
+# Global instance
+oauth_state_manager = OAuthStateManager()
+
diff --git a/backend/app/utils/toolkit/google_calendar_toolkit.py b/backend/app/utils/toolkit/google_calendar_toolkit.py
index 08aaeb162..fb7e77780 100644
--- a/backend/app/utils/toolkit/google_calendar_toolkit.py
+++ b/backend/app/utils/toolkit/google_calendar_toolkit.py
@@ -1,11 +1,14 @@
from typing import Any, Dict, List
import os
+import threading
from app.component.environment import env
from app.service.task import Agents
from app.utils.listen.toolkit_listen import listen_toolkit
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
+from app.utils.oauth_state_manager import oauth_state_manager
from camel.toolkits import GoogleCalendarToolkit as BaseGoogleCalendarToolkit
+from loguru import logger
SCOPES = ['https://www.googleapis.com/auth/calendar']
@@ -99,52 +102,188 @@ class GoogleCalendarToolkit(BaseGoogleCalendarToolkit, AbstractToolkit):
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
- client_id = os.environ.get("GOOGLE_CLIENT_ID")
- client_secret = os.environ.get("GOOGLE_CLIENT_SECRET")
- refresh_token = os.environ.get("GOOGLE_REFRESH_TOKEN")
- token_uri = os.environ.get("GOOGLE_TOKEN_URI", "https://oauth2.googleapis.com/token")
-
creds = None
+ # First, try to load from token file
try:
if os.path.exists(self._token_path):
+ logger.info(f"Loading credentials from token file: {self._token_path}")
creds = Credentials.from_authorized_user_file(self._token_path, SCOPES)
- except Exception:
+ logger.info("Successfully loaded credentials from token file")
+ except Exception as e:
+ logger.warning(f"Could not load from token file: {e}")
creds = None
- if not creds and refresh_token:
- creds = Credentials(
- None,
- refresh_token=refresh_token,
- token_uri=token_uri,
- client_id=client_id,
- client_secret=client_secret,
- scopes=SCOPES,
- )
-
+ # If no token file, try environment variables
if not creds:
- client_config = {
- "installed": {
- "client_id": client_id,
- "client_secret": client_secret,
- "auth_uri": "https://accounts.google.com/o/oauth2/auth",
- "token_uri": token_uri,
- "redirect_uris": ["http://localhost"],
- }
- }
- flow = InstalledAppFlow.from_client_config(client_config, SCOPES)
- creds = flow.run_local_server(port=0)
+ client_id = os.environ.get("GOOGLE_CLIENT_ID")
+ client_secret = os.environ.get("GOOGLE_CLIENT_SECRET")
+ refresh_token = os.environ.get("GOOGLE_REFRESH_TOKEN")
+ token_uri = os.environ.get("GOOGLE_TOKEN_URI", "https://oauth2.googleapis.com/token")
+
+ if refresh_token and client_id and client_secret:
+ logger.info("Creating credentials from environment variables")
+ creds = Credentials(
+ None,
+ refresh_token=refresh_token,
+ token_uri=token_uri,
+ client_id=client_id,
+ client_secret=client_secret,
+ scopes=SCOPES,
+ )
+ # If still no creds, check background authorization
+ if not creds:
+ state = oauth_state_manager.get_state("google_calendar")
+ if state and state.status == "success" and state.result:
+ logger.info("Using credentials from background authorization")
+ creds = state.result
+ else:
+ # No credentials available
+ raise ValueError("No credentials available. Please run authorization first via /api/install/tool/google_calendar")
+ # Refresh if expired
if creds and creds.expired and creds.refresh_token:
- creds.refresh(Request())
-
+ try:
+ logger.info("Token expired, refreshing...")
+ creds.refresh(Request())
+ logger.info("Token refreshed successfully")
+ except Exception as e:
+ logger.error(f"Failed to refresh token: {e}")
+ raise ValueError("Failed to refresh expired token. Please re-authorize.")
+ # Save credentials
try:
os.makedirs(os.path.dirname(self._token_path), exist_ok=True)
with open(self._token_path, "w") as f:
f.write(creds.to_json())
- except Exception:
- pass
+ except Exception as e:
+ logger.warning(f"Could not save credentials: {e}")
- return creds
\ No newline at end of file
+ return creds
+
+ @staticmethod
+ def start_background_auth(api_task_id: str = "install_auth") -> str:
+ """
+ Start background OAuth authorization flow with timeout
+ Returns the status of the authorization
+ """
+ from google_auth_oauthlib.flow import InstalledAppFlow
+ from wsgiref import simple_server
+ import socket
+
+ # Check if there's an existing authorization and force stop it
+ old_state = oauth_state_manager.get_state("google_calendar")
+ if old_state and old_state.status in ["pending", "authorizing"]:
+ logger.info("Found existing authorization, forcing shutdown...")
+ old_state.cancel()
+ # Try to shutdown the old server if it exists
+ if hasattr(old_state, 'server') and old_state.server:
+ try:
+ old_state.server.shutdown()
+ logger.info("Old server shutdown successfully")
+ except Exception as e:
+ logger.warning(f"Could not shutdown old server: {e}")
+
+ # Create new state for this authorization
+ state = oauth_state_manager.create_state("google_calendar")
+
+ def auth_flow():
+ local_server = None
+ try:
+ state.status = "authorizing"
+ oauth_state_manager.update_status("google_calendar", "authorizing")
+
+ client_id = os.environ.get("GOOGLE_CLIENT_ID")
+ client_secret = os.environ.get("GOOGLE_CLIENT_SECRET")
+ token_uri = os.environ.get("GOOGLE_TOKEN_URI", "https://oauth2.googleapis.com/token")
+
+ logger.info(f"Google Calendar auth - client_id present: {bool(client_id)}, client_secret present: {bool(client_secret)}")
+
+ if not client_id or not client_secret:
+ error_msg = "GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set in environment variables"
+ logger.error(error_msg)
+ raise ValueError(error_msg)
+
+ client_config = {
+ "installed": {
+ "client_id": client_id,
+ "client_secret": client_secret,
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
+ "token_uri": token_uri,
+ "redirect_uris": ["http://localhost"],
+ }
+ }
+
+ flow = InstalledAppFlow.from_client_config(client_config, SCOPES)
+
+ # Check for cancellation before starting
+ if state.is_cancelled():
+ logger.info("Authorization cancelled before starting")
+ return
+
+ # This will automatically open browser and wait for user authorization
+ logger.info("=" * 80)
+ logger.info(f"[Thread {threading.current_thread().name}] Starting local server for Google Calendar authorization")
+ logger.info("Browser should open automatically in a moment...")
+ logger.info("=" * 80)
+
+ # Run local server - this will block until authorization completes
+ # Note: Each call uses a random port (port=0), so multiple concurrent attempts won't conflict
+ try:
+ creds = flow.run_local_server(
+ port=0,
+ authorization_prompt_message="",
+ success_message="
Authorization successful!
You can close this window and return to Eigent.
",
+ open_browser=True
+ )
+ logger.info("Authorization flow completed successfully!")
+ except Exception as server_error:
+ logger.error(f"Error during run_local_server: {server_error}")
+ raise
+
+ # Check for cancellation after auth
+ if state.is_cancelled():
+ logger.info("Authorization cancelled after completion")
+ return
+
+ # Save credentials to token file
+ token_path = os.path.join(
+ os.path.expanduser("~"),
+ ".eigent",
+ "tokens",
+ "google_calendar",
+ f"google_calendar_token_{api_task_id}.json",
+ )
+
+ try:
+ os.makedirs(os.path.dirname(token_path), exist_ok=True)
+ with open(token_path, "w") as f:
+ f.write(creds.to_json())
+ logger.info(f"Saved Google Calendar credentials to {token_path}")
+ except Exception as e:
+ logger.warning(f"Could not save credentials: {e}")
+
+ # Update state with success
+ oauth_state_manager.update_status("google_calendar", "success", result=creds)
+ logger.info("Google Calendar authorization successful!")
+
+ except Exception as e:
+ if state.is_cancelled():
+ logger.info("Authorization was cancelled")
+ oauth_state_manager.update_status("google_calendar", "cancelled")
+ else:
+ error_msg = str(e)
+ logger.error(f"Google Calendar authorization failed: {error_msg}")
+ oauth_state_manager.update_status("google_calendar", "failed", error=error_msg)
+ finally:
+ # Clean up server reference
+ state.server = None
+
+ # Start authorization in background thread
+ thread = threading.Thread(target=auth_flow, daemon=True, name=f"GoogleCalendar-OAuth-{state.started_at.timestamp()}")
+ state.thread = thread
+ thread.start()
+
+ logger.info("Started background Google Calendar authorization")
+ return "authorizing"
\ No newline at end of file
diff --git a/src/components/AddWorker/IntegrationList.tsx b/src/components/AddWorker/IntegrationList.tsx
index 7345febb7..089a3e87f 100644
--- a/src/components/AddWorker/IntegrationList.tsx
+++ b/src/components/AddWorker/IntegrationList.tsx
@@ -78,18 +78,29 @@ export default function IntegrationList({
// items or configs change, recalculate installed
useEffect(() => {
- // remove duplicates by config_group
- const groupSet = new Set();
- configs.forEach((c: any) => {
- if (c.config_group) groupSet.add(c.config_group.toLowerCase());
- });
- // construct installed map
+ // For Google Calendar, check for allowed env keys
+ // For other integrations, check by config_group
const map: { [key: string]: boolean } = {};
+
items.forEach((item) => {
- if (groupSet.has(item.key.toLowerCase())) {
- map[item.key] = true;
+ if (item.key === "Google Calendar") {
+ // Only mark installed when refresh token is present (auth completed)
+ const hasRefreshToken = configs.some(
+ (c: any) =>
+ c.config_group?.toLowerCase() === "google calendar" &&
+ c.config_name === "GOOGLE_REFRESH_TOKEN" &&
+ c.config_value && String(c.config_value).length > 0
+ );
+ map[item.key] = hasRefreshToken;
+ } else {
+ // For other integrations, use config_group presence
+ const hasConfig = configs.some(
+ (c: any) => c.config_group?.toLowerCase() === item.key.toLowerCase()
+ );
+ map[item.key] = hasConfig;
}
});
+
setInstalled(map);
}, [items, configs]);
@@ -100,23 +111,35 @@ export default function IntegrationList({
value: string
) => {
const configPayload = {
- config_group: capitalizeFirstLetter(provider),
+ // Keep exact group name to satisfy backend whitelist
+ config_group: provider,
config_name: envVarKey,
config_value: value,
};
- // Check if config already exists
- const existingConfig = configs.find(
- (c: any) => c.config_name === envVarKey &&
- c.config_group?.toLowerCase() === provider.toLowerCase()
- );
+ // Fetch latest configs to avoid stale state when deciding POST/PUT
+ let latestConfigs: any[] = Array.isArray(configs) ? configs : [];
+ try {
+ const fresh = await proxyFetchGet("/api/configs");
+ if (Array.isArray(fresh)) latestConfigs = fresh;
+ } catch {}
+
+ // Backend uniqueness is by config_name for a user
+ let existingConfig = latestConfigs.find((c: any) => c.config_name === envVarKey);
if (existingConfig) {
- // Update existing config
await proxyFetchPut(`/api/configs/${existingConfig.id}`, configPayload);
} else {
- // Create new config
- await proxyFetchPost("/api/configs", configPayload);
+ const res = await proxyFetchPost("/api/configs", configPayload);
+ if (res && res.detail && (res.detail as string).toLowerCase().includes("already exists")) {
+ try {
+ const again = await proxyFetchGet("/api/configs");
+ const found = Array.isArray(again) ? again.find((c: any) => c.config_name === envVarKey) : null;
+ if (found) {
+ await proxyFetchPut(`/api/configs/${found.id}`, configPayload);
+ }
+ } catch {}
+ }
}
if (window.electronAPI?.envWrite) {
@@ -215,6 +238,7 @@ export default function IntegrationList({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items, oauth]);
+
// install/uninstall
const handleInstall = useCallback(
async (item: IntegrationItem) => {
@@ -237,21 +261,22 @@ export default function IntegrationList({
return;
}
- if (item.key === "Google Calendar") {
- let mcp = {
- name: "Google Calendar",
- key: "Google Calendar",
- install_command: {
- env: {} as any,
- },
- id: 14,
- };
- item.env_vars.map((key) => {
- mcp.install_command.env[key] = "";
- });
- onShowEnvConfig?.(mcp);
- return;
- }
+ if (item.key === "Google Calendar") {
+ // Always prompt env dialog first instead of jumping to authorization
+ let mcp = {
+ name: "Google Calendar",
+ key: "Google Calendar",
+ install_command: {
+ env: {} as any,
+ },
+ id: 14,
+ };
+ item.env_vars.map((key) => {
+ mcp.install_command.env[key] = "";
+ });
+ onShowEnvConfig?.(mcp);
+ return;
+ }
if (installed[item.key]) return;
await item.onInstall();
// refresh configs after install to update installed state indicator
@@ -333,6 +358,7 @@ export default function IntegrationList({
>
{items.map((item) => {
const isInstalled = !!installed[item.key];
+
return (
{
- try {
- const response = await fetchPost("/install/tool/google_calendar");
- if (response.success) {
- // Save to config to mark as installed
- await proxyFetchPost("/api/configs", {
- config_group: "Google Calendar",
- config_name: "GOOGLE_CLIENT_ID",
- config_value: response.toolkit_name || "GoogleCalendarToolkit",
- });
- console.log("Google Calendar installed successfully");
- // After successful installation, add to selected tools
- const calendarItem = {
- id: 0, // Use 0 for integration items
- key: key,
- name: key,
- description: "Google Calendar integration for managing events and schedules",
- toolkit: "google_calendar_toolkit", // Add the toolkit name
- isLocal: true
- };
- addOption(calendarItem, true);
- } else {
- console.error("Failed to install Google Calendar:", response.error || "Unknown error");
- throw new Error(response.error || "Failed to install Google Calendar");
- }
- } catch (error: any) {
- console.error("Failed to install Google Calendar:", error.message);
- throw error;
- }
- };
+ } else if (key.toLowerCase() === 'google calendar') {
+ onInstall = async () => {
+ try {
+ const response = await fetchPost("/install/tool/google_calendar");
+ if (response.success) {
+ // Check if config exists first to avoid 400 error
+ const existingConfigs = await proxyFetchGet("/api/configs");
+ const existing = Array.isArray(existingConfigs)
+ ? existingConfigs.find((c: any) =>
+ c.config_group?.toLowerCase() === "google calendar" &&
+ c.config_name === "GOOGLE_REFRESH_TOKEN"
+ )
+ : null;
+
+ const configPayload = {
+ config_group: "Google Calendar",
+ config_name: "GOOGLE_REFRESH_TOKEN",
+ config_value: "exists",
+ };
+
+ if (existing) {
+ await proxyFetchPut(`/api/configs/${existing.id}`, configPayload);
+ } else {
+ await proxyFetchPost("/api/configs", configPayload);
+ }
+
+ console.log("Google Calendar installed successfully");
+ // After successful installation, add to selected tools
+ const calendarItem = {
+ id: 0, // Use 0 for integration items
+ key: key,
+ name: key,
+ description: "Google Calendar integration for managing events and schedules",
+ toolkit: "google_calendar_toolkit", // Add the toolkit name
+ isLocal: true
+ };
+ addOption(calendarItem, true);
+ } else if (response.status === "authorizing") {
+ // Authorization in progress - browser should have opened
+ console.log("Google Calendar authorization in progress. Please complete in browser.");
+ console.log(response.message);
+ // Don't throw error - this is expected behavior
+ } else {
+ // Real error
+ console.error("Failed to install Google Calendar:", response.error || "Unknown error");
+ throw new Error(response.error || "Failed to install Google Calendar");
+ }
+ // Return the response so IntegrationList can check the status
+ return response;
+ } catch (error: any) {
+ // Only throw if it's a real error, not authorization in progress
+ if (!error.message?.includes("authorization")) {
+ console.error("Failed to install Google Calendar:", error.message);
+ throw error;
+ }
+ return null; // Return null on error
+ }
+ };
} else {
onInstall = () =>
(window.location.href = `${baseURL}/api/oauth/${key.toLowerCase()}/login`);
@@ -271,28 +298,55 @@ const ToolSelect = forwardRef<
// Continue anyway to trigger installation
}
- // Trigger instantiation for Google Calendar
- if (activeMcp.key === "Google Calendar") {
- try {
- const response = await fetchPost("/install/tool/google_calendar");
+ // Trigger instantiation for Google Calendar
+ if (activeMcp.key === "Google Calendar") {
+ try {
+ const response = await fetchPost("/install/tool/google_calendar");
+
+ if (response.success) {
+ // Mark as successfully installed by writing refresh token marker
+ const existingConfigs = await proxyFetchGet("/api/configs");
+ const existing = Array.isArray(existingConfigs)
+ ? existingConfigs.find((c: any) =>
+ c.config_group?.toLowerCase() === "google calendar" &&
+ c.config_name === "GOOGLE_REFRESH_TOKEN"
+ )
+ : null;
- if (response.success) {
- const selectedItem = {
- id: activeMcp.id,
- key: activeMcp.key,
- name: activeMcp.name,
- description: "Google Calendar integration for managing events and schedules",
- toolkit: "google_calendar_toolkit",
- isLocal: true
- };
- addOption(selectedItem, true);
+ const configPayload = {
+ config_group: "Google Calendar",
+ config_name: "GOOGLE_REFRESH_TOKEN",
+ config_value: "exists",
+ };
+
+ if (existing) {
+ await proxyFetchPut(`/api/configs/${existing.id}`, configPayload);
} else {
- console.error("Failed to install Google Calendar:", response.error || "Unknown error");
+ await proxyFetchPost("/api/configs", configPayload);
}
- } catch (error: any) {
- console.error("Failed to install Google Calendar:", error.message);
+
+ // Refresh integrations to update install status
+ fetchIntegrationsData();
+
+ const selectedItem = {
+ id: activeMcp.id,
+ key: activeMcp.key,
+ name: activeMcp.name,
+ description: "Google Calendar integration for managing events and schedules",
+ toolkit: "google_calendar_toolkit",
+ isLocal: true
+ };
+ addOption(selectedItem, true);
+ } else if (response.status === "authorizing") {
+ // Authorization in progress - browser should have opened
+ console.log("Google Calendar authorization in progress. Please complete in browser and try installing again.");
+ } else {
+ console.error("Failed to install Google Calendar:", response.error || "Unknown error");
}
+ } catch (error: any) {
+ console.error("Failed to install Google Calendar:", error.message);
}
+ }
return;
}
setInstalling((prev) => ({ ...prev, [id]: true }));
diff --git a/src/pages/Setting/MCP.tsx b/src/pages/Setting/MCP.tsx
index c324732e9..05d89df0d 100644
--- a/src/pages/Setting/MCP.tsx
+++ b/src/pages/Setting/MCP.tsx
@@ -1,10 +1,11 @@
import { useState, useEffect, useCallback } from "react";
import {
- proxyFetchGet,
- proxyFetchDelete,
- proxyFetchPost,
- proxyFetchPut,
- fetchPost,
+ proxyFetchGet,
+ proxyFetchDelete,
+ proxyFetchPost,
+ proxyFetchPut,
+ fetchPost,
+ fetchGet,
} from "@/api/http";
import MCPList from "./components/MCPList";
import MCPConfigDialog from "./components/MCPConfigDialog";
@@ -137,22 +138,93 @@ export default function SettingMCP() {
}
};
} else if (key.toLowerCase() === 'google calendar') {
- onInstall = async () => {
- try {
- const response = await fetchPost("/install/tool/google_calendar");
- if (response.success) {
- toast.success("Google Calendar installed successfully");
- // Refresh the integrations list to show the installed state
- fetchList();
- // Force refresh IntegrationList component
- setRefreshKey(prev => prev + 1);
- } else {
- toast.error(response.error || "Failed to install Google Calendar");
- }
- } catch (error: any) {
- toast.error(error.message || "Failed to install Google Calendar");
+ onInstall = async () => {
+ try {
+ const response = await fetchPost("/install/tool/google_calendar");
+ if (response.success) {
+ // Check if config exists first to avoid 400 error
+ const existingConfigs = await proxyFetchGet("/api/configs");
+ const existing = Array.isArray(existingConfigs)
+ ? existingConfigs.find((c: any) =>
+ c.config_group?.toLowerCase() === "google calendar" &&
+ c.config_name === "GOOGLE_REFRESH_TOKEN"
+ )
+ : null;
+
+ const configPayload = {
+ config_group: "Google Calendar",
+ config_name: "GOOGLE_REFRESH_TOKEN",
+ config_value: "exists",
+ };
+
+ if (existing) {
+ await proxyFetchPut(`/api/configs/${existing.id}`, configPayload);
+ } else {
+ await proxyFetchPost("/api/configs", configPayload);
+ }
+
+ toast.success("Google Calendar installed successfully");
+ // Refresh the integrations list to show the installed state
+ fetchList();
+ // Force refresh IntegrationList component
+ setRefreshKey(prev => prev + 1);
+ } else if (response.status === "authorizing") {
+ // Authorization in progress - start polling for completion
+ toast.info("Please complete authorization in your browser...");
+
+ // Poll for authorization completion via oauth status endpoint
+ const pollInterval = setInterval(async () => {
+ try {
+ const statusResp = await fetchGet("/oauth/status/google_calendar");
+ if (statusResp?.status === "success") {
+ clearInterval(pollInterval);
+ // Now that auth succeeded, run install again to initialize toolkit
+ const finalize = await fetchPost("/install/tool/google_calendar");
+ if (finalize?.success) {
+ const configs = await proxyFetchGet("/api/configs");
+ const existing = Array.isArray(configs)
+ ? configs.find((c: any) =>
+ c.config_group?.toLowerCase() === "google calendar" &&
+ c.config_name === "GOOGLE_REFRESH_TOKEN"
+ )
+ : null;
+
+ const payload = {
+ config_group: "Google Calendar",
+ config_name: "GOOGLE_REFRESH_TOKEN",
+ config_value: "exists",
+ };
+
+ if (existing) {
+ await proxyFetchPut(`/api/configs/${existing.id}`, payload);
+ } else {
+ await proxyFetchPost("/api/configs", payload);
+ }
+
+ toast.success("Google Calendar installed successfully");
+ fetchList();
+ setRefreshKey((prev) => prev + 1);
+ }
+ } else if (statusResp?.status === "failed" || statusResp?.status === "cancelled") {
+ clearInterval(pollInterval);
+ const msg = statusResp?.error || (statusResp?.status === "cancelled" ? "Authorization cancelled" : "Authorization failed");
+ toast.error(msg);
+ }
+ // if still authorizing, continue polling
+ } catch (err) {
+ console.error("Polling oauth status failed", err);
+ }
+ }, 2000);
+
+ // Safety timeout
+ setTimeout(() => clearInterval(pollInterval), 5 * 60 * 1000);
+ } else {
+ toast.error(response.error || response.message || "Failed to install Google Calendar");
}
- };
+ } catch (error: any) {
+ toast.error(error.message || "Failed to install Google Calendar");
+ }
+ };
} else {
onInstall = () =>
(window.location.href = `${baseURL}/api/oauth/${key.toLowerCase()}/login`);
diff --git a/src/pages/Setting/components/IntegrationList.tsx b/src/pages/Setting/components/IntegrationList.tsx
index 43e646ad0..1925bb42b 100644
--- a/src/pages/Setting/components/IntegrationList.tsx
+++ b/src/pages/Setting/components/IntegrationList.tsx
@@ -77,16 +77,24 @@ export default function IntegrationList({
// items or configs change, recalculate installed
useEffect(() => {
- // remove duplicates by config_group
- const groupSet = new Set();
- configs.forEach((c: any) => {
- if (c.config_group) groupSet.add(c.config_group.toLowerCase());
- });
// construct installed map
const map: { [key: string]: boolean } = {};
items.forEach((item) => {
- if (groupSet.has(item.key.toLowerCase())) {
- map[item.key] = true;
+ if (item.key === "Google Calendar") {
+ // Only mark installed after refresh token exists
+ const hasRefreshToken = configs.some(
+ (c: any) =>
+ c.config_group?.toLowerCase() === "google calendar" &&
+ c.config_name === "GOOGLE_REFRESH_TOKEN" &&
+ c.config_value && String(c.config_value).length > 0
+ );
+ map[item.key] = hasRefreshToken;
+ } else {
+ // For other integrations, use presence of any config in the group
+ const hasConfig = configs.some(
+ (c: any) => c.config_group?.toLowerCase() === item.key.toLowerCase()
+ );
+ map[item.key] = hasConfig;
}
});
setInstalled(map);
@@ -99,23 +107,36 @@ export default function IntegrationList({
value: string
) => {
const configPayload = {
- config_group: capitalizeFirstLetter(provider),
+ // Use exact group name, do not transform case to avoid whitelist mismatch
+ config_group: provider,
config_name: envVarKey,
config_value: value,
};
- // Check if config already exists
- const existingConfig = configs.find(
- (c: any) => c.config_name === envVarKey &&
- c.config_group?.toLowerCase() === provider.toLowerCase()
- );
+ // Fetch latest configs to avoid stale state when deciding POST/PUT
+ let latestConfigs: any[] = Array.isArray(configs) ? configs : [];
+ try {
+ const fresh = await proxyFetchGet("/api/configs");
+ if (Array.isArray(fresh)) latestConfigs = fresh;
+ } catch {}
+
+ // Check if config already exists (by name, regardless of group - backend uniqueness is by name)
+ let existingConfig = latestConfigs.find((c: any) => c.config_name === envVarKey);
if (existingConfig) {
- // Update existing config
await proxyFetchPut(`/api/configs/${existingConfig.id}`, configPayload);
} else {
- // Create new config
- await proxyFetchPost("/api/configs", configPayload);
+ const res = await proxyFetchPost("/api/configs", configPayload);
+ // If backend says it already exists (race), switch to PUT
+ if (res && res.detail && (res.detail as string).toLowerCase().includes("already exists")) {
+ try {
+ const again = await proxyFetchGet("/api/configs");
+ const found = Array.isArray(again) ? again.find((c: any) => c.config_name === envVarKey) : null;
+ if (found) {
+ await proxyFetchPut(`/api/configs/${found.id}`, configPayload);
+ }
+ } catch {}
+ }
}
if (window.electronAPI?.envWrite) {