forked from taylorwilsdon/google_workspace_mcp
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
644 lines (572 loc) · 23.7 KB
/
main.py
File metadata and controls
644 lines (572 loc) · 23.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
import io
import argparse
import json
import logging
import os
import socket
import sys
from importlib import metadata, import_module
from dotenv import load_dotenv
# Prevent any stray startup output on macOS (e.g. platform identifiers) from
# corrupting the MCP JSON-RPC handshake on stdout. We capture anything written
# to stdout during module-level initialisation and replay it to stderr so that
# diagnostic information is not lost.
_original_stdout = sys.stdout
if sys.platform == "darwin":
sys.stdout = io.StringIO()
def _load_startup_dependencies():
from auth.oauth_config import (
get_oauth_config,
reload_oauth_config,
is_stateless_mode,
is_service_account_enabled,
)
from core.log_formatter import EnhancedLogFormatter, configure_file_logging
from core.utils import check_credentials_directory_permissions
from core.server import server, set_transport_mode, configure_server_for_http
from core.tool_tier_loader import resolve_tools_from_tier
from core.tool_registry import (
set_enabled_tools as set_enabled_tool_names,
wrap_server_tool_method,
filter_server_tools,
)
return (
get_oauth_config,
reload_oauth_config,
is_stateless_mode,
is_service_account_enabled,
EnhancedLogFormatter,
configure_file_logging,
check_credentials_directory_permissions,
server,
set_transport_mode,
configure_server_for_http,
resolve_tools_from_tier,
set_enabled_tool_names,
wrap_server_tool_method,
filter_server_tools,
)
(
get_oauth_config,
reload_oauth_config,
is_stateless_mode,
is_service_account_enabled,
EnhancedLogFormatter,
configure_file_logging,
check_credentials_directory_permissions,
server,
set_transport_mode,
configure_server_for_http,
resolve_tools_from_tier,
set_enabled_tool_names,
wrap_server_tool_method,
filter_server_tools,
) = _load_startup_dependencies()
dotenv_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
load_dotenv(dotenv_path=dotenv_path)
# Suppress googleapiclient discovery cache warning
logging.getLogger("googleapiclient.discovery_cache").setLevel(logging.ERROR)
# Suppress httpx/httpcore INFO logs that leak access tokens in URLs
# (e.g. tokeninfo?access_token=ya29.xxx)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
reload_oauth_config()
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
configure_file_logging()
def safe_print(text):
# Don't print to stderr when running as MCP server via uvx to avoid JSON parsing errors
# Check if we're running as MCP server (no TTY and uvx in process name)
if not sys.stderr.isatty():
# Running as MCP server, suppress output to avoid JSON parsing errors
logger.debug(f"[MCP Server] {text}")
return
try:
print(text, file=sys.stderr)
except UnicodeEncodeError:
print(text.encode("ascii", errors="replace").decode(), file=sys.stderr)
def configure_safe_logging():
class SafeEnhancedFormatter(EnhancedLogFormatter):
"""Enhanced ASCII formatter with additional Windows safety."""
def format(self, record):
try:
return super().format(record)
except UnicodeEncodeError:
# Fallback to ASCII-safe formatting
service_prefix = self._get_ascii_prefix(record.name, record.levelname)
safe_msg = (
str(record.getMessage())
.encode("ascii", errors="replace")
.decode("ascii")
)
return f"{service_prefix} {safe_msg}"
# Replace all console handlers' formatters with safe enhanced ones
for handler in logging.root.handlers:
# Only apply to console/stream handlers, keep file handlers as-is
if isinstance(handler, logging.StreamHandler) and handler.stream.name in [
"<stderr>",
"<stdout>",
]:
safe_formatter = SafeEnhancedFormatter(use_colors=True)
handler.setFormatter(safe_formatter)
def resolve_permissions_mode_selection(
permission_services: list[str], tool_tier: str | None
) -> tuple[list[str], set[str] | None]:
"""
Resolve service imports and optional tool-name filtering for --permissions mode.
When a tier is specified, both:
- imported services are narrowed to services with tier-matched tools
- registered tools are narrowed to the resolved tool names
"""
if tool_tier is None:
return permission_services, None
tier_tools, tier_services = resolve_tools_from_tier(tool_tier, permission_services)
return tier_services, set(tier_tools)
def narrow_permissions_to_services(
permissions: dict[str, str], services: list[str]
) -> dict[str, str]:
"""Restrict permission entries to the provided service list order."""
return {
service: permissions[service] for service in services if service in permissions
}
def _restore_stdout() -> None:
"""Restore the real stdout and replay any captured output to stderr."""
captured_stdout = sys.stdout
# Idempotent: if already restored, nothing to do.
if captured_stdout is _original_stdout:
return
captured = ""
required_stringio_methods = ("getvalue", "write", "flush")
try:
if all(
callable(getattr(captured_stdout, method_name, None))
for method_name in required_stringio_methods
):
captured = captured_stdout.getvalue()
finally:
sys.stdout = _original_stdout
if captured:
print(captured, end="", file=sys.stderr)
def main():
"""
Main entry point for the Google Workspace MCP server.
Uses FastMCP's native streamable-http transport.
"""
_restore_stdout()
# Configure safe logging for Windows Unicode handling
configure_safe_logging()
# Parse command line arguments
parser = argparse.ArgumentParser(description="Google Workspace MCP Server")
parser.add_argument(
"--single-user",
action="store_true",
help="Run in single-user mode - bypass session mapping and use any credentials from the credentials directory",
)
parser.add_argument(
"--tools",
nargs="*",
choices=[
"gmail",
"drive",
"calendar",
"docs",
"sheets",
"chat",
"forms",
"slides",
"tasks",
"contacts",
"search",
"appscript",
],
help="Specify which tools to register. If not provided, all tools are registered.",
)
parser.add_argument(
"--tool-tier",
choices=["core", "extended", "complete"],
help="Load tools based on tier level. Can be combined with --tools to filter services.",
)
parser.add_argument(
"--transport",
choices=["stdio", "streamable-http"],
default="stdio",
help="Transport mode: stdio (default) or streamable-http",
)
parser.add_argument(
"--read-only",
action="store_true",
help="Run in read-only mode - requests only read-only scopes and disables tools requiring write permissions",
)
parser.add_argument(
"--permissions",
nargs="+",
metavar="SERVICE:LEVEL",
help=(
"Granular per-service permission levels. Format: service:level. "
"Example: --permissions gmail:organize drive:readonly. "
"Gmail levels: readonly, organize, drafts, send, full (cumulative). "
"Other services: readonly, full. "
"Mutually exclusive with --read-only and --tools."
),
)
args = parser.parse_args()
# Validate mutually exclusive flags
if args.permissions and args.read_only:
print(
"Error: --permissions and --read-only are mutually exclusive. "
"Use service:readonly within --permissions instead.",
file=sys.stderr,
)
sys.exit(1)
if args.permissions and args.tools is not None:
print(
"Error: --permissions and --tools cannot be combined. "
"Select services via --permissions (optionally with --tool-tier).",
file=sys.stderr,
)
sys.exit(1)
# Set port and base URI once for reuse throughout the function
port = int(os.getenv("PORT", os.getenv("WORKSPACE_MCP_PORT", 8000)))
base_uri = os.getenv("WORKSPACE_MCP_BASE_URI", "http://localhost")
host = os.getenv("WORKSPACE_MCP_HOST", "0.0.0.0")
external_url = os.getenv("WORKSPACE_EXTERNAL_URL")
display_url = external_url if external_url else f"{base_uri}:{port}"
safe_print("🔧 Google Workspace MCP Server")
safe_print("=" * 35)
safe_print("📋 Server Information:")
try:
version = metadata.version("workspace-mcp")
except metadata.PackageNotFoundError:
version = "dev"
safe_print(f" 📦 Version: {version}")
safe_print(f" 🌐 Transport: {args.transport}")
if args.transport == "streamable-http":
safe_print(f" 🔗 URL: {display_url}")
safe_print(f" 🔐 OAuth Callback: {display_url}/oauth2callback")
safe_print(f" 👤 Mode: {'Single-user' if args.single_user else 'Multi-user'}")
if args.read_only:
safe_print(" 🔒 Read-Only: Enabled")
if args.permissions:
safe_print(" 🔒 Permissions: Granular mode")
safe_print(f" 🐍 Python: {sys.version.split()[0]}")
safe_print("")
# Active Configuration
safe_print("⚙️ Active Configuration:")
# Redact client secret for security
client_secret = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET", "Not Set")
redacted_secret = (
f"{client_secret[:4]}...{client_secret[-4:]}"
if len(client_secret) > 8
else "Invalid or too short"
)
# Determine credentials directory (same logic as credential_store.py)
workspace_creds_dir = os.getenv("WORKSPACE_MCP_CREDENTIALS_DIR")
google_creds_dir = os.getenv("GOOGLE_MCP_CREDENTIALS_DIR")
if workspace_creds_dir:
creds_dir_display = os.path.expanduser(workspace_creds_dir)
creds_dir_source = "WORKSPACE_MCP_CREDENTIALS_DIR"
elif google_creds_dir:
creds_dir_display = os.path.expanduser(google_creds_dir)
creds_dir_source = "GOOGLE_MCP_CREDENTIALS_DIR"
else:
creds_dir_display = os.path.join(
os.path.expanduser("~"), ".google_workspace_mcp", "credentials"
)
creds_dir_source = "default"
config_vars = {
"GOOGLE_OAUTH_CLIENT_ID": os.getenv("GOOGLE_OAUTH_CLIENT_ID", "Not Set"),
"GOOGLE_OAUTH_CLIENT_SECRET": redacted_secret,
"USER_GOOGLE_EMAIL": os.getenv("USER_GOOGLE_EMAIL", "Not Set"),
"CREDENTIALS_DIR": f"{creds_dir_display} ({creds_dir_source})",
"MCP_SINGLE_USER_MODE": os.getenv("MCP_SINGLE_USER_MODE", "false"),
"MCP_ENABLE_OAUTH21": os.getenv("MCP_ENABLE_OAUTH21", "false"),
"WORKSPACE_MCP_STATELESS_MODE": os.getenv(
"WORKSPACE_MCP_STATELESS_MODE", "false"
),
"OAUTHLIB_INSECURE_TRANSPORT": os.getenv(
"OAUTHLIB_INSECURE_TRANSPORT", "false"
),
"GOOGLE_CLIENT_SECRET_PATH": os.getenv("GOOGLE_CLIENT_SECRET_PATH", "Not Set"),
"GOOGLE_SERVICE_ACCOUNT_KEY_FILE": os.getenv(
"GOOGLE_SERVICE_ACCOUNT_KEY_FILE", "Not Set"
),
}
for key, value in config_vars.items():
safe_print(f" - {key}: {value}")
safe_print("")
# Import tool modules to register them with the MCP server via decorators
tool_imports = {
"gmail": lambda: import_module("gmail.gmail_tools"),
"drive": lambda: import_module("gdrive.drive_tools"),
"calendar": lambda: import_module("gcalendar.calendar_tools"),
"docs": lambda: import_module("gdocs.docs_tools"),
"sheets": lambda: import_module("gsheets.sheets_tools"),
"chat": lambda: import_module("gchat.chat_tools"),
"forms": lambda: import_module("gforms.forms_tools"),
"slides": lambda: import_module("gslides.slides_tools"),
"tasks": lambda: import_module("gtasks.tasks_tools"),
"contacts": lambda: import_module("gcontacts.contacts_tools"),
"search": lambda: import_module("gsearch.search_tools"),
"appscript": lambda: import_module("gappsscript.apps_script_tools"),
}
tool_icons = {
"gmail": "📧",
"drive": "📁",
"calendar": "📅",
"docs": "📄",
"sheets": "📊",
"chat": "💬",
"forms": "📝",
"slides": "🖼️",
"tasks": "✓",
"contacts": "👤",
"search": "🔍",
"appscript": "📜",
}
# Determine which tools to import based on arguments
perms = None
if args.permissions:
# Granular permissions mode — parse and activate before tool selection
from auth.permissions import parse_permissions_arg, set_permissions
try:
perms = parse_permissions_arg(args.permissions)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# Permissions implicitly defines which services to load
tools_to_import = list(perms.keys())
set_enabled_tool_names(None)
if args.tool_tier is not None:
# Combine with tier filtering within the permission-selected services
try:
tools_to_import, tier_tool_filter = resolve_permissions_mode_selection(
tools_to_import, args.tool_tier
)
set_enabled_tool_names(tier_tool_filter)
perms = narrow_permissions_to_services(perms, tools_to_import)
except Exception as e:
print(
f"Error loading tools for tier '{args.tool_tier}': {e}",
file=sys.stderr,
)
sys.exit(1)
set_permissions(perms)
elif args.tool_tier is not None:
# Use tier-based tool selection, optionally filtered by services
try:
tier_tools, suggested_services = resolve_tools_from_tier(
args.tool_tier, args.tools
)
# If --tools specified, use those services; otherwise use all services that have tier tools
if args.tools is not None:
tools_to_import = args.tools
else:
tools_to_import = suggested_services
# Set the specific tools that should be registered
set_enabled_tool_names(set(tier_tools))
except Exception as e:
safe_print(f"❌ Error loading tools for tier '{args.tool_tier}': {e}")
sys.exit(1)
elif args.tools is not None:
# Use explicit tool list without tier filtering
tools_to_import = args.tools
# Don't filter individual tools when using explicit service list only
set_enabled_tool_names(None)
else:
# Default: import all tools
tools_to_import = tool_imports.keys()
# Don't filter individual tools when importing all
set_enabled_tool_names(None)
wrap_server_tool_method(server)
from auth.scopes import set_enabled_tools, set_read_only
set_enabled_tools(list(tools_to_import))
if args.read_only:
set_read_only(True)
safe_print(
f"🛠️ Loading {len(tools_to_import)} tool module{'s' if len(tools_to_import) != 1 else ''}:"
)
for tool in tools_to_import:
try:
tool_imports[tool]()
safe_print(
f" {tool_icons[tool]} {tool.title()} - Google {tool.title()} API integration"
)
except ModuleNotFoundError as exc:
logger.error("Failed to import tool '%s': %s", tool, exc, exc_info=True)
safe_print(f" ⚠️ Failed to load {tool.title()} tool module ({exc}).")
if perms:
safe_print("🔒 Permission Levels:")
for svc, lvl in sorted(perms.items()):
safe_print(f" {tool_icons.get(svc, ' ')} {svc}: {lvl}")
safe_print("")
# Filter tools based on tier configuration (if tier-based loading is enabled)
filter_server_tools(server)
safe_print("📊 Configuration Summary:")
safe_print(f" 🔧 Services Loaded: {len(tools_to_import)}/{len(tool_imports)}")
if args.tool_tier is not None:
if args.tools is not None:
safe_print(
f" 📊 Tool Tier: {args.tool_tier} (filtered to {', '.join(args.tools)})"
)
else:
safe_print(f" 📊 Tool Tier: {args.tool_tier}")
safe_print(f" 📝 Log Level: {logging.getLogger().getEffectiveLevel()}")
safe_print("")
# Set global single-user mode flag
if args.single_user:
# Check for incompatible OAuth 2.1 mode
if os.getenv("MCP_ENABLE_OAUTH21", "false").lower() == "true":
safe_print("❌ Single-user mode is incompatible with OAuth 2.1 mode")
safe_print(
" Single-user mode is for legacy clients that pass user emails"
)
safe_print(
" OAuth 2.1 mode is for multi-user scenarios with bearer tokens"
)
safe_print(
" Please choose one mode: either --single-user OR MCP_ENABLE_OAUTH21=true"
)
sys.exit(1)
if is_stateless_mode():
safe_print("❌ Single-user mode is incompatible with stateless mode")
safe_print(" Stateless mode requires OAuth 2.1 which is multi-user")
sys.exit(1)
if is_service_account_enabled():
safe_print("❌ Single-user mode is incompatible with service account mode")
safe_print(
" Service account mode handles auth via domain-wide delegation"
)
safe_print(
" Please choose one mode: either --single-user OR GOOGLE_SERVICE_ACCOUNT_KEY_FILE"
)
sys.exit(1)
os.environ["MCP_SINGLE_USER_MODE"] = "1"
safe_print("🔐 Single-user mode enabled")
safe_print("")
# Service account mode startup validation
if is_service_account_enabled():
user_email = os.getenv("USER_GOOGLE_EMAIL")
if not user_email:
safe_print("❌ Service account mode requires USER_GOOGLE_EMAIL to be set")
safe_print(" Set USER_GOOGLE_EMAIL to the domain user to impersonate")
sys.exit(1)
# Validate service account key material before advertising readiness
sa_config = get_oauth_config()
try:
if sa_config.service_account_key_file:
with open(sa_config.service_account_key_file) as f:
key_data = json.load(f)
else:
key_data = json.loads(sa_config.service_account_key_json)
required_fields = {"type", "project_id", "private_key", "client_email"}
missing = required_fields - set(key_data.keys())
if missing:
safe_print(
f"❌ Service account key missing required fields: "
f"{', '.join(sorted(missing))}"
)
sys.exit(1)
if key_data.get("type") != "service_account":
safe_print(
f"❌ Service account key has unexpected type: "
f"{key_data.get('type')!r}"
)
sys.exit(1)
except FileNotFoundError as e:
safe_print(f"❌ Service account key file not found: {e}")
sys.exit(1)
except json.JSONDecodeError as e:
safe_print(f"❌ Service account key contains invalid JSON: {e}")
sys.exit(1)
except (IOError, OSError) as e:
safe_print(f"❌ Failed to read service account key: {e}")
sys.exit(1)
safe_print("🔐 Service account mode enabled (domain-wide delegation)")
safe_print(f" Impersonating: {user_email}")
safe_print("")
# Check credentials directory permissions before starting (skip in stateless/service-account mode)
if not is_stateless_mode() and not is_service_account_enabled():
try:
safe_print("🔍 Checking credentials directory permissions...")
check_credentials_directory_permissions()
safe_print("✅ Credentials directory permissions verified")
safe_print("")
except (PermissionError, OSError) as e:
safe_print(f"❌ Credentials directory permission check failed: {e}")
safe_print(
" Please ensure the service has write permissions to create/access the credentials directory"
)
logger.error(f"Failed credentials directory permission check: {e}")
sys.exit(1)
else:
skip_reason = (
"stateless mode" if is_stateless_mode() else "service account mode"
)
safe_print(f"🔍 Skipping credentials directory check ({skip_reason})")
safe_print("")
try:
# Set transport mode for OAuth callback handling
set_transport_mode(args.transport)
# Configure auth initialization for FastMCP lifecycle events
if args.transport == "streamable-http":
configure_server_for_http()
safe_print("")
safe_print(f"🚀 Starting HTTP server on {base_uri}:{port}")
if external_url:
safe_print(f" External URL: {external_url}")
else:
safe_print("")
safe_print("🚀 Starting STDIO server")
# Start minimal OAuth callback server for stdio mode (not needed for service accounts)
if not is_service_account_enabled():
from auth.oauth_callback_server import ensure_oauth_callback_available
success, error_msg = ensure_oauth_callback_available(
"stdio", port, base_uri
)
if success:
safe_print(
f" OAuth callback server started on {display_url}/oauth2callback"
)
else:
warning_msg = " ⚠️ Warning: Failed to start OAuth callback server"
if error_msg:
warning_msg += f": {error_msg}"
safe_print(warning_msg)
safe_print("✅ Ready for MCP connections")
safe_print("")
if args.transport == "streamable-http":
# Check port availability before starting HTTP server
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((host, port))
except OSError as e:
safe_print(f"Socket error: {e}")
safe_print(
f"❌ Port {port} is already in use. Cannot start HTTP server."
)
sys.exit(1)
server.run(
transport="streamable-http",
host=host,
port=port,
stateless_http=is_stateless_mode(),
)
else:
server.run()
except KeyboardInterrupt:
safe_print("\n👋 Server shutdown requested")
# Clean up OAuth callback server if running
from auth.oauth_callback_server import cleanup_oauth_callback_server
cleanup_oauth_callback_server()
sys.exit(0)
except Exception as e:
safe_print(f"\n❌ Server error: {e}")
logger.error(f"Unexpected error running server: {e}", exc_info=True)
# Clean up OAuth callback server if running
from auth.oauth_callback_server import cleanup_oauth_callback_server
cleanup_oauth_callback_server()
sys.exit(1)
if __name__ == "__main__":
main()