Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ uv run main.py --tools gmail drive
| `MCP_ENABLE_OAUTH21` | Set to `true` for OAuth 2.1 support |
| `EXTERNAL_OAUTH21_PROVIDER` | Set to `true` for external OAuth flow with bearer tokens (requires OAuth 2.1) |
| `WORKSPACE_MCP_STATELESS_MODE` | Set to `true` for stateless operation (requires OAuth 2.1) |
| `AUTH_BROWSER_TYPE` | Browser mode for OAuth: `normal` (default) or `incognito` |

</td></tr>
</table>
Expand Down
191 changes: 176 additions & 15 deletions auth/google_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
import jwt
import logging
import os
import subprocess
import sys
import webbrowser

from typing import List, Optional, Tuple, Dict, Any
from urllib.parse import parse_qs, urlparse
Expand Down Expand Up @@ -88,6 +91,111 @@ def get_default_credentials_dir():
"client_secret.json",
)

def _copy_to_clipboard(text: str) -> bool:
"""
Attempts to copy text to the system clipboard.
Returns True if successful, False otherwise.
"""
try:
# macOS
if sys.platform == "darwin":
process = subprocess.Popen(
["pbcopy"], stdin=subprocess.PIPE, stderr=subprocess.DEVNULL
)
process.communicate(text.encode("utf-8"))
return process.returncode == 0
# Linux with xclip
elif sys.platform.startswith("linux"):
process = subprocess.Popen(
["xclip", "-selection", "clipboard"],
stdin=subprocess.PIPE,
stderr=subprocess.DEVNULL,
)
process.communicate(text.encode("utf-8"))
return process.returncode == 0
# Windows
elif sys.platform == "win32":
process = subprocess.Popen(
["clip"], stdin=subprocess.PIPE, stderr=subprocess.DEVNULL
)
process.communicate(text.encode("utf-8"))
return process.returncode == 0
except Exception as e:
logger.debug(f"Failed to copy to clipboard: {e}")
return False


def _has_tty() -> bool:
"""Check if we're running with a TTY (interactive terminal)."""
try:
return sys.stdin.isatty() and sys.stdout.isatty()
except Exception:
return False


def _open_browser_incognito(url: str) -> bool:
"""
Opens a URL in an incognito/private browser window.
Tries Chrome first, then Firefox, then falls back to regular browser.
Returns True if successful, False otherwise.
"""
try:
if sys.platform == "darwin":
# Try Chrome incognito first
result = subprocess.run(
["open", "-na", "Google Chrome", "--args", "--incognito", url],
capture_output=True,
)
if result.returncode == 0:
logger.info("Opened Chrome incognito window")
return True

# Try Firefox private window
result = subprocess.run(
["open", "-na", "Firefox", "--args", "-private-window", url],
capture_output=True,
)
if result.returncode == 0:
logger.info("Opened Firefox private window")
return True

elif sys.platform.startswith("linux"):
# Try Chrome incognito
for chrome in ["google-chrome", "chromium-browser", "chromium"]:
result = subprocess.run(
[chrome, "--incognito", url],
capture_output=True,
)
if result.returncode == 0:
logger.info(f"Opened {chrome} incognito window")
return True

# Try Firefox private
result = subprocess.run(
["firefox", "-private-window", url],
capture_output=True,
)
if result.returncode == 0:
logger.info("Opened Firefox private window")
return True

elif sys.platform == "win32":
# Try Chrome incognito
result = subprocess.run(
["start", "chrome", "--incognito", url],
shell=True,
capture_output=True,
)
if result.returncode == 0:
logger.info("Opened Chrome incognito window")
return True

except Exception as e:
logger.debug(f"Failed to open incognito browser: {e}")

return False


# --- Helper Functions ---


Expand Down Expand Up @@ -392,31 +500,84 @@ async def start_auth_flow(
store.store_oauth_state(oauth_state, session_id=session_id)

logger.info(
f"Auth flow started for {user_display_name}. Advise user to visit: {auth_url}"
f"Auth flow started for {user_display_name}. Auth URL: {auth_url}"
)

message_lines = [
f"**ACTION REQUIRED: Google Authentication Needed for {user_display_name}**\n",
f"To proceed, the user must authorize this application for {service_name} access using all required permissions.",
"**LLM, please present this exact authorization URL to the user as a clickable hyperlink:**",
f"Authorization URL: {auth_url}",
f"Markdown for hyperlink: [Click here to authorize {service_name} access]({auth_url})\n",
"**LLM, after presenting the link, instruct the user as follows:**",
"1. Click the link and complete the authorization in their browser.",
]
session_info_for_llm = ""
# Try to open browser, then clipboard as fallback
# AUTH_BROWSER_TYPE: "normal" (default) or "incognito"
browser_opened = False
incognito_opened = False
clipboard_copied = False
browser_type = os.getenv("AUTH_BROWSER_TYPE", "normal").lower()

if browser_type == "incognito":
# Try incognito/private window (helps when user has multiple Google accounts)
incognito_opened = _open_browser_incognito(auth_url)
if incognito_opened:
browser_opened = True

if not browser_opened:
# Open regular browser window
try:
browser_opened = webbrowser.open(auth_url)
if browser_opened:
logger.info("Successfully opened browser for OAuth flow")
else:
logger.warning("webbrowser.open returned False - browser may not have opened")
except Exception as browser_error:
logger.warning(f"Failed to open browser automatically: {browser_error}")

# If browser didn't open, try clipboard as fallback
if not browser_opened:
clipboard_copied = _copy_to_clipboard(auth_url)
if clipboard_copied:
logger.info("Successfully copied auth URL to clipboard")
else:
logger.warning("Failed to copy auth URL to clipboard")

# Build message based on what action was taken
if incognito_opened:
message_lines = [
f"**ACTION REQUIRED: Google Authentication Needed for {user_display_name}**\n",
"An incognito browser window has been opened for you to authorize access.",
"Please complete the authorization in your browser.",
]
elif browser_opened:
message_lines = [
f"**ACTION REQUIRED: Google Authentication Needed for {user_display_name}**\n",
"A browser window has been opened for you to authorize access.",
"Please complete the authorization in your browser.",
]
elif clipboard_copied:
message_lines = [
f"**ACTION REQUIRED: Google Authentication Needed for {user_display_name}**\n",
"The authorization URL has been copied to your clipboard.",
"Please paste it in a browser and complete the authorization.",
]
else:
# Fallback - provide the URL directly
message_lines = [
f"**ACTION REQUIRED: Google Authentication Needed for {user_display_name}**\n",
f"To proceed, the user must authorize this application for {service_name} access using all required permissions.",
"**LLM, please present this exact authorization URL to the user as a clickable hyperlink:**",
f"Authorization URL: {auth_url}",
f"Markdown for hyperlink: [Click here to authorize {service_name} access]({auth_url})\n",
"**LLM, after presenting the link, instruct the user as follows:**",
"1. Click the link and complete the authorization in their browser.",
]

if not initial_email_provided:
message_lines.extend(
[
f"2. After successful authorization{session_info_for_llm}, the browser page will display the authenticated email address.",
" **LLM: Instruct the user to provide you with this email address.**",
"3. Once you have the email, **retry their original command, ensuring you include this `user_google_email`.**",
"",
"After successful authorization, the browser page will display the authenticated email address.",
"**LLM: Instruct the user to provide you with this email address.**",
"Once you have the email, **retry their original command, ensuring you include this `user_google_email`.**",
]
)
else:
message_lines.append(
f"2. After successful authorization{session_info_for_llm}, **retry their original command**."
"\nAfter successful authorization, **retry your original command**."
)

message_lines.append(
Expand Down
33 changes: 33 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,39 @@ def main():
warning_msg += f": {error_msg}"
safe_print(warning_msg)

# Proactive auth check if USER_GOOGLE_EMAIL is configured
user_email = os.getenv("USER_GOOGLE_EMAIL")
if user_email and success:
from auth.google_auth import get_credentials
from auth.scopes import get_current_scopes

safe_print(f" Checking credentials for {user_email}...")
creds = get_credentials(
user_google_email=user_email,
required_scopes=get_current_scopes(),
)
if not creds or not creds.valid:
safe_print(" ⚠️ No valid credentials found - initiating auth flow...")
import asyncio
from auth.google_auth import start_auth_flow
from core.config import get_oauth_redirect_uri

redirect_uri = get_oauth_redirect_uri()
try:
auth_message = asyncio.get_event_loop().run_until_complete(
start_auth_flow(
user_google_email=user_email,
service_name="Google Workspace",
redirect_uri=redirect_uri,
)
)
# The browser should have opened automatically
safe_print(" 🔐 Browser opened for authentication")
except Exception as auth_err:
safe_print(f" ⚠️ Auth flow error: {auth_err}")
else:
safe_print(" ✅ Valid credentials found")

safe_print("✅ Ready for MCP connections")
safe_print("")

Expand Down