Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions auth/google_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ async def start_auth_flow(
user_google_email: Optional[str],
service_name: str, # e.g., "Google Calendar", "Gmail" for user messages
redirect_uri: str, # Added redirect_uri as a required parameter
) -> str:
) -> tuple[str, str]:
"""
Initiates the Google OAuth flow and returns an actionable message for the user.

Expand All @@ -339,7 +339,7 @@ async def start_auth_flow(
redirect_uri: The URI Google will redirect to after authorization.

Returns:
A formatted string containing guidance for the LLM/user.
Tuple of (formatted message string, auth_url).

Raises:
Exception: If the OAuth flow cannot be initiated.
Expand Down Expand Up @@ -378,7 +378,7 @@ async def start_auth_flow(
state=oauth_state,
)

auth_url, _ = flow.authorization_url(access_type="offline", prompt="consent")
auth_url, _ = flow.authorization_url(prompt="consent")

session_id = None
try:
Expand Down Expand Up @@ -422,7 +422,7 @@ async def start_auth_flow(
message_lines.append(
f"\nThe application will use the new credentials. If '{user_google_email}' was provided, it must match the authenticated account."
)
return "\n".join(message_lines)
return "\n".join(message_lines), auth_url

except FileNotFoundError as e:
error_text = f"OAuth client credentials not found: {e}. Please either:\n1. Set environment variables: GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET\n2. Ensure '{CONFIG_CLIENT_SECRETS_PATH}' file exists"
Expand Down Expand Up @@ -939,14 +939,14 @@ async def get_authenticated_google_service(
)

# Generate auth URL and raise exception with it
auth_response = await start_auth_flow(
auth_response, auth_url = await start_auth_flow(
user_google_email=user_google_email,
service_name=f"Google {service_name.title()}",
redirect_uri=redirect_uri,
)

# Extract the auth URL from the response and raise with it
raise GoogleAuthenticationError(auth_response)
# Raise with both the message and the auth URL
raise GoogleAuthenticationError(auth_response, auth_url=auth_url)

try:
service = build(service_name, version, credentials=credentials)
Expand Down
60 changes: 60 additions & 0 deletions auth/oauth_callback_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ def __init__(self, port: int = 8000, base_uri: str = "http://localhost"):
self.server_thread = None
self.is_running = False

# CLI auth completion signaling
self.auth_completed = threading.Event()
self.auth_result: Optional[dict] = None # {"success": bool, "user_id": str|None, "error": str|None}

# Setup the callback route
self._setup_callback_route()
# Setup attachment serving route
Expand All @@ -62,19 +66,25 @@ async def oauth_callback(request: Request):
f"Authentication failed: Google returned an error: {error}."
)
logger.error(error_message)
self.auth_result = {"success": False, "user_id": None, "error": error_message}
self.auth_completed.set()
return create_error_response(error_message)

if not code:
error_message = (
"Authentication failed: No authorization code received from Google."
)
logger.error(error_message)
self.auth_result = {"success": False, "user_id": None, "error": error_message}
self.auth_completed.set()
return create_error_response(error_message)

try:
# Check if we have credentials available (environment variables or file)
error_message = check_client_secrets()
if error_message:
self.auth_result = {"success": False, "user_id": None, "error": error_message}
self.auth_completed.set()
return create_server_error_response(error_message)

logger.info(
Expand All @@ -96,12 +106,18 @@ async def oauth_callback(request: Request):
f"OAuth callback: Successfully authenticated user: {verified_user_id}."
)

# Signal completion for CLI auth flow
self.auth_result = {"success": True, "user_id": verified_user_id, "error": None}
self.auth_completed.set()

# Return success page using shared template
return create_success_response(verified_user_id)

except Exception as e:
error_message_detail = f"Error processing OAuth callback: {str(e)}"
logger.error(error_message_detail, exc_info=True)
self.auth_result = {"success": False, "user_id": None, "error": str(e)}
self.auth_completed.set()
return create_server_error_response(str(e))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

def _setup_attachment_route(self):
Expand Down Expand Up @@ -200,6 +216,22 @@ def run_server():
logger.error(error_msg)
return False, error_msg

def wait_for_auth(self, timeout: float = 300) -> Optional[dict]:
"""
Block until OAuth callback is received or timeout.

Args:
timeout: Maximum seconds to wait (default 5 minutes)

Returns:
Auth result dict {"success": bool, "user_id": str|None, "error": str|None}
or None if timed out
"""
completed = self.auth_completed.wait(timeout=timeout)
if completed:
return self.auth_result
return None

Comment on lines +240 to +255

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Reset auth state between flows to avoid stale success.

auth_completed is never cleared, so a second auth flow in the same process can return an old auth_result immediately. This can cause the CLI to skip a new login or retry prematurely.

Consider adding an explicit reset path (called before opening the auth URL) to clear the event and result.

🔧 Suggested change
-    def wait_for_auth(self, timeout: float = 300) -> Optional[dict]:
+    def wait_for_auth(self, timeout: float = 300, reset: bool = False) -> Optional[dict]:
         """
         Block until OAuth callback is received or timeout.
@@
-        completed = self.auth_completed.wait(timeout=timeout)
+        if reset:
+            self.auth_result = None
+            self.auth_completed.clear()
+        completed = self.auth_completed.wait(timeout=timeout)
         if completed:
             return self.auth_result
         return None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@auth/oauth_callback_server.py` around lines 219 - 234, The auth flow leaves
auth_completed set and auth_result populated so subsequent calls to
wait_for_auth return stale success; add a reset method (e.g., reset_auth_state)
that clears self.auth_completed (call clear()), sets self.auth_result = None (or
default failure dict), and ensure callers invoke this reset before starting a
new flow (before opening the auth URL or calling wait_for_auth); update any code
paths that start an auth flow to call reset_auth_state to guarantee a fresh
event/result state.

def stop(self):
"""Stop the minimal OAuth server."""
if not self.is_running:
Expand Down Expand Up @@ -279,6 +311,34 @@ def ensure_oauth_callback_available(
return False, error_msg


def set_cli_oauth_server(server: MinimalOAuthServer) -> None:
"""Register a MinimalOAuthServer as the global instance (used by CLI mode)."""
global _minimal_oauth_server
_minimal_oauth_server = server


def get_cli_oauth_port() -> int:
"""
Find an available port for the CLI OAuth callback server.
Tries ports 8000-8009, falls back to OS-assigned port.

Returns:
Available port number
"""
for port in range(8000, 8010):
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("localhost", port))
return port
except OSError:
continue

# Fallback: let OS assign a port
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("localhost", 0))
return s.getsockname()[1]
Comment on lines +341 to +360

@coderabbitai coderabbitai Bot Mar 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Do not pick the callback port with a bind-and-release probe.

get_cli_oauth_port() only proves the port was free momentarily; it does not reserve it. Another local process can claim that port before MinimalOAuthServer.start() runs, and the later readiness probe can even accept that foreign listener as "started" if /oauth2callback returns anything other than 404. Try candidate ports by actually starting the server, then set GOOGLE_OAUTH_REDIRECT_URI from the port that bound successfully.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Probably more minor than major, the likelihood that a port gets claimed in the moments between check and initialization seems low

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@taylorwilsdon, that's a fair point — the race window is genuinely tiny under normal conditions. On a typical developer machine or a dedicated Docker container, the gap between releasing the socket and uvicorn binding is unlikely to be contested. Downgrading this to minor is reasonable.

The slightly more interesting edge case remains: if some other process already happened to be listening on the chosen port before get_cli_oauth_port() runs, the readiness probe (which accepts any non-404 response) could mistake that foreign listener for the OAuth server. But again, port 8000 is usually free in the described headless-Docker use case, so this is a low-probability path in practice.

Happy to leave this as-is if you'd prefer — it's a pre-existing pattern and the practical risk is low.


🧠 Learnings used
Learnt from: hGriff0n
Repo: taylorwilsdon/google_workspace_mcp PR: 499
File: gkeep/keep_tools.py:417-425
Timestamp: 2026-02-21T18:00:37.834Z
Learning: When using asyncio.to_thread(callable, *args, **kwargs), pass a callable, not the result of a call. For methods on objects, pass the method reference (e.g., await asyncio.to_thread(service.some_method(...).execute)) rather than invoking it upfront (service.some_method(...).execute()). This ensures a callable is provided to to_thread, which will run it in a separate thread. This guideline applies broadly to all Python files in the Google Workspace MCP codebase (and similar Python projects); enforce by scanning for instances where a method is called immediately inside to_thread instead of passing the bound method/reference.



def cleanup_oauth_callback_server():
"""Clean up the minimal OAuth server if it was started."""
global _minimal_oauth_server
Expand Down
99 changes: 95 additions & 4 deletions core/cli_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,16 @@
import asyncio
import json
import logging
import os
import sys
from typing import Any, Dict, List, Optional

from auth.oauth_config import set_transport_mode
from auth.oauth_config import set_transport_mode, reload_oauth_config
from auth.oauth_callback_server import (
MinimalOAuthServer,
get_cli_oauth_port,
set_cli_oauth_server,
)
Comment on lines +24 to +28

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

CLI run path starts OAuth server but never tears it down.

Line 474 starts a callback server, but the run branch has early return paths and no finally cleanup. In long-lived processes, repeated CLI runs can accumulate orphaned server threads/sockets and stale global registration.

🔧 Suggested fix
 from auth.oauth_callback_server import (
     MinimalOAuthServer,
+    cleanup_oauth_callback_server,
     get_cli_oauth_port,
     set_cli_oauth_server,
 )
@@
         if parsed["command"] == "run":
             # Pre-start OAuth server before running tools that may need auth
             oauth_server = _setup_cli_oauth_server()

             # Merge stdin args with inline args (inline takes precedence)
             args = read_stdin_args()
             args.update(parsed["tool_args"])

-            try:
-                result = await run_tool(server, parsed["tool_name"], args)
-                print(result)
-                return 0
-            except Exception as e:
-                from auth.google_auth import GoogleAuthenticationError
-                if isinstance(e, GoogleAuthenticationError) and e.auth_url:
-                    result = await _handle_cli_auth_flow(
-                        server, parsed["tool_name"], args, e, oauth_server
-                    )
-                    print(result)
-                    return 0
-                raise
+            try:
+                try:
+                    result = await run_tool(server, parsed["tool_name"], args)
+                    print(result)
+                    return 0
+                except Exception as e:
+                    from auth.google_auth import GoogleAuthenticationError
+                    if isinstance(e, GoogleAuthenticationError) and e.auth_url:
+                        result = await _handle_cli_auth_flow(
+                            server, parsed["tool_name"], args, e, oauth_server
+                        )
+                        print(result)
+                        return 0
+                    raise
+            finally:
+                cleanup_oauth_callback_server()

Also applies to: 473-492

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/cli_handler.py` around lines 24 - 28, The CLI path that starts the
MinimalOAuthServer (via get_cli_oauth_port and set_cli_oauth_server) never
guarantees teardown on early returns; modify the run logic so the server is
shutdown and deregistered in a finally/cleanup block: after creating and
set_cli_oauth_server(server) ensure you call the server's stop/shutdown method
and then set_cli_oauth_server(None) on every exit path (including error/early
returns) so no threads/sockets or stale global remain; wrap the run branch that
starts the server in try/finally (or ensure each return first performs the same
cleanup) and reference MinimalOAuthServer, get_cli_oauth_port, and
set_cli_oauth_server when making the changes.


logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -264,6 +270,10 @@ async def run_tool(server, tool_name: str, args: Dict[str, Any]) -> str:
f"Provided parameters: {list(call_args.keys())}"
)
except Exception as e:
# Let GoogleAuthenticationError propagate for CLI auth handling
from auth.google_auth import GoogleAuthenticationError
if isinstance(e, GoogleAuthenticationError):
raise
logger.error(f"[CLI] Error executing {tool_name}: {e}", exc_info=True)
return f"Error: {type(e).__name__}: {e}"

Expand Down Expand Up @@ -361,6 +371,74 @@ def read_stdin_args() -> Dict[str, Any]:
return {}


def _setup_cli_oauth_server() -> MinimalOAuthServer:
"""
Pre-start a MinimalOAuthServer for CLI auth flow.

Finds an available port, configures the redirect URI, starts the server,
and registers it as the global OAuth server.

Returns:
The started MinimalOAuthServer instance
"""
port = get_cli_oauth_port()
redirect_uri = f"http://localhost:{port}/oauth2callback"

# Set redirect URI in env so OAuthConfig picks it up
os.environ["GOOGLE_OAUTH_REDIRECT_URI"] = redirect_uri
reload_oauth_config()

oauth_server = MinimalOAuthServer(port=port, base_uri="http://localhost")
success, error_msg = oauth_server.start()
if not success:
raise RuntimeError(f"Failed to start OAuth server: {error_msg}")

set_cli_oauth_server(oauth_server)
logger.info(f"[CLI] OAuth callback server ready on port {port}")
return oauth_server


async def _handle_cli_auth_flow(
server, tool_name: str, args: Dict[str, Any], auth_error, oauth_server: MinimalOAuthServer
) -> str:
"""
Handle OAuth authentication flow in CLI mode.

Prints the auth URL, waits for the browser callback, then retries the tool.

Args:
server: The FastMCP server instance
tool_name: Name of the tool to retry after auth
args: Tool arguments
auth_error: The GoogleAuthenticationError with auth_url
oauth_server: The running MinimalOAuthServer

Returns:
Tool result as string
"""
print("\n" + "=" * 60, file=sys.stderr)
print("Authentication Required", file=sys.stderr)
print("=" * 60, file=sys.stderr)
print(f"\nOpen this URL in your browser:\n", file=sys.stderr)
print(f" {auth_error.auth_url}\n", file=sys.stderr)
print("Waiting for authentication (timeout: 5 minutes)...", file=sys.stderr)

# Wait for the OAuth callback in a thread (blocking call)
result = await asyncio.to_thread(oauth_server.wait_for_auth, 300)

if result is None:
raise RuntimeError("Authentication timed out after 5 minutes.")

if not result["success"]:
raise RuntimeError(f"Authentication failed: {result['error']}")

print(f"\nAuthenticated as: {result['user_id']}", file=sys.stderr)

@coderabbitai coderabbitai Bot Mar 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't print the authenticated account ID to stderr.

This sends a raw Google account identifier into CLI transcripts and agent event streams. A generic success message, or at most a masked address, keeps the flow usable without leaking PII.

🔧 Suggested change
-    print(f"\nAuthenticated as: {result['user_id']}", file=sys.stderr)
+    print("\nAuthentication completed successfully.", file=sys.stderr)

As per coding guidelines "Never log or leak secrets, refresh tokens, or PII in exceptions or event streams."

📝 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
print(f"\nAuthenticated as: {result['user_id']}", file=sys.stderr)
print("\nAuthentication completed successfully.", file=sys.stderr)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/cli_handler.py` at line 437, The current print call prints
result['user_id'] to stderr which leaks PII; update the code that prints
authentication feedback (the print(...) statement referencing result['user_id']
in core/cli_handler.py) to avoid raw account IDs—either emit a generic success
message like "Authenticated successfully" or print a masked identifier (e.g.,
replace most characters of the address with asterisks while keeping only a
non-identifying suffix). Ensure the change touches the print(...) invocation
that currently outputs result['user_id'] and does not expose the full identifier
in CLI transcripts or event streams.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

CLI mode is explicitly local to a user system so I would not consider this "major" - just a nit

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@taylorwilsdon, that's a fair point — in a strictly local CLI context the user is seeing their own account identifier on their own terminal, which is quite different from leaking PII into a shared server log or agent event stream. Downgrading this to a nit is reasonable. The suggestion to use a generic message still stands as a minor polish item, but it's not a blocker.


🧠 Learnings used
Learnt from: CR
Repo: taylorwilsdon/google_workspace_mcp PR: 0
File: .github/instructions/general.instructions.md:0-0
Timestamp: 2026-03-03T22:54:40.426Z
Learning: Applies to **/*.py : Never log or leak secrets, refresh tokens, or PII in exceptions or event streams.

Learnt from: hGriff0n
Repo: taylorwilsdon/google_workspace_mcp PR: 499
File: gkeep/keep_tools.py:417-425
Timestamp: 2026-02-21T18:00:37.834Z
Learning: When using asyncio.to_thread(callable, *args, **kwargs), pass a callable, not the result of a call. For methods on objects, pass the method reference (e.g., await asyncio.to_thread(service.some_method(...).execute)) rather than invoking it upfront (service.some_method(...).execute()). This ensures a callable is provided to to_thread, which will run it in a separate thread. This guideline applies broadly to all Python files in the Google Workspace MCP codebase (and similar Python projects); enforce by scanning for instances where a method is called immediately inside to_thread instead of passing the bound method/reference.

print("Retrying tool execution...\n", file=sys.stderr)

# Retry the tool
return await run_tool(server, tool_name, args)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


async def handle_cli_mode(server, cli_args: List[str]) -> int:
"""
Main entry point for CLI mode.
Expand Down Expand Up @@ -390,13 +468,26 @@ async def handle_cli_mode(server, cli_args: List[str]) -> int:
return 0

if parsed["command"] == "run":
# Pre-start OAuth server before running tools that may need auth
oauth_server = _setup_cli_oauth_server()

Comment on lines 472 to +475

@coderabbitai coderabbitai Bot Mar 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't make every CLI tool run depend on starting the callback server.

This now fails the run path before tool execution whenever the local callback server cannot bind, even if cached credentials are already valid and no browser auth would happen. A safer shape is to configure/register the CLI OAuth server here, but defer start() until auth is actually requested.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/cli_handler.py` around lines 472 - 475, The code currently calls
_setup_cli_oauth_server() unconditionally in the run path which attempts to bind
the callback port immediately; instead, change the flow to only
configure/register the CLI OAuth server object without starting it: have
_setup_cli_oauth_server() return an OAuthServer instance (or factory) but do not
call its start()/serve() here, and remove any immediate bind calls from the
run-path; then update the authentication code paths (the functions that actually
perform browser OAuth or token refresh) to call oauth_server.start() (or
instantiate/start the server) lazily only when a real interactive auth callback
is required. Reference: parsed["command"] == "run", _setup_cli_oauth_server(),
and any oauth_server.start()/start_server calls in auth routines.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Valid

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧩 Analysis chain

@taylorwilsdon, thanks for confirming. Here's a plan to lazily start the OAuth server only when auth is actually needed:

  • Split _setup_cli_oauth_server into a configure phase (port selection, env, reload, server instantiation, registration) and a start phase
  • Call configure unconditionally in the run path so the redirect URI is set before the tool runs
  • Move oauth_server.start() into _handle_cli_auth_flow, called only when a GoogleAuthenticationError is caught
  • Guard oauth_server.stop() in the finally block so it's safe to call even if the server was never started

🏁 Script executed:

#!/bin/bash
# Check if MinimalOAuthServer.stop() is already safe to call when not started
ast-grep --pattern 'def stop(self) {
  $$$
}'
rg -n "def stop" auth/oauth_callback_server.py -A 10
# Also check if there's an is_running or similar attribute
rg -n "is_running\|_started\|_running\|self\._server" auth/oauth_callback_server.py

Length of output: 378


🏁 Script executed:

#!/bin/bash
rg -n "def stop\|def start\|is_running\|_started\|_running\|self\._server\b" auth/oauth_callback_server.py -A 5

Length of output: 0


🏁 Script executed:

#!/bin/bash
rg -n "is_running|def start|def stop|_started|_running" auth/oauth_callback_server.py -A 3

Length of output: 1495


stop() already guards with if not self.is_running: return, so the finally block is safe as-is. Here's the full lazy-start refactor:

  • Rename _setup_cli_oauth_server_configure_cli_oauth_server, removing the start() call
  • Call the configure function (not start) unconditionally so the redirect URI is registered before tool execution
  • Move oauth_server.start() into _handle_cli_auth_flow, only when auth is actually needed
  • finally: oauth_server.stop() stays unchanged — already a no-op when is_running is False

🧠 Learnings used
Learnt from: hGriff0n
Repo: taylorwilsdon/google_workspace_mcp PR: 499
File: gkeep/keep_tools.py:417-425
Timestamp: 2026-02-21T18:00:37.834Z
Learning: When using asyncio.to_thread(callable, *args, **kwargs), pass a callable, not the result of a call. For methods on objects, pass the method reference (e.g., await asyncio.to_thread(service.some_method(...).execute)) rather than invoking it upfront (service.some_method(...).execute()). This ensures a callable is provided to to_thread, which will run it in a separate thread. This guideline applies broadly to all Python files in the Google Workspace MCP codebase (and similar Python projects); enforce by scanning for instances where a method is called immediately inside to_thread instead of passing the bound method/reference.

💡 Use @coderabbitai plan to create a plan for code changes

# Merge stdin args with inline args (inline takes precedence)
args = read_stdin_args()
args.update(parsed["tool_args"])

result = await run_tool(server, parsed["tool_name"], args)
print(result)
return 0
try:
result = await run_tool(server, parsed["tool_name"], args)
print(result)
return 0
except Exception as e:
from auth.google_auth import GoogleAuthenticationError
if isinstance(e, GoogleAuthenticationError) and e.auth_url:
result = await _handle_cli_auth_flow(
server, parsed["tool_name"], args, e, oauth_server
)
print(result)
return 0
raise

# Unknown command
print(f"Unknown command: {parsed['command']}")
Expand Down
2 changes: 1 addition & 1 deletion core/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,7 @@ async def start_google_auth(
return f"**Authentication Error:** {error_message}"

try:
auth_message = await start_auth_flow(
auth_message, _ = await start_auth_flow(
user_google_email=user_google_email,
service_name=service_name,
redirect_uri=get_oauth_redirect_uri_for_current_mode(),
Expand Down
7 changes: 5 additions & 2 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,9 +311,12 @@ def main():
safe_print(
f" {tool_icons[tool]} {tool.title()} - Google {tool.title()} API integration"
)
except ModuleNotFoundError as exc:
except Exception as exc:
logger.error("Failed to import tool '%s': %s", tool, exc, exc_info=True)
safe_print(f" ⚠️ Failed to load {tool.title()} tool module ({exc}).")
if _CLI_MODE:
print(f" Warning: Failed to load {tool.title()} tool module ({exc}).", file=sys.stderr)
else:
safe_print(f" ⚠️ Failed to load {tool.title()} tool module ({exc}).")
safe_print("")

# Filter tools based on tier configuration (if tier-based loading is enabled)
Expand Down