import asyncio import logging import os import time import re from typing import Optional # Optional MCP imports - the system should fall back gracefully if mcp is not installed try: from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client MCP_AVAILABLE = True except ImportError: MCP_AVAILABLE = False from backend.security.vault import resolve_vault_key_oauth def get_trusted_otp_sites() -> list[str]: try: import sqlite3, os db_path = os.path.join(os.environ.get("JARVIS_APP_DATA_DIR", os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))), "memory.db") conn = sqlite3.connect(db_path) c = conn.cursor() c.execute("CREATE TABLE IF NOT EXISTS trusted_otp_sites (site TEXT PRIMARY KEY)") c.execute("SELECT site FROM trusted_otp_sites") rows = c.fetchall() conn.close() return [r[0] for r in rows] except Exception: return [] # Global MCP session reference _gmail_session: Optional['ClientSession'] = None async def connect_mcp_server() -> Optional['ClientSession']: if _gmail_session is not None: return _gmail_session if not MCP_AVAILABLE: logging.error("MCP library not available. Cannot connect to Gmail MCP server.") return None oauth_token = await resolve_vault_key_oauth("GMAIL_OAUTH") if not oauth_token: logging.error("Could not obtain Gmail OAuth token. Setup required.") return None try: # Standard MCP Stdio setup for the Gmail server (StdioServerParameters built inline when needed) # We start the stdio client in the background. Since this isn't a long-running persistent daemon script, # we will handle it via normal context manager or leave it open if we can. # For simplicity, we just assume the session is set up properly or stub it out if we can't keep it open. # In a real daemon, you'd manage the context lifetimes carefully. pass # The real implementation would manage the context managers for stdio_client and ClientSession here. # For this prototype hook, we'll let watch_for_otp_email handle the short-lived session. except Exception as e: logging.error(f"Error connecting to Gmail MCP server: {e}") return None return _gmail_session def extract_otp_pattern(snippet: str) -> Optional[str]: """Extracts a 4-8 digit OTP code from a given string/snippet.""" # Look for common phrases lower_snip = snippet.lower() if "verification code" in lower_snip or "one-time password" in lower_snip or "otp" in lower_snip or "code is" in lower_snip: # regex for 4 to 8 digit codes, often separated by spaces or dashes match = re.search(r'\b(\d{4,8})\b', snippet) if match: return match.group(1) # Sometimes codes are alphanumeric: e.g. G-123456 match = re.search(r'\b(G-\d{6})\b', snippet) if match: return match.group(1).replace("G-", "") return None async def watch_for_otp_email(timeout_seconds: int = 60, current_site: str = "") -> Optional[str]: """Polls Gmail for a newly arrived OTP code.""" if not MCP_AVAILABLE: logging.error("Cannot watch for OTP: MCP unavailable") return None oauth_token = await resolve_vault_key_oauth("GMAIL_OAUTH") if not oauth_token: logging.error("No Gmail OAuth token available.") # REQUIRED FIX: Surface Gmail OAuth expiry via voice from core.voice import speak speak("Gmail access has expired or is disconnected. Please reconnect your account in settings to use auto-fill.") return None logging.info(f"Watching for OTP emails for the next {timeout_seconds} seconds...") start = time.time() server_params = StdioServerParameters( command="npx", args=["-y", "@modelcontextprotocol/server-google-mail"], env={"GMAIL_OAUTH_TOKEN": oauth_token, **os.environ} # passing via env or args depending on the MCP server specs ) try: async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() while time.time() - start < timeout_seconds: try: # Assuming the Gmail MCP server provides a tool named "list_messages" or similar # We use the generic call_tool API for MCP # REQUIRED FIX: Add dynamic sender domain verification to prevent phishing hijacks query_str = "newer_than:1d" if current_site and current_site != "unknown": query_str += f" from:{current_site}" result = await session.call_tool("list_messages", {"query": query_str, "max_results": 5}) messages = result.content if hasattr(result, 'content') else [] for msg in messages: snippet = msg.get("snippet", "") if isinstance(msg, dict) else str(msg) code = extract_otp_pattern(snippet) if code: if current_site and current_site not in get_trusted_otp_sites(): logging.warning(f"Found OTP {code} for {current_site}, but it is not in TRUSTED_OTP_AUTOFILL_SITES.") # Fallback to notify_user from core.voice import speak speak(f"Found a code for {current_site}, but auto-fill isn't enabled for this site yet. Add it in Settings to enable automatic entry.") return None return code except Exception as e: logging.warning(f"Error querying messages: {e}") await asyncio.sleep(5) # poll every 5s except Exception as e: logging.error(f"Failed to run MCP session: {e}") return None