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.
@@ -0,0 +1,404 @@
1
+ import inspect
2
+ import logging
3
+ from functools import wraps
4
+ from typing import Dict, List, Optional, Any, Callable, Union
5
+ from datetime import datetime, timedelta
6
+
7
+ from google.auth.exceptions import RefreshError
8
+ from auth.google_auth import get_authenticated_google_service, GoogleAuthenticationError
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Import scope constants
13
+ from auth.scopes import (
14
+ GMAIL_READONLY_SCOPE, GMAIL_SEND_SCOPE, GMAIL_COMPOSE_SCOPE, GMAIL_MODIFY_SCOPE, GMAIL_LABELS_SCOPE,
15
+ DRIVE_READONLY_SCOPE, DRIVE_FILE_SCOPE,
16
+ DOCS_READONLY_SCOPE, DOCS_WRITE_SCOPE,
17
+ CALENDAR_READONLY_SCOPE, CALENDAR_EVENTS_SCOPE,
18
+ SHEETS_READONLY_SCOPE, SHEETS_WRITE_SCOPE,
19
+ CHAT_READONLY_SCOPE, CHAT_WRITE_SCOPE, CHAT_SPACES_SCOPE,
20
+ FORMS_BODY_SCOPE, FORMS_BODY_READONLY_SCOPE, FORMS_RESPONSES_READONLY_SCOPE,
21
+ SLIDES_SCOPE, SLIDES_READONLY_SCOPE
22
+ )
23
+
24
+ # Service configuration mapping
25
+ SERVICE_CONFIGS = {
26
+ "gmail": {"service": "gmail", "version": "v1"},
27
+ "drive": {"service": "drive", "version": "v3"},
28
+ "calendar": {"service": "calendar", "version": "v3"},
29
+ "docs": {"service": "docs", "version": "v1"},
30
+ "sheets": {"service": "sheets", "version": "v4"},
31
+ "chat": {"service": "chat", "version": "v1"},
32
+ "forms": {"service": "forms", "version": "v1"},
33
+ "slides": {"service": "slides", "version": "v1"}
34
+ }
35
+
36
+
37
+ # Scope group definitions for easy reference
38
+ SCOPE_GROUPS = {
39
+ # Gmail scopes
40
+ "gmail_read": GMAIL_READONLY_SCOPE,
41
+ "gmail_send": GMAIL_SEND_SCOPE,
42
+ "gmail_compose": GMAIL_COMPOSE_SCOPE,
43
+ "gmail_modify": GMAIL_MODIFY_SCOPE,
44
+ "gmail_labels": GMAIL_LABELS_SCOPE,
45
+
46
+ # Drive scopes
47
+ "drive_read": DRIVE_READONLY_SCOPE,
48
+ "drive_file": DRIVE_FILE_SCOPE,
49
+
50
+ # Docs scopes
51
+ "docs_read": DOCS_READONLY_SCOPE,
52
+ "docs_write": DOCS_WRITE_SCOPE,
53
+
54
+ # Calendar scopes
55
+ "calendar_read": CALENDAR_READONLY_SCOPE,
56
+ "calendar_events": CALENDAR_EVENTS_SCOPE,
57
+
58
+ # Sheets scopes
59
+ "sheets_read": SHEETS_READONLY_SCOPE,
60
+ "sheets_write": SHEETS_WRITE_SCOPE,
61
+
62
+ # Chat scopes
63
+ "chat_read": CHAT_READONLY_SCOPE,
64
+ "chat_write": CHAT_WRITE_SCOPE,
65
+ "chat_spaces": CHAT_SPACES_SCOPE,
66
+
67
+ # Forms scopes
68
+ "forms": FORMS_BODY_SCOPE,
69
+ "forms_read": FORMS_BODY_READONLY_SCOPE,
70
+ "forms_responses_read": FORMS_RESPONSES_READONLY_SCOPE,
71
+
72
+ # Slides scopes
73
+ "slides": SLIDES_SCOPE,
74
+ "slides_read": SLIDES_READONLY_SCOPE,
75
+ }
76
+
77
+ # Service cache: {cache_key: (service, cached_time, user_email)}
78
+ _service_cache: Dict[str, tuple[Any, datetime, str]] = {}
79
+ _cache_ttl = timedelta(minutes=30) # Cache services for 30 minutes
80
+
81
+
82
+ def _get_cache_key(user_email: str, service_name: str, version: str, scopes: List[str]) -> str:
83
+ """Generate a cache key for service instances."""
84
+ sorted_scopes = sorted(scopes)
85
+ return f"{user_email}:{service_name}:{version}:{':'.join(sorted_scopes)}"
86
+
87
+
88
+ def _is_cache_valid(cached_time: datetime) -> bool:
89
+ """Check if cached service is still valid."""
90
+ return datetime.now() - cached_time < _cache_ttl
91
+
92
+
93
+ def _get_cached_service(cache_key: str) -> Optional[tuple[Any, str]]:
94
+ """Retrieve cached service if valid."""
95
+ if cache_key in _service_cache:
96
+ service, cached_time, user_email = _service_cache[cache_key]
97
+ if _is_cache_valid(cached_time):
98
+ logger.debug(f"Using cached service for key: {cache_key}")
99
+ return service, user_email
100
+ else:
101
+ # Remove expired cache entry
102
+ del _service_cache[cache_key]
103
+ logger.debug(f"Removed expired cache entry: {cache_key}")
104
+ return None
105
+
106
+
107
+ def _cache_service(cache_key: str, service: Any, user_email: str) -> None:
108
+ """Cache a service instance."""
109
+ _service_cache[cache_key] = (service, datetime.now(), user_email)
110
+ logger.debug(f"Cached service for key: {cache_key}")
111
+
112
+
113
+ def _resolve_scopes(scopes: Union[str, List[str]]) -> List[str]:
114
+ """Resolve scope names to actual scope URLs."""
115
+ if isinstance(scopes, str):
116
+ if scopes in SCOPE_GROUPS:
117
+ return [SCOPE_GROUPS[scopes]]
118
+ else:
119
+ return [scopes]
120
+
121
+ resolved = []
122
+ for scope in scopes:
123
+ if scope in SCOPE_GROUPS:
124
+ resolved.append(SCOPE_GROUPS[scope])
125
+ else:
126
+ resolved.append(scope)
127
+ return resolved
128
+
129
+
130
+ def _handle_token_refresh_error(error: RefreshError, user_email: str, service_name: str) -> str:
131
+ """
132
+ Handle token refresh errors gracefully, particularly expired/revoked tokens.
133
+
134
+ Args:
135
+ error: The RefreshError that occurred
136
+ user_email: User's email address
137
+ service_name: Name of the Google service
138
+
139
+ Returns:
140
+ A user-friendly error message with instructions for reauthentication
141
+ """
142
+ error_str = str(error)
143
+
144
+ if 'invalid_grant' in error_str.lower() or 'expired or revoked' in error_str.lower():
145
+ logger.warning(f"Token expired or revoked for user {user_email} accessing {service_name}")
146
+
147
+ # Clear any cached service for this user to force fresh authentication
148
+ clear_service_cache(user_email)
149
+
150
+ service_display_name = f"Google {service_name.title()}"
151
+
152
+ return (
153
+ f"**Authentication Required: Token Expired/Revoked for {service_display_name}**\n\n"
154
+ f"Your Google authentication token for {user_email} has expired or been revoked. "
155
+ f"This commonly happens when:\n"
156
+ f"- The token has been unused for an extended period\n"
157
+ f"- You've changed your Google account password\n"
158
+ f"- You've revoked access to the application\n\n"
159
+ f"**To resolve this, please:**\n"
160
+ f"1. Run `start_google_auth` with your email ({user_email}) and service_name='{service_display_name}'\n"
161
+ f"2. Complete the authentication flow in your browser\n"
162
+ f"3. Retry your original command\n\n"
163
+ f"The application will automatically use the new credentials once authentication is complete."
164
+ )
165
+ else:
166
+ # Handle other types of refresh errors
167
+ logger.error(f"Unexpected refresh error for user {user_email}: {error}")
168
+ return (
169
+ f"Authentication error occurred for {user_email}. "
170
+ f"Please try running `start_google_auth` with your email and the appropriate service name to reauthenticate."
171
+ )
172
+
173
+
174
+ def require_google_service(
175
+ service_type: str,
176
+ scopes: Union[str, List[str]],
177
+ version: Optional[str] = None,
178
+ cache_enabled: bool = True
179
+ ):
180
+ """
181
+ Decorator that automatically handles Google service authentication and injection.
182
+
183
+ Args:
184
+ service_type: Type of Google service ("gmail", "drive", "calendar", etc.)
185
+ scopes: Required scopes (can be scope group names or actual URLs)
186
+ version: Service version (defaults to standard version for service type)
187
+ cache_enabled: Whether to use service caching (default: True)
188
+
189
+ Usage:
190
+ @require_google_service("gmail", "gmail_read")
191
+ async def search_messages(service, user_google_email: str, query: str):
192
+ # service parameter is automatically injected
193
+ # Original authentication logic is handled automatically
194
+ """
195
+ def decorator(func: Callable) -> Callable:
196
+ @wraps(func)
197
+ async def wrapper(*args, **kwargs):
198
+ # Extract user_google_email from function parameters
199
+ sig = inspect.signature(func)
200
+ param_names = list(sig.parameters.keys())
201
+
202
+ # Find user_google_email parameter
203
+ user_google_email = None
204
+ if 'user_google_email' in kwargs:
205
+ user_google_email = kwargs['user_google_email']
206
+ else:
207
+ # Look for user_google_email in positional args
208
+ try:
209
+ user_email_index = param_names.index('user_google_email')
210
+ if user_email_index < len(args):
211
+ user_google_email = args[user_email_index]
212
+ except ValueError:
213
+ pass
214
+
215
+ if not user_google_email:
216
+ raise Exception("user_google_email parameter is required but not found")
217
+
218
+ # Get service configuration
219
+ if service_type not in SERVICE_CONFIGS:
220
+ raise Exception(f"Unknown service type: {service_type}")
221
+
222
+ config = SERVICE_CONFIGS[service_type]
223
+ service_name = config["service"]
224
+ service_version = version or config["version"]
225
+
226
+ # Resolve scopes
227
+ resolved_scopes = _resolve_scopes(scopes)
228
+
229
+ # Check cache first if enabled
230
+ service = None
231
+ actual_user_email = user_google_email
232
+
233
+ if cache_enabled:
234
+ cache_key = _get_cache_key(user_google_email, service_name, service_version, resolved_scopes)
235
+ cached_result = _get_cached_service(cache_key)
236
+ if cached_result:
237
+ service, actual_user_email = cached_result
238
+
239
+ # If not cached, authenticate
240
+ if service is None:
241
+ try:
242
+ tool_name = func.__name__
243
+ service, actual_user_email = await get_authenticated_google_service(
244
+ service_name=service_name,
245
+ version=service_version,
246
+ tool_name=tool_name,
247
+ user_google_email=user_google_email,
248
+ required_scopes=resolved_scopes,
249
+ )
250
+
251
+ # Cache the service if caching is enabled
252
+ if cache_enabled:
253
+ cache_key = _get_cache_key(user_google_email, service_name, service_version, resolved_scopes)
254
+ _cache_service(cache_key, service, actual_user_email)
255
+
256
+ except GoogleAuthenticationError as e:
257
+ raise Exception(str(e))
258
+
259
+ # Inject service as first parameter
260
+ if 'service' in param_names:
261
+ kwargs['service'] = service
262
+ else:
263
+ # Insert service as first positional argument
264
+ args = (service,) + args
265
+
266
+ # Call the original function with refresh error handling
267
+ try:
268
+ return await func(*args, **kwargs)
269
+ except RefreshError as e:
270
+ # Handle token refresh errors gracefully
271
+ error_message = _handle_token_refresh_error(e, actual_user_email, service_name)
272
+ raise Exception(error_message)
273
+
274
+ return wrapper
275
+ return decorator
276
+
277
+
278
+ def require_multiple_services(service_configs: List[Dict[str, Any]]):
279
+ """
280
+ Decorator for functions that need multiple Google services.
281
+
282
+ Args:
283
+ service_configs: List of service configurations, each containing:
284
+ - service_type: Type of service
285
+ - scopes: Required scopes
286
+ - param_name: Name to inject service as (e.g., 'drive_service', 'docs_service')
287
+ - version: Optional version override
288
+
289
+ Usage:
290
+ @require_multiple_services([
291
+ {"service_type": "drive", "scopes": "drive_read", "param_name": "drive_service"},
292
+ {"service_type": "docs", "scopes": "docs_read", "param_name": "docs_service"}
293
+ ])
294
+ async def get_doc_with_metadata(drive_service, docs_service, user_google_email: str, doc_id: str):
295
+ # Both services are automatically injected
296
+ """
297
+ def decorator(func: Callable) -> Callable:
298
+ @wraps(func)
299
+ async def wrapper(*args, **kwargs):
300
+ # Extract user_google_email
301
+ sig = inspect.signature(func)
302
+ param_names = list(sig.parameters.keys())
303
+
304
+ user_google_email = None
305
+ if 'user_google_email' in kwargs:
306
+ user_google_email = kwargs['user_google_email']
307
+ else:
308
+ try:
309
+ user_email_index = param_names.index('user_google_email')
310
+ if user_email_index < len(args):
311
+ user_google_email = args[user_email_index]
312
+ except ValueError:
313
+ pass
314
+
315
+ if not user_google_email:
316
+ raise Exception("user_google_email parameter is required but not found")
317
+
318
+ # Authenticate all services
319
+ for config in service_configs:
320
+ service_type = config["service_type"]
321
+ scopes = config["scopes"]
322
+ param_name = config["param_name"]
323
+ version = config.get("version")
324
+
325
+ if service_type not in SERVICE_CONFIGS:
326
+ raise Exception(f"Unknown service type: {service_type}")
327
+
328
+ service_config = SERVICE_CONFIGS[service_type]
329
+ service_name = service_config["service"]
330
+ service_version = version or service_config["version"]
331
+ resolved_scopes = _resolve_scopes(scopes)
332
+
333
+ try:
334
+ tool_name = func.__name__
335
+ service, _ = await get_authenticated_google_service(
336
+ service_name=service_name,
337
+ version=service_version,
338
+ tool_name=tool_name,
339
+ user_google_email=user_google_email,
340
+ required_scopes=resolved_scopes,
341
+ )
342
+
343
+ # Inject service with specified parameter name
344
+ kwargs[param_name] = service
345
+
346
+ except GoogleAuthenticationError as e:
347
+ raise Exception(str(e))
348
+
349
+ # Call the original function with refresh error handling
350
+ try:
351
+ return await func(*args, **kwargs)
352
+ except RefreshError as e:
353
+ # Handle token refresh errors gracefully
354
+ error_message = _handle_token_refresh_error(e, user_google_email, "Multiple Services")
355
+ raise Exception(error_message)
356
+
357
+ return wrapper
358
+ return decorator
359
+
360
+
361
+ def clear_service_cache(user_email: Optional[str] = None) -> int:
362
+ """
363
+ Clear service cache entries.
364
+
365
+ Args:
366
+ user_email: If provided, only clear cache for this user. If None, clear all.
367
+
368
+ Returns:
369
+ Number of cache entries cleared.
370
+ """
371
+ global _service_cache
372
+
373
+ if user_email is None:
374
+ count = len(_service_cache)
375
+ _service_cache.clear()
376
+ logger.info(f"Cleared all {count} service cache entries")
377
+ return count
378
+
379
+ keys_to_remove = [key for key in _service_cache.keys() if key.startswith(f"{user_email}:")]
380
+ for key in keys_to_remove:
381
+ del _service_cache[key]
382
+
383
+ logger.info(f"Cleared {len(keys_to_remove)} service cache entries for user {user_email}")
384
+ return len(keys_to_remove)
385
+
386
+
387
+ def get_cache_stats() -> Dict[str, Any]:
388
+ """Get service cache statistics."""
389
+ now = datetime.now()
390
+ valid_entries = 0
391
+ expired_entries = 0
392
+
393
+ for _, (_, cached_time, _) in _service_cache.items():
394
+ if _is_cache_valid(cached_time):
395
+ valid_entries += 1
396
+ else:
397
+ expired_entries += 1
398
+
399
+ return {
400
+ "total_entries": len(_service_cache),
401
+ "valid_entries": valid_entries,
402
+ "expired_entries": expired_entries,
403
+ "cache_ttl_minutes": _cache_ttl.total_seconds() / 60
404
+ }
core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # Make the core directory a Python package
core/server.py ADDED
@@ -0,0 +1,214 @@
1
+ import logging
2
+ import os
3
+ from typing import Dict, Any, Optional
4
+
5
+ from fastapi import Header
6
+ from fastapi.responses import HTMLResponse
7
+
8
+ from mcp import types
9
+
10
+ from mcp.server.fastmcp import FastMCP
11
+ from starlette.requests import Request
12
+
13
+ from auth.google_auth import handle_auth_callback, start_auth_flow, CONFIG_CLIENT_SECRETS_PATH
14
+ from auth.oauth_callback_server import get_oauth_redirect_uri, ensure_oauth_callback_available
15
+ from auth.oauth_responses import create_error_response, create_success_response, create_server_error_response
16
+
17
+ # Import shared configuration
18
+ from auth.scopes import (
19
+ OAUTH_STATE_TO_SESSION_ID_MAP,
20
+ USERINFO_EMAIL_SCOPE,
21
+ OPENID_SCOPE,
22
+ CALENDAR_READONLY_SCOPE,
23
+ CALENDAR_EVENTS_SCOPE,
24
+ DRIVE_READONLY_SCOPE,
25
+ DRIVE_FILE_SCOPE,
26
+ GMAIL_READONLY_SCOPE,
27
+ GMAIL_SEND_SCOPE,
28
+ GMAIL_COMPOSE_SCOPE,
29
+ GMAIL_MODIFY_SCOPE,
30
+ GMAIL_LABELS_SCOPE,
31
+ BASE_SCOPES,
32
+ CALENDAR_SCOPES,
33
+ DRIVE_SCOPES,
34
+ GMAIL_SCOPES,
35
+ DOCS_READONLY_SCOPE,
36
+ DOCS_WRITE_SCOPE,
37
+ CHAT_READONLY_SCOPE,
38
+ CHAT_WRITE_SCOPE,
39
+ CHAT_SPACES_SCOPE,
40
+ CHAT_SCOPES,
41
+ SHEETS_READONLY_SCOPE,
42
+ SHEETS_WRITE_SCOPE,
43
+ SHEETS_SCOPES,
44
+ FORMS_BODY_SCOPE,
45
+ FORMS_BODY_READONLY_SCOPE,
46
+ FORMS_RESPONSES_READONLY_SCOPE,
47
+ FORMS_SCOPES,
48
+ SLIDES_SCOPE,
49
+ SLIDES_READONLY_SCOPE,
50
+ SLIDES_SCOPES,
51
+ SCOPES
52
+ )
53
+
54
+ # Configure logging
55
+ logging.basicConfig(level=logging.INFO)
56
+ logger = logging.getLogger(__name__)
57
+
58
+ WORKSPACE_MCP_PORT = int(os.getenv("PORT", os.getenv("WORKSPACE_MCP_PORT", 8000)))
59
+ WORKSPACE_MCP_BASE_URI = os.getenv("WORKSPACE_MCP_BASE_URI", "http://localhost")
60
+
61
+ # Transport mode detection (will be set by main.py)
62
+ _current_transport_mode = "stdio" # Default to stdio
63
+
64
+ # Basic MCP server instance
65
+ server = FastMCP(
66
+ name="google_workspace",
67
+ server_url=f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}/mcp",
68
+ port=WORKSPACE_MCP_PORT,
69
+ host="0.0.0.0"
70
+ )
71
+
72
+ def set_transport_mode(mode: str):
73
+ """Set the current transport mode for OAuth callback handling."""
74
+ global _current_transport_mode
75
+ _current_transport_mode = mode
76
+ logger.info(f"Transport mode set to: {mode}")
77
+
78
+ def get_oauth_redirect_uri_for_current_mode() -> str:
79
+ """Get OAuth redirect URI based on current transport mode."""
80
+ return get_oauth_redirect_uri(_current_transport_mode, WORKSPACE_MCP_PORT, WORKSPACE_MCP_BASE_URI)
81
+
82
+ # Health check endpoint
83
+ @server.custom_route("/health", methods=["GET"])
84
+ async def health_check(request: Request):
85
+ """Health check endpoint for container orchestration."""
86
+ from fastapi.responses import JSONResponse
87
+ return JSONResponse({
88
+ "status": "healthy",
89
+ "service": "google-workspace-mcp",
90
+ "version": "0.1.1",
91
+ "transport": _current_transport_mode
92
+ })
93
+
94
+
95
+ # Register OAuth callback as a custom route
96
+ @server.custom_route("/oauth2callback", methods=["GET"])
97
+ async def oauth2_callback(request: Request) -> HTMLResponse:
98
+ """
99
+ Handle OAuth2 callback from Google via a custom route.
100
+ This endpoint exchanges the authorization code for credentials and saves them.
101
+ It then displays a success or error page to the user.
102
+ """
103
+ # State is used by google-auth-library for CSRF protection and should be present.
104
+ # We don't need to track it ourselves in this simplified flow.
105
+ state = request.query_params.get("state")
106
+ code = request.query_params.get("code")
107
+ error = request.query_params.get("error")
108
+
109
+ if error:
110
+ error_message = f"Authentication failed: Google returned an error: {error}. State: {state}."
111
+ logger.error(error_message)
112
+ return create_error_response(error_message)
113
+
114
+ if not code:
115
+ error_message = "Authentication failed: No authorization code received from Google."
116
+ logger.error(error_message)
117
+ return create_error_response(error_message)
118
+
119
+ try:
120
+ # Use the centralized CONFIG_CLIENT_SECRETS_PATH
121
+ client_secrets_path = CONFIG_CLIENT_SECRETS_PATH
122
+ if not os.path.exists(client_secrets_path):
123
+ logger.error(f"OAuth client secrets file not found at {client_secrets_path}")
124
+ # This is a server configuration error, should not happen in a deployed environment.
125
+ return HTMLResponse(content="Server Configuration Error: Client secrets not found.", status_code=500)
126
+
127
+ logger.info(f"OAuth callback: Received code (state: {state}). Attempting to exchange for tokens.")
128
+
129
+ mcp_session_id: Optional[str] = OAUTH_STATE_TO_SESSION_ID_MAP.pop(state, None)
130
+ if mcp_session_id:
131
+ logger.info(f"OAuth callback: Retrieved MCP session ID '{mcp_session_id}' for state '{state}'.")
132
+ else:
133
+ logger.warning(f"OAuth callback: No MCP session ID found for state '{state}'. Auth will not be tied to a specific session directly via this callback.")
134
+
135
+ # Exchange code for credentials. handle_auth_callback will save them.
136
+ # The user_id returned here is the Google-verified email.
137
+ verified_user_id, credentials = handle_auth_callback(
138
+ client_secrets_path=client_secrets_path,
139
+ scopes=SCOPES, # Ensure all necessary scopes are requested
140
+ authorization_response=str(request.url),
141
+ redirect_uri=get_oauth_redirect_uri_for_current_mode(),
142
+ session_id=mcp_session_id # Pass session_id if available
143
+ )
144
+
145
+ log_session_part = f" (linked to session: {mcp_session_id})" if mcp_session_id else ""
146
+ logger.info(f"OAuth callback: Successfully authenticated user: {verified_user_id} (state: {state}){log_session_part}.")
147
+
148
+ # Return success page using shared template
149
+ return create_success_response(verified_user_id)
150
+
151
+ except Exception as e:
152
+ error_message_detail = f"Error processing OAuth callback (state: {state}): {str(e)}"
153
+ logger.error(error_message_detail, exc_info=True)
154
+ # Generic error page for any other issues during token exchange or credential saving
155
+ return create_server_error_response(str(e))
156
+
157
+ @server.tool()
158
+ async def start_google_auth(
159
+ user_google_email: str,
160
+ service_name: str,
161
+ mcp_session_id: Optional[str] = Header(None, alias="Mcp-Session-Id")
162
+ ) -> str:
163
+ """
164
+ Initiates the Google OAuth 2.0 authentication flow for the specified user email and service.
165
+ This is the primary method to establish credentials when no valid session exists or when targeting a specific account for a particular service.
166
+ It generates an authorization URL that the LLM must present to the user.
167
+ The authentication attempt is linked to the current MCP session via `mcp_session_id`.
168
+
169
+ LLM Guidance:
170
+ - Use this tool when you need to authenticate a user for a specific Google service (e.g., "Google Calendar", "Google Docs", "Gmail", "Google Drive")
171
+ and don't have existing valid credentials for the session or specified email.
172
+ - You MUST provide the `user_google_email` and the `service_name`. If you don't know the email, ask the user first.
173
+ - Valid `service_name` values typically include "Google Calendar", "Google Docs", "Gmail", "Google Drive".
174
+ - After calling this tool, present the returned authorization URL clearly to the user and instruct them to:
175
+ 1. Click the link and complete the sign-in/consent process in their browser.
176
+ 2. Note the authenticated email displayed on the success page.
177
+ 3. Provide that email back to you (the LLM).
178
+ 4. Retry their original request, including the confirmed `user_google_email`.
179
+
180
+ Args:
181
+ user_google_email (str): The user's full Google email address (e.g., 'example@gmail.com'). This is REQUIRED.
182
+ service_name (str): The name of the Google service for which authentication is being requested (e.g., "Google Calendar", "Google Docs"). This is REQUIRED.
183
+ mcp_session_id (Optional[str]): The active MCP session ID (automatically injected by FastMCP from the Mcp-Session-Id header). Links the OAuth flow state to the session.
184
+
185
+ Returns:
186
+ str: A detailed message for the LLM with the authorization URL and instructions to guide the user through the authentication process.
187
+ """
188
+ if not user_google_email or not isinstance(user_google_email, str) or '@' not in user_google_email:
189
+ error_msg = "Invalid or missing 'user_google_email'. This parameter is required and must be a valid email address. LLM, please ask the user for their Google email address."
190
+ logger.error(f"[start_google_auth] {error_msg}")
191
+ raise Exception(error_msg)
192
+
193
+ if not service_name or not isinstance(service_name, str):
194
+ error_msg = "Invalid or missing 'service_name'. This parameter is required (e.g., 'Google Calendar', 'Google Docs'). LLM, please specify the service name."
195
+ logger.error(f"[start_google_auth] {error_msg}")
196
+ raise Exception(error_msg)
197
+
198
+ logger.info(f"Tool 'start_google_auth' invoked for user_google_email: '{user_google_email}', service: '{service_name}', session: '{mcp_session_id}'.")
199
+
200
+ # Ensure OAuth callback is available for current transport mode
201
+ redirect_uri = get_oauth_redirect_uri_for_current_mode()
202
+ if not ensure_oauth_callback_available(_current_transport_mode, WORKSPACE_MCP_PORT, WORKSPACE_MCP_BASE_URI):
203
+ raise Exception("Failed to start OAuth callback server. Please try again.")
204
+
205
+ # Use the centralized start_auth_flow from auth.google_auth
206
+ auth_result = await start_auth_flow(
207
+ mcp_session_id=mcp_session_id,
208
+ user_google_email=user_google_email,
209
+ service_name=service_name,
210
+ redirect_uri=redirect_uri
211
+ )
212
+
213
+ # auth_result is now a plain string, not a CallToolResult
214
+ return auth_result