workspace-mcp 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
auth/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # Make the auth directory a Python package
auth/google_auth.py ADDED
@@ -0,0 +1,549 @@
1
+ # auth/google_auth.py
2
+
3
+ import os
4
+ import json
5
+ import logging
6
+ import asyncio
7
+ from typing import List, Optional, Tuple, Dict, Any, Callable
8
+ import os
9
+
10
+ from google.oauth2.credentials import Credentials
11
+ from google_auth_oauthlib.flow import Flow, InstalledAppFlow
12
+ from google.auth.transport.requests import Request
13
+ from google.auth.exceptions import RefreshError
14
+ from googleapiclient.discovery import build
15
+ from googleapiclient.errors import HttpError
16
+ from auth.scopes import OAUTH_STATE_TO_SESSION_ID_MAP, SCOPES
17
+
18
+ # Configure logging
19
+ logging.basicConfig(level=logging.INFO)
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Constants
23
+ DEFAULT_CREDENTIALS_DIR = ".credentials"
24
+
25
+ # In-memory cache for session credentials, maps session_id to Credentials object
26
+ # This is brittle and bad, but our options are limited with Claude in present state.
27
+ # This should be more robust in a production system once OAuth2.1 is implemented in client.
28
+ _SESSION_CREDENTIALS_CACHE: Dict[str, Credentials] = {}
29
+ # Centralized Client Secrets Path Logic
30
+ _client_secrets_env = os.getenv("GOOGLE_CLIENT_SECRETS")
31
+ if _client_secrets_env:
32
+ CONFIG_CLIENT_SECRETS_PATH = _client_secrets_env
33
+ else:
34
+ # Assumes this file is in auth/ and client_secret.json is in the root
35
+ CONFIG_CLIENT_SECRETS_PATH = os.path.join(
36
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
37
+ 'client_secret.json'
38
+ )
39
+
40
+ # --- Helper Functions ---
41
+
42
+ def _find_any_credentials(base_dir: str = DEFAULT_CREDENTIALS_DIR) -> Optional[Credentials]:
43
+ """
44
+ Find and load any valid credentials from the credentials directory.
45
+ Used in single-user mode to bypass session-to-OAuth mapping.
46
+
47
+ Returns:
48
+ First valid Credentials object found, or None if none exist.
49
+ """
50
+ if not os.path.exists(base_dir):
51
+ logger.info(f"[single-user] Credentials directory not found: {base_dir}")
52
+ return None
53
+
54
+ # Scan for any .json credential files
55
+ for filename in os.listdir(base_dir):
56
+ if filename.endswith('.json'):
57
+ filepath = os.path.join(base_dir, filename)
58
+ try:
59
+ with open(filepath, 'r') as f:
60
+ creds_data = json.load(f)
61
+ credentials = Credentials(
62
+ token=creds_data.get('token'),
63
+ refresh_token=creds_data.get('refresh_token'),
64
+ token_uri=creds_data.get('token_uri'),
65
+ client_id=creds_data.get('client_id'),
66
+ client_secret=creds_data.get('client_secret'),
67
+ scopes=creds_data.get('scopes')
68
+ )
69
+ logger.info(f"[single-user] Found credentials in {filepath}")
70
+ return credentials
71
+ except (IOError, json.JSONDecodeError, KeyError) as e:
72
+ logger.warning(f"[single-user] Error loading credentials from {filepath}: {e}")
73
+ continue
74
+
75
+ logger.info(f"[single-user] No valid credentials found in {base_dir}")
76
+ return None
77
+
78
+ def _get_user_credential_path(user_google_email: str, base_dir: str = DEFAULT_CREDENTIALS_DIR) -> str:
79
+ """Constructs the path to a user's credential file."""
80
+ if not os.path.exists(base_dir):
81
+ os.makedirs(base_dir)
82
+ logger.info(f"Created credentials directory: {base_dir}")
83
+ return os.path.join(base_dir, f"{user_google_email}.json")
84
+
85
+ def save_credentials_to_file(user_google_email: str, credentials: Credentials, base_dir: str = DEFAULT_CREDENTIALS_DIR):
86
+ """Saves user credentials to a file."""
87
+ creds_path = _get_user_credential_path(user_google_email, base_dir)
88
+ creds_data = {
89
+ 'token': credentials.token,
90
+ 'refresh_token': credentials.refresh_token,
91
+ 'token_uri': credentials.token_uri,
92
+ 'client_id': credentials.client_id,
93
+ 'client_secret': credentials.client_secret,
94
+ 'scopes': credentials.scopes,
95
+ 'expiry': credentials.expiry.isoformat() if credentials.expiry else None
96
+ }
97
+ try:
98
+ with open(creds_path, 'w') as f:
99
+ json.dump(creds_data, f)
100
+ logger.info(f"Credentials saved for user {user_google_email} to {creds_path}")
101
+ except IOError as e:
102
+ logger.error(f"Error saving credentials for user {user_google_email} to {creds_path}: {e}")
103
+ raise
104
+
105
+ def save_credentials_to_session(session_id: str, credentials: Credentials):
106
+ """Saves user credentials to the in-memory session cache."""
107
+ _SESSION_CREDENTIALS_CACHE[session_id] = credentials
108
+ logger.debug(f"Credentials saved to session cache for session_id: {session_id}")
109
+
110
+ def load_credentials_from_file(user_google_email: str, base_dir: str = DEFAULT_CREDENTIALS_DIR) -> Optional[Credentials]:
111
+ """Loads user credentials from a file."""
112
+ creds_path = _get_user_credential_path(user_google_email, base_dir)
113
+ if not os.path.exists(creds_path):
114
+ logger.info(f"No credentials file found for user {user_google_email} at {creds_path}")
115
+ return None
116
+
117
+ try:
118
+ with open(creds_path, 'r') as f:
119
+ creds_data = json.load(f)
120
+
121
+ # Parse expiry if present
122
+ expiry = None
123
+ if creds_data.get('expiry'):
124
+ try:
125
+ from datetime import datetime
126
+ expiry = datetime.fromisoformat(creds_data['expiry'])
127
+ except (ValueError, TypeError) as e:
128
+ logger.warning(f"Could not parse expiry time for {user_google_email}: {e}")
129
+
130
+ credentials = Credentials(
131
+ token=creds_data.get('token'),
132
+ refresh_token=creds_data.get('refresh_token'),
133
+ token_uri=creds_data.get('token_uri'),
134
+ client_id=creds_data.get('client_id'),
135
+ client_secret=creds_data.get('client_secret'),
136
+ scopes=creds_data.get('scopes'),
137
+ expiry=expiry
138
+ )
139
+ logger.debug(f"Credentials loaded for user {user_google_email} from {creds_path}")
140
+ return credentials
141
+ except (IOError, json.JSONDecodeError, KeyError) as e:
142
+ logger.error(f"Error loading or parsing credentials for user {user_google_email} from {creds_path}: {e}")
143
+ return None
144
+
145
+ def load_credentials_from_session(session_id: str) -> Optional[Credentials]:
146
+ """Loads user credentials from the in-memory session cache."""
147
+ credentials = _SESSION_CREDENTIALS_CACHE.get(session_id)
148
+ if credentials:
149
+ logger.debug(f"Credentials loaded from session cache for session_id: {session_id}")
150
+ else:
151
+ logger.debug(f"No credentials found in session cache for session_id: {session_id}")
152
+ return credentials
153
+
154
+ def load_client_secrets(client_secrets_path: str) -> Dict[str, Any]:
155
+ """Loads the client secrets file."""
156
+ try:
157
+ with open(client_secrets_path, 'r') as f:
158
+ client_config = json.load(f)
159
+ # The file usually contains a top-level key like "web" or "installed"
160
+ if "web" in client_config:
161
+ return client_config["web"]
162
+ elif "installed" in client_config:
163
+ return client_config["installed"]
164
+ else:
165
+ logger.error(f"Client secrets file {client_secrets_path} has unexpected format.")
166
+ raise ValueError("Invalid client secrets file format")
167
+ except (IOError, json.JSONDecodeError) as e:
168
+ logger.error(f"Error loading client secrets file {client_secrets_path}: {e}")
169
+ raise
170
+
171
+ # --- Core OAuth Logic ---
172
+
173
+ async def start_auth_flow(
174
+ mcp_session_id: Optional[str],
175
+ user_google_email: Optional[str],
176
+ service_name: str, # e.g., "Google Calendar", "Gmail" for user messages
177
+ redirect_uri: str, # Added redirect_uri as a required parameter
178
+ ) -> str:
179
+ """
180
+ Initiates the Google OAuth flow and returns an actionable message for the user.
181
+
182
+ Args:
183
+ mcp_session_id: The active MCP session ID.
184
+ user_google_email: The user's specified Google email, if provided.
185
+ service_name: The name of the Google service requiring auth (for user messages).
186
+ redirect_uri: The URI Google will redirect to after authorization.
187
+
188
+ Returns:
189
+ A formatted string containing guidance for the LLM/user.
190
+
191
+ Raises:
192
+ Exception: If the OAuth flow cannot be initiated.
193
+ """
194
+ initial_email_provided = bool(user_google_email and user_google_email.strip() and user_google_email.lower() != 'default')
195
+ user_display_name = f"{service_name} for '{user_google_email}'" if initial_email_provided else service_name
196
+
197
+ logger.info(f"[start_auth_flow] Initiating auth for {user_display_name} (session: {mcp_session_id}) with global SCOPES.")
198
+
199
+ try:
200
+ if 'OAUTHLIB_INSECURE_TRANSPORT' not in os.environ and ("localhost" in redirect_uri or "127.0.0.1" in redirect_uri): # Use passed redirect_uri
201
+ logger.warning("OAUTHLIB_INSECURE_TRANSPORT not set. Setting it for localhost/local development.")
202
+ os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1'
203
+
204
+ oauth_state = os.urandom(16).hex()
205
+ if mcp_session_id:
206
+ OAUTH_STATE_TO_SESSION_ID_MAP[oauth_state] = mcp_session_id
207
+ logger.info(f"[start_auth_flow] Stored mcp_session_id '{mcp_session_id}' for oauth_state '{oauth_state}'.")
208
+
209
+ flow = Flow.from_client_secrets_file(
210
+ CONFIG_CLIENT_SECRETS_PATH, # Use module constant
211
+ scopes=SCOPES, # Use global SCOPES
212
+ redirect_uri=redirect_uri, # Use passed redirect_uri
213
+ state=oauth_state
214
+ )
215
+
216
+ auth_url, _ = flow.authorization_url(access_type='offline', prompt='consent')
217
+ logger.info(f"Auth flow started for {user_display_name}. State: {oauth_state}. Advise user to visit: {auth_url}")
218
+
219
+ message_lines = [
220
+ f"**ACTION REQUIRED: Google Authentication Needed for {user_display_name}**\n",
221
+ f"To proceed, the user must authorize this application for {service_name} access using all required permissions.",
222
+ "**LLM, please present this exact authorization URL to the user as a clickable hyperlink:**",
223
+ f"Authorization URL: {auth_url}",
224
+ f"Markdown for hyperlink: [Click here to authorize {service_name} access]({auth_url})\n",
225
+ "**LLM, after presenting the link, instruct the user as follows:**",
226
+ "1. Click the link and complete the authorization in their browser.",
227
+ ]
228
+ session_info_for_llm = f" (this will link to your current session {mcp_session_id})" if mcp_session_id else ""
229
+
230
+ if not initial_email_provided:
231
+ message_lines.extend([
232
+ f"2. After successful authorization{session_info_for_llm}, the browser page will display the authenticated email address.",
233
+ " **LLM: Instruct the user to provide you with this email address.**",
234
+ "3. Once you have the email, **retry their original command, ensuring you include this `user_google_email`.**"
235
+ ])
236
+ else:
237
+ message_lines.append(f"2. After successful authorization{session_info_for_llm}, **retry their original command**.")
238
+
239
+ message_lines.append(f"\nThe application will use the new credentials. If '{user_google_email}' was provided, it must match the authenticated account.")
240
+ return "\n".join(message_lines)
241
+
242
+ except FileNotFoundError as e:
243
+ error_text = f"OAuth client secrets file not found: {e}. Please ensure '{CONFIG_CLIENT_SECRETS_PATH}' is correctly configured."
244
+ logger.error(error_text, exc_info=True)
245
+ raise Exception(error_text)
246
+ except Exception as e:
247
+ error_text = f"Could not initiate authentication for {user_display_name} due to an unexpected error: {str(e)}"
248
+ logger.error(f"Failed to start the OAuth flow for {user_display_name}: {e}", exc_info=True)
249
+ raise Exception(error_text)
250
+
251
+ def handle_auth_callback(
252
+ client_secrets_path: str,
253
+ scopes: List[str],
254
+ authorization_response: str,
255
+ redirect_uri: str, # Made redirect_uri a required parameter
256
+ credentials_base_dir: str = DEFAULT_CREDENTIALS_DIR,
257
+ session_id: Optional[str] = None
258
+ ) -> Tuple[str, Credentials]:
259
+ """
260
+ Handles the callback from Google, exchanges the code for credentials,
261
+ fetches user info, determines user_google_email, saves credentials (file & session),
262
+ and returns them.
263
+
264
+ Args:
265
+ client_secrets_path: Path to the Google client secrets JSON file.
266
+ scopes: List of OAuth scopes requested.
267
+ authorization_response: The full callback URL from Google.
268
+ redirect_uri: The redirect URI.
269
+ credentials_base_dir: Base directory for credential files.
270
+ session_id: Optional MCP session ID to associate with the credentials.
271
+
272
+ Returns:
273
+ A tuple containing the user_google_email and the obtained Credentials object.
274
+
275
+ Raises:
276
+ ValueError: If the state is missing or doesn't match.
277
+ FlowExchangeError: If the code exchange fails.
278
+ HttpError: If fetching user info fails.
279
+ """
280
+ try:
281
+ # Allow HTTP for localhost in development
282
+ if 'OAUTHLIB_INSECURE_TRANSPORT' not in os.environ:
283
+ logger.warning("OAUTHLIB_INSECURE_TRANSPORT not set. Setting it for localhost development.")
284
+ os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1'
285
+
286
+ flow = Flow.from_client_secrets_file(
287
+ client_secrets_path,
288
+ scopes=scopes,
289
+ redirect_uri=redirect_uri
290
+ )
291
+
292
+ # Exchange the authorization code for credentials
293
+ # Note: fetch_token will use the redirect_uri configured in the flow
294
+ flow.fetch_token(authorization_response=authorization_response)
295
+ credentials = flow.credentials
296
+ logger.info("Successfully exchanged authorization code for tokens.")
297
+
298
+ # Get user info to determine user_id (using email here)
299
+ user_info = get_user_info(credentials)
300
+ if not user_info or 'email' not in user_info:
301
+ logger.error("Could not retrieve user email from Google.")
302
+ raise ValueError("Failed to get user email for identification.")
303
+
304
+ user_google_email = user_info['email']
305
+ logger.info(f"Identified user_google_email: {user_google_email}")
306
+
307
+ # Save the credentials to file
308
+ save_credentials_to_file(user_google_email, credentials, credentials_base_dir)
309
+
310
+ # If session_id is provided, also save to session cache
311
+ if session_id:
312
+ save_credentials_to_session(session_id, credentials)
313
+
314
+ return user_google_email, credentials
315
+
316
+ except Exception as e: # Catch specific exceptions like FlowExchangeError if needed
317
+ logger.error(f"Error handling auth callback: {e}")
318
+ raise # Re-raise for the caller
319
+
320
+ def get_credentials(
321
+ user_google_email: Optional[str], # Can be None if relying on session_id
322
+ required_scopes: List[str],
323
+ client_secrets_path: Optional[str] = None,
324
+ credentials_base_dir: str = DEFAULT_CREDENTIALS_DIR,
325
+ session_id: Optional[str] = None
326
+ ) -> Optional[Credentials]:
327
+ """
328
+ Retrieves stored credentials, prioritizing session, then file. Refreshes if necessary.
329
+ If credentials are loaded from file and a session_id is present, they are cached in the session.
330
+ In single-user mode, bypasses session mapping and uses any available credentials.
331
+
332
+ Args:
333
+ user_google_email: Optional user's Google email.
334
+ required_scopes: List of scopes the credentials must have.
335
+ client_secrets_path: Path to client secrets, required for refresh if not in creds.
336
+ credentials_base_dir: Base directory for credential files.
337
+ session_id: Optional MCP session ID.
338
+
339
+ Returns:
340
+ Valid Credentials object or None.
341
+ """
342
+ # Check for single-user mode
343
+ if os.getenv('MCP_SINGLE_USER_MODE') == '1':
344
+ logger.info(f"[get_credentials] Single-user mode: bypassing session mapping, finding any credentials")
345
+ credentials = _find_any_credentials(credentials_base_dir)
346
+ if not credentials:
347
+ logger.info(f"[get_credentials] Single-user mode: No credentials found in {credentials_base_dir}")
348
+ return None
349
+
350
+ # In single-user mode, if user_google_email wasn't provided, try to get it from user info
351
+ # This is needed for proper credential saving after refresh
352
+ if not user_google_email and credentials.valid:
353
+ try:
354
+ user_info = get_user_info(credentials)
355
+ if user_info and 'email' in user_info:
356
+ user_google_email = user_info['email']
357
+ logger.debug(f"[get_credentials] Single-user mode: extracted user email {user_google_email} from credentials")
358
+ except Exception as e:
359
+ logger.debug(f"[get_credentials] Single-user mode: could not extract user email: {e}")
360
+ else:
361
+ credentials: Optional[Credentials] = None
362
+
363
+ # Session ID should be provided by the caller
364
+ if not session_id:
365
+ logger.debug("[get_credentials] No session_id provided")
366
+
367
+ logger.debug(f"[get_credentials] Called for user_google_email: '{user_google_email}', session_id: '{session_id}', required_scopes: {required_scopes}")
368
+
369
+ if session_id:
370
+ credentials = load_credentials_from_session(session_id)
371
+ if credentials:
372
+ logger.debug(f"[get_credentials] Loaded credentials from session for session_id '{session_id}'.")
373
+
374
+ if not credentials and user_google_email:
375
+ logger.debug(f"[get_credentials] No session credentials, trying file for user_google_email '{user_google_email}'.")
376
+ credentials = load_credentials_from_file(user_google_email, credentials_base_dir)
377
+ if credentials and session_id:
378
+ logger.debug(f"[get_credentials] Loaded from file for user '{user_google_email}', caching to session '{session_id}'.")
379
+ save_credentials_to_session(session_id, credentials) # Cache for current session
380
+
381
+ if not credentials:
382
+ logger.info(f"[get_credentials] No credentials found for user '{user_google_email}' or session '{session_id}'.")
383
+ return None
384
+
385
+ logger.debug(f"[get_credentials] Credentials found. Scopes: {credentials.scopes}, Valid: {credentials.valid}, Expired: {credentials.expired}")
386
+
387
+ if not all(scope in credentials.scopes for scope in required_scopes):
388
+ logger.warning(f"[get_credentials] Credentials lack required scopes. Need: {required_scopes}, Have: {credentials.scopes}. User: '{user_google_email}', Session: '{session_id}'")
389
+ return None # Re-authentication needed for scopes
390
+
391
+ logger.debug(f"[get_credentials] Credentials have sufficient scopes. User: '{user_google_email}', Session: '{session_id}'")
392
+
393
+ if credentials.valid:
394
+ logger.debug(f"[get_credentials] Credentials are valid. User: '{user_google_email}', Session: '{session_id}'")
395
+ return credentials
396
+ elif credentials.expired and credentials.refresh_token:
397
+ logger.info(f"[get_credentials] Credentials expired. Attempting refresh. User: '{user_google_email}', Session: '{session_id}'")
398
+ if not client_secrets_path:
399
+ logger.error("[get_credentials] Client secrets path required for refresh but not provided.")
400
+ return None
401
+ try:
402
+ logger.debug(f"[get_credentials] Refreshing token using client_secrets_path: {client_secrets_path}")
403
+ # client_config = load_client_secrets(client_secrets_path) # Not strictly needed if creds have client_id/secret
404
+ credentials.refresh(Request())
405
+ logger.info(f"[get_credentials] Credentials refreshed successfully. User: '{user_google_email}', Session: '{session_id}'")
406
+
407
+ # Save refreshed credentials
408
+ if user_google_email: # Always save to file if email is known
409
+ save_credentials_to_file(user_google_email, credentials, credentials_base_dir)
410
+ if session_id: # Update session cache if it was the source or is active
411
+ save_credentials_to_session(session_id, credentials)
412
+ return credentials
413
+ except RefreshError as e:
414
+ logger.warning(f"[get_credentials] RefreshError - token expired/revoked: {e}. User: '{user_google_email}', Session: '{session_id}'")
415
+ # For RefreshError, we should return None to trigger reauthentication
416
+ return None
417
+ except Exception as e:
418
+ logger.error(f"[get_credentials] Error refreshing credentials: {e}. User: '{user_google_email}', Session: '{session_id}'", exc_info=True)
419
+ return None # Failed to refresh
420
+ else:
421
+ logger.warning(f"[get_credentials] Credentials invalid/cannot refresh. Valid: {credentials.valid}, Refresh Token: {credentials.refresh_token is not None}. User: '{user_google_email}', Session: '{session_id}'")
422
+ return None
423
+
424
+
425
+ def get_user_info(credentials: Credentials) -> Optional[Dict[str, Any]]:
426
+ """Fetches basic user profile information (requires userinfo.email scope)."""
427
+ if not credentials or not credentials.valid:
428
+ logger.error("Cannot get user info: Invalid or missing credentials.")
429
+ return None
430
+ try:
431
+ # Using googleapiclient discovery to get user info
432
+ # Requires 'google-api-python-client' library
433
+ service = build('oauth2', 'v2', credentials=credentials)
434
+ user_info = service.userinfo().get().execute()
435
+ logger.info(f"Successfully fetched user info: {user_info.get('email')}")
436
+ return user_info
437
+ except HttpError as e:
438
+ logger.error(f"HttpError fetching user info: {e.status_code} {e.reason}")
439
+ # Handle specific errors, e.g., 401 Unauthorized might mean token issue
440
+ return None
441
+ except Exception as e:
442
+ logger.error(f"Unexpected error fetching user info: {e}")
443
+ return None
444
+
445
+
446
+ # --- Centralized Google Service Authentication ---
447
+
448
+ class GoogleAuthenticationError(Exception):
449
+ """Exception raised when Google authentication is required or fails."""
450
+ def __init__(self, message: str, auth_url: Optional[str] = None):
451
+ super().__init__(message)
452
+ self.auth_url = auth_url
453
+
454
+
455
+ async def get_authenticated_google_service(
456
+ service_name: str, # "gmail", "calendar", "drive", "docs"
457
+ version: str, # "v1", "v3"
458
+ tool_name: str, # For logging/debugging
459
+ user_google_email: str, # Required - no more Optional
460
+ required_scopes: List[str],
461
+ ) -> tuple[Any, str]:
462
+ """
463
+ Centralized Google service authentication for all MCP tools.
464
+ Returns (service, user_email) on success or raises GoogleAuthenticationError.
465
+
466
+ Args:
467
+ service_name: The Google service name ("gmail", "calendar", "drive", "docs")
468
+ version: The API version ("v1", "v3", etc.)
469
+ tool_name: The name of the calling tool (for logging/debugging)
470
+ user_google_email: The user's Google email address (required)
471
+ required_scopes: List of required OAuth scopes
472
+
473
+ Returns:
474
+ tuple[service, user_email] on success
475
+
476
+ Raises:
477
+ GoogleAuthenticationError: When authentication is required or fails
478
+ """
479
+ logger.info(
480
+ f"[{tool_name}] Attempting to get authenticated {service_name} service. Email: '{user_google_email}'"
481
+ )
482
+
483
+ # Validate email format
484
+ if not user_google_email or "@" not in user_google_email:
485
+ error_msg = f"Authentication required for {tool_name}. No valid 'user_google_email' provided. Please provide a valid Google email address."
486
+ logger.info(f"[{tool_name}] {error_msg}")
487
+ raise GoogleAuthenticationError(error_msg)
488
+
489
+ credentials = await asyncio.to_thread(
490
+ get_credentials,
491
+ user_google_email=user_google_email,
492
+ required_scopes=required_scopes,
493
+ client_secrets_path=CONFIG_CLIENT_SECRETS_PATH,
494
+ session_id=None, # Session ID not available in service layer
495
+ )
496
+
497
+
498
+ if not credentials or not credentials.valid:
499
+ logger.warning(
500
+ f"[{tool_name}] No valid credentials. Email: '{user_google_email}'."
501
+ )
502
+ logger.info(
503
+ f"[{tool_name}] Valid email '{user_google_email}' provided, initiating auth flow."
504
+ )
505
+
506
+ # Import here to avoid circular import
507
+ from core.server import get_oauth_redirect_uri_for_current_mode
508
+
509
+ # Ensure OAuth callback is available
510
+ redirect_uri = get_oauth_redirect_uri_for_current_mode()
511
+ # Note: We don't know the transport mode here, but the server should have set it
512
+
513
+ # Generate auth URL and raise exception with it
514
+ auth_response = await start_auth_flow(
515
+ mcp_session_id=None, # Session ID not available in service layer
516
+ user_google_email=user_google_email,
517
+ service_name=f"Google {service_name.title()}",
518
+ redirect_uri=redirect_uri,
519
+ )
520
+
521
+ # Extract the auth URL from the response and raise with it
522
+ raise GoogleAuthenticationError(auth_response)
523
+
524
+ try:
525
+ service = build(service_name, version, credentials=credentials)
526
+ log_user_email = user_google_email
527
+
528
+ # Try to get email from credentials if needed for validation
529
+ if credentials and credentials.id_token:
530
+ try:
531
+ import jwt
532
+ # Decode without verification (just to get email for logging)
533
+ decoded_token = jwt.decode(credentials.id_token, options={"verify_signature": False})
534
+ token_email = decoded_token.get("email")
535
+ if token_email:
536
+ log_user_email = token_email
537
+ logger.info(f"[{tool_name}] Token email: {token_email}")
538
+ except Exception as e:
539
+ logger.debug(f"[{tool_name}] Could not decode id_token: {e}")
540
+
541
+ logger.info(f"[{tool_name}] Successfully authenticated {service_name} service for user: {log_user_email}")
542
+ return service, log_user_email
543
+
544
+ except Exception as e:
545
+ error_msg = f"[{tool_name}] Failed to build {service_name} service: {str(e)}"
546
+ logger.error(error_msg, exc_info=True)
547
+ raise GoogleAuthenticationError(error_msg)
548
+
549
+