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
@@ -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
|