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 +1 -0
- auth/google_auth.py +549 -0
- auth/oauth_callback_server.py +241 -0
- auth/oauth_responses.py +223 -0
- auth/scopes.py +108 -0
- auth/service_decorator.py +404 -0
- core/__init__.py +1 -0
- core/server.py +214 -0
- core/utils.py +162 -0
- gcalendar/__init__.py +1 -0
- gcalendar/calendar_tools.py +496 -0
- gchat/__init__.py +6 -0
- gchat/chat_tools.py +254 -0
- gdocs/__init__.py +0 -0
- gdocs/docs_tools.py +244 -0
- gdrive/__init__.py +0 -0
- gdrive/drive_tools.py +362 -0
- gforms/__init__.py +3 -0
- gforms/forms_tools.py +318 -0
- gmail/__init__.py +1 -0
- gmail/gmail_tools.py +807 -0
- gsheets/__init__.py +23 -0
- gsheets/sheets_tools.py +393 -0
- gslides/__init__.py +0 -0
- gslides/slides_tools.py +316 -0
- main.py +160 -0
- workspace_mcp-0.2.0.dist-info/METADATA +29 -0
- workspace_mcp-0.2.0.dist-info/RECORD +32 -0
- workspace_mcp-0.2.0.dist-info/WHEEL +5 -0
- workspace_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- workspace_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
- workspace_mcp-0.2.0.dist-info/top_level.txt +11 -0
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
|
+
|