diff --git a/Dockerfile b/Dockerfile index cd1ffbb..5a7fbab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,12 @@ RUN uv sync --frozen --no-dev # Create non-root user for security RUN useradd --create-home --shell /bin/bash app \ && chown -R app:app /app + +# Give read and write access to the store_creds volume +RUN mkdir -p /app/store_creds \ + && chown -R app:app /app/store_creds \ + && chmod 755 /app/store_creds + USER app # Expose port (use default of 8000 if PORT not set) diff --git a/README.md b/README.md index c8d67bc..886f61c 100644 --- a/README.md +++ b/README.md @@ -380,6 +380,7 @@ export USER_GOOGLE_EMAIL=\ |----------|-------------|---------| | `WORKSPACE_MCP_BASE_URI` | Base server URI (no port) | `http://localhost` | | `WORKSPACE_MCP_PORT` | Server listening port | `8000` | +| `WORKSPACE_EXTERNAL_URL` | External URL for reverse proxy setups | None | | `GOOGLE_OAUTH_REDIRECT_URI` | Override OAuth callback URL | Auto-constructed | | `USER_GOOGLE_EMAIL` | Default auth email | None | @@ -963,28 +964,31 @@ This architecture enables any OAuth 2.1 compliant client to authenticate users t #### Reverse Proxy Setup -If you're running the MCP server behind a reverse proxy (nginx, Apache, Cloudflare, etc.), you'll need to configure `GOOGLE_OAUTH_REDIRECT_URI` to match your external URL: +If you're running the MCP server behind a reverse proxy (nginx, Apache, Cloudflare, etc.), you have two configuration options: -**Problem**: When behind a reverse proxy, the server constructs redirect URIs using internal ports (e.g., `http://localhost:8000/oauth2callback`) but Google expects the external URL (e.g., `https://your-domain.com/oauth2callback`). +**Problem**: When behind a reverse proxy, the server constructs OAuth URLs using internal ports (e.g., `http://localhost:8000`) but external clients need the public URL (e.g., `https://your-domain.com`). + +**Solution 1**: Set `WORKSPACE_EXTERNAL_URL` for all OAuth endpoints: +```bash +# This configures all OAuth endpoints to use your external URL +export WORKSPACE_EXTERNAL_URL="https://your-domain.com" +``` + +**Solution 2**: Set `GOOGLE_OAUTH_REDIRECT_URI` for just the callback: +```bash +# This only overrides the OAuth callback URL +export GOOGLE_OAUTH_REDIRECT_URI="https://your-domain.com/oauth2callback" +``` You also have options for: | `OAUTH_CUSTOM_REDIRECT_URIS` *(optional)* | Comma-separated list of additional redirect URIs | | `OAUTH_ALLOWED_ORIGINS` *(optional)* | Comma-separated list of additional CORS origins | -**Solution**: Set `GOOGLE_OAUTH_REDIRECT_URI` to your external URL: - -```bash -# External URL without port (nginx/Apache handling HTTPS) -export GOOGLE_OAUTH_REDIRECT_URI="https://your-domain.com/oauth2callback" - -# Or with custom port if needed -export GOOGLE_OAUTH_REDIRECT_URI="https://your-domain.com:8443/oauth2callback" -``` - **Important**: +- Use `WORKSPACE_EXTERNAL_URL` when all OAuth endpoints should use the external URL (recommended for reverse proxy setups) +- Use `GOOGLE_OAUTH_REDIRECT_URI` when you only need to override the callback URL - The redirect URI must exactly match what's configured in your Google Cloud Console -- The server will use this value for all OAuth flows instead of constructing it from `WORKSPACE_MCP_BASE_URI` and `WORKSPACE_MCP_PORT` -- Your reverse proxy must forward `/oauth2callback` requests to the MCP server +- Your reverse proxy must forward OAuth-related requests (`/oauth2callback`, `/oauth2/*`, `/.well-known/*`) to the MCP server
🚀 Advanced uvx Commands ← More startup options diff --git a/auth/fastmcp_google_auth.py b/auth/fastmcp_google_auth.py index b43bd08..9993147 100644 --- a/auth/fastmcp_google_auth.py +++ b/auth/fastmcp_google_auth.py @@ -12,7 +12,6 @@ Key features: - Session bridging to Google credentials for API access """ -import os import logging from typing import Dict, Any, Optional, List @@ -39,11 +38,14 @@ class GoogleWorkspaceAuthProvider(AuthProvider): """Initialize the Google Workspace auth provider.""" super().__init__() - # Get configuration from environment - self.client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID") - self.client_secret = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET") - self.base_url = os.getenv("WORKSPACE_MCP_BASE_URI", "http://localhost") - self.port = int(os.getenv("PORT", os.getenv("WORKSPACE_MCP_PORT", 8000))) + # Get configuration from OAuth config + from auth.oauth_config import get_oauth_config + config = get_oauth_config() + + self.client_id = config.client_id + self.client_secret = config.client_secret + self.base_url = config.get_oauth_base_url() + self.port = config.port if not self.client_id: logger.warning("GOOGLE_OAUTH_CLIENT_ID not set - OAuth 2.1 authentication will not work") diff --git a/auth/google_auth.py b/auth/google_auth.py index c8dbd42..1c0382a 100644 --- a/auth/google_auth.py +++ b/auth/google_auth.py @@ -17,9 +17,8 @@ from googleapiclient.errors import HttpError from auth.scopes import SCOPES from auth.oauth21_session_store import get_oauth21_session_store from auth.credential_store import get_credential_store +from auth.oauth_config import get_oauth_config from core.config import ( - WORKSPACE_MCP_PORT, - WORKSPACE_MCP_BASE_URI, get_transport_mode, get_oauth_redirect_uri, ) @@ -818,8 +817,9 @@ async def get_authenticated_google_service( from auth.oauth_callback_server import ensure_oauth_callback_available redirect_uri = get_oauth_redirect_uri() + config = get_oauth_config() success, error_msg = ensure_oauth_callback_available( - get_transport_mode(), WORKSPACE_MCP_PORT, WORKSPACE_MCP_BASE_URI + get_transport_mode(), config.port, config.base_uri ) if not success: error_detail = f" ({error_msg})" if error_msg else "" diff --git a/auth/google_remote_auth_provider.py b/auth/google_remote_auth_provider.py index 3731474..ea13de1 100644 --- a/auth/google_remote_auth_provider.py +++ b/auth/google_remote_auth_provider.py @@ -13,7 +13,6 @@ This provider is used only in streamable-http transport mode with FastMCP v2.11. For earlier versions or other transport modes, the legacy GoogleWorkspaceAuthProvider is used. """ -import os import logging import aiohttp from typing import Optional, List @@ -60,11 +59,14 @@ class GoogleRemoteAuthProvider(RemoteAuthProvider): if not REMOTEAUTHPROVIDER_AVAILABLE: raise ImportError("FastMCP v2.11.1+ required for RemoteAuthProvider") - # Get configuration from environment - self.client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID") - self.client_secret = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET") - self.base_url = os.getenv("WORKSPACE_MCP_BASE_URI", "http://localhost") - self.port = int(os.getenv("PORT", os.getenv("WORKSPACE_MCP_PORT", 8000))) + # Get configuration from OAuth config + from auth.oauth_config import get_oauth_config + config = get_oauth_config() + + self.client_id = config.client_id + self.client_secret = config.client_secret + self.base_url = config.get_oauth_base_url() + self.port = config.port if not self.client_id: logger.error( @@ -86,11 +88,13 @@ class GoogleRemoteAuthProvider(RemoteAuthProvider): # The /mcp/ resource URL is handled in the protected resource metadata endpoint super().__init__( token_verifier=token_verifier, - authorization_servers=[AnyHttpUrl(f"{self.base_url}:{self.port}")], - resource_server_url=f"{self.base_url}:{self.port}", + authorization_servers=[AnyHttpUrl(self.base_url)], + resource_server_url=self.base_url, ) - logger.debug("GoogleRemoteAuthProvider") + logger.debug( + f"Initialized GoogleRemoteAuthProvider with base_url={self.base_url}" + ) def get_routes(self) -> List[Route]: """ diff --git a/auth/oauth_common_handlers.py b/auth/oauth_common_handlers.py index f478451..c67f80c 100644 --- a/auth/oauth_common_handlers.py +++ b/auth/oauth_common_handlers.py @@ -241,6 +241,7 @@ async def handle_oauth_protected_resource(request: Request): # For streamable-http transport, the MCP server runs at /mcp # This is the actual resource being protected + # As of August, /mcp is now the proper base - prior was /mcp/ resource_url = f"{base_url}/mcp" # Build metadata response per RFC 9449 @@ -253,7 +254,6 @@ async def handle_oauth_protected_resource(request: Request): "client_registration_required": True, "client_configuration_endpoint": f"{base_url}/.well-known/oauth-client", } - # Log the response for debugging logger.debug(f"Returning protected resource metadata: {metadata}") @@ -416,4 +416,4 @@ async def handle_oauth_register(request: Request): "error": str(e) }, request) error = OAuthConfigurationError("Internal server error") - return create_oauth_error_response(error, origin) \ No newline at end of file + return create_oauth_error_response(error, origin) diff --git a/auth/oauth_config.py b/auth/oauth_config.py index 5148e64..cb75c61 100644 --- a/auth/oauth_config.py +++ b/auth/oauth_config.py @@ -26,6 +26,9 @@ class OAuthConfig: self.base_uri = os.getenv("WORKSPACE_MCP_BASE_URI", "http://localhost") self.port = int(os.getenv("PORT", os.getenv("WORKSPACE_MCP_PORT", "8000"))) self.base_url = f"{self.base_uri}:{self.port}" + + # External URL for reverse proxy scenarios + self.external_url = os.getenv("WORKSPACE_EXTERNAL_URL") # OAuth client configuration self.client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID") @@ -112,10 +115,15 @@ class OAuthConfig: def get_oauth_base_url(self) -> str: """ Get OAuth base URL for constructing OAuth endpoints. + + Uses WORKSPACE_EXTERNAL_URL if set (for reverse proxy scenarios), + otherwise falls back to constructed base_url with port. Returns: Base URL for OAuth endpoints """ + if self.external_url: + return self.external_url return self.base_url def validate_redirect_uri(self, uri: str) -> bool: @@ -140,6 +148,8 @@ class OAuthConfig: """ return { "base_url": self.base_url, + "external_url": self.external_url, + "effective_oauth_url": self.get_oauth_base_url(), "redirect_uri": self.redirect_uri, "client_configured": bool(self.client_id), "oauth21_enabled": self.oauth21_enabled, @@ -232,11 +242,12 @@ class OAuthConfig: Returns: Authorization server metadata dictionary """ + oauth_base = self.get_oauth_base_url() metadata = { - "issuer": self.base_url, - "authorization_endpoint": f"{self.base_url}/oauth2/authorize", - "token_endpoint": f"{self.base_url}/oauth2/token", - "registration_endpoint": f"{self.base_url}/oauth2/register", + "issuer": oauth_base, + "authorization_endpoint": f"{oauth_base}/oauth2/authorize", + "token_endpoint": f"{oauth_base}/oauth2/token", + "registration_endpoint": f"{oauth_base}/oauth2/register", "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs", "response_types_supported": ["code", "token"], "grant_types_supported": ["authorization_code", "refresh_token"], diff --git a/core/log_formatter.py b/core/log_formatter.py new file mode 100644 index 0000000..399d377 --- /dev/null +++ b/core/log_formatter.py @@ -0,0 +1,142 @@ +""" +Enhanced Log Formatter for Google Workspace MCP + +Provides visually appealing log formatting with emojis and consistent styling +to match the safe_print output format. +""" +import logging +import re + + +class EnhancedLogFormatter(logging.Formatter): + """Custom log formatter that adds ASCII prefixes and visual enhancements to log messages.""" + + # Color codes for terminals that support ANSI colors + COLORS = { + 'DEBUG': '\033[36m', # Cyan + 'INFO': '\033[32m', # Green + 'WARNING': '\033[33m', # Yellow + 'ERROR': '\033[31m', # Red + 'CRITICAL': '\033[35m', # Magenta + 'RESET': '\033[0m' # Reset + } + + def __init__(self, use_colors: bool = True, *args, **kwargs): + """ + Initialize the emoji log formatter. + + Args: + use_colors: Whether to use ANSI color codes (default: True) + """ + super().__init__(*args, **kwargs) + self.use_colors = use_colors + + def format(self, record: logging.LogRecord) -> str: + """Format the log record with ASCII prefixes and enhanced styling.""" + # Get the appropriate ASCII prefix for the service + service_prefix = self._get_ascii_prefix(record.name, record.levelname) + + # Format the message with enhanced styling + formatted_msg = self._enhance_message(record.getMessage()) + + # Build the formatted log entry + if self.use_colors: + color = self.COLORS.get(record.levelname, '') + reset = self.COLORS['RESET'] + return f"{service_prefix} {color}{formatted_msg}{reset}" + else: + return f"{service_prefix} {formatted_msg}" + + def _get_ascii_prefix(self, logger_name: str, level_name: str) -> str: + """Get ASCII-safe prefix for Windows compatibility.""" + # ASCII-safe prefixes for different services + ascii_prefixes = { + 'core.tool_tier_loader': '[TOOLS]', + 'core.tool_registry': '[REGISTRY]', + 'auth.scopes': '[AUTH]', + 'core.utils': '[UTILS]', + 'auth.google_auth': '[OAUTH]', + 'auth.credential_store': '[CREDS]', + 'auth.oauth_common_handlers': '[OAUTH]', + 'gcalendar.calendar_tools': '[CALENDAR]', + 'gdrive.drive_tools': '[DRIVE]', + 'gmail.gmail_tools': '[GMAIL]', + 'gdocs.docs_tools': '[DOCS]', + 'gsheets.sheets_tools': '[SHEETS]', + 'gchat.chat_tools': '[CHAT]', + 'gforms.forms_tools': '[FORMS]', + 'gslides.slides_tools': '[SLIDES]', + 'gtasks.tasks_tools': '[TASKS]', + 'gsearch.search_tools': '[SEARCH]' + } + + return ascii_prefixes.get(logger_name, f'[{level_name}]') + + def _enhance_message(self, message: str) -> str: + """Enhance the log message with better formatting.""" + # Handle common patterns for better visual appeal + + # Tool tier loading messages + if "resolved to" in message and "tools across" in message: + # Extract numbers and service names for better formatting + pattern = r"Tier '(\w+)' resolved to (\d+) tools across (\d+) services: (.+)" + match = re.search(pattern, message) + if match: + tier, tool_count, service_count, services = match.groups() + return f"Tool tier '{tier}' loaded: {tool_count} tools across {service_count} services [{services}]" + + # Configuration loading messages + if "Loaded tool tiers configuration from" in message: + path = message.split("from ")[-1] + return f"Configuration loaded from {path}" + + # Tool filtering messages + if "Tool tier filtering" in message: + pattern = r"removed (\d+) tools, (\d+) enabled" + match = re.search(pattern, message) + if match: + removed, enabled = match.groups() + return f"Tool filtering complete: {enabled} tools enabled ({removed} filtered out)" + + # Enabled tools messages + if "Enabled tools set for scope management" in message: + tools = message.split(": ")[-1] + return f"Scope management configured for tools: {tools}" + + # Credentials directory messages + if "Credentials directory permissions check passed" in message: + path = message.split(": ")[-1] + return f"Credentials directory verified: {path}" + + # If no specific pattern matches, return the original message + return message + + +def setup_enhanced_logging(log_level: int = logging.INFO, use_colors: bool = True) -> None: + """ + Set up enhanced logging with ASCII prefix formatter for the entire application. + + Args: + log_level: The logging level to use (default: INFO) + use_colors: Whether to use ANSI colors (default: True) + """ + # Create the enhanced formatter + formatter = EnhancedLogFormatter(use_colors=use_colors) + + # Get the root logger + root_logger = logging.getLogger() + + # Update existing console handlers + for handler in root_logger.handlers: + if isinstance(handler, logging.StreamHandler) and handler.stream.name in ['', '']: + handler.setFormatter(formatter) + + # If no console handler exists, create one + console_handlers = [h for h in root_logger.handlers + if isinstance(h, logging.StreamHandler) and h.stream.name in ['', '']] + + if not console_handlers: + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + console_handler.setLevel(log_level) + root_logger.addHandler(console_handler) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..425c45a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + gws_mcp: + build: . + container_name: gws_mcp + ports: + - "8000:8000" + environment: + - GOOGLE_MCP_CREDENTIALS_DIR=/app/store_creds + volumes: + - ./client_secret.json:/app/client_secret.json:ro + - store_creds:/app/store_creds:rw + env_file: + - .env + +volumes: + store_creds: \ No newline at end of file diff --git a/fastmcp_server.py b/fastmcp_server.py new file mode 100644 index 0000000..daee1a3 --- /dev/null +++ b/fastmcp_server.py @@ -0,0 +1,117 @@ +""" +FastMCP CLI entrypoint for Google Workspace MCP Server. +This file imports all tool modules to register them with the server instance. +Includes full initialization bootstrap that main.py provides. +""" +import logging +import os +import sys +from dotenv import load_dotenv + +from auth.oauth_config import reload_oauth_config +from core.log_formatter import EnhancedLogFormatter +from core.utils import check_credentials_directory_permissions +from core.server import server, set_transport_mode, configure_server_for_http +from core.tool_registry import set_enabled_tools as set_enabled_tool_names, wrap_server_tool_method, filter_server_tools +from auth.scopes import set_enabled_tools + +# Load environment variables +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) + +# Reload OAuth configuration after env vars loaded +reload_oauth_config() + +# Configure basic logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# Configure file logging +try: + root_logger = logging.getLogger() + log_file_dir = os.path.dirname(os.path.abspath(__file__)) + log_file_path = os.path.join(log_file_dir, 'mcp_server_debug.log') + + file_handler = logging.FileHandler(log_file_path, mode='a') + file_handler.setLevel(logging.DEBUG) + + file_formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(process)d - %(threadName)s ' + '[%(module)s.%(funcName)s:%(lineno)d] - %(message)s' + ) + file_handler.setFormatter(file_formatter) + root_logger.addHandler(file_handler) + + logger.debug(f"Detailed file logging configured to: {log_file_path}") +except Exception as e: + sys.stderr.write(f"CRITICAL: Failed to set up file logging to '{log_file_path}': {e}\n") + +def configure_safe_logging(): + """Configure safe Unicode handling for 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 ['', '']: + safe_formatter = SafeEnhancedFormatter(use_colors=True) + handler.setFormatter(safe_formatter) + +# Configure safe logging +configure_safe_logging() + +# Check credentials directory permissions +try: + logger.info("🔍 Checking credentials directory permissions...") + check_credentials_directory_permissions() + logger.info("✅ Credentials directory permissions verified") +except (PermissionError, OSError) as e: + logger.error(f"❌ Credentials directory permission check failed: {e}") + logger.error(" Please ensure the service has write permissions to create/access the credentials directory") + sys.exit(1) + +# Set transport mode for HTTP (FastMCP CLI defaults to streamable-http) +set_transport_mode('streamable-http') +configure_server_for_http() + +# Import all tool modules to register their @server.tool() decorators +import gmail.gmail_tools +import gdrive.drive_tools +import gcalendar.calendar_tools +import gdocs.docs_tools +import gsheets.sheets_tools +import gchat.chat_tools +import gforms.forms_tools +import gslides.slides_tools +import gtasks.tasks_tools +import gsearch.search_tools + +# Configure tool registration +wrap_server_tool_method(server) + +# Enable all tools and services by default +all_services = ['gmail', 'drive', 'calendar', 'docs', 'sheets', 'chat', 'forms', 'slides', 'tasks', 'search'] +set_enabled_tools(all_services) # Set enabled services for scopes +set_enabled_tool_names(None) # Don't filter individual tools - enable all + +# Filter tools based on configuration +filter_server_tools(server) + +# Export server instance for FastMCP CLI (looks for 'mcp', 'server', or 'app') +mcp = server +app = server \ No newline at end of file diff --git a/google_workspace_mcp.dxt b/google_workspace_mcp.dxt index a219be2..b769c46 100644 Binary files a/google_workspace_mcp.dxt and b/google_workspace_mcp.dxt differ diff --git a/gtasks/tasks_tools.py b/gtasks/tasks_tools.py index 0e73bef..a224dbf 100644 --- a/gtasks/tasks_tools.py +++ b/gtasks/tasks_tools.py @@ -26,7 +26,7 @@ LIST_TASKS_MAX_POSITION = "99999999999999999999" async def list_task_lists( service, user_google_email: str, - max_results: Optional[int] = None, + max_results: Optional[str] = None, page_token: Optional[str] = None ) -> str: """ @@ -34,7 +34,7 @@ async def list_task_lists( Args: user_google_email (str): The user's Google email address. Required. - max_results (Optional[int]): Maximum number of task lists to return (default: 1000, max: 1000). + max_results (Optional[str]): Maximum number of task lists to return (default: 1000, max: 1000). page_token (Optional[str]): Token for pagination. Returns: @@ -269,12 +269,12 @@ async def list_tasks( service, user_google_email: str, task_list_id: str, - max_results: Optional[int] = None, + max_results: int = None, page_token: Optional[str] = None, - show_completed: Optional[bool] = None, - show_deleted: Optional[bool] = None, - show_hidden: Optional[bool] = None, - show_assigned: Optional[bool] = None, + show_completed: bool = None, + show_deleted: bool = None, + show_hidden: bool = None, + show_assigned: bool = None, completed_max: Optional[str] = None, completed_min: Optional[str] = None, due_max: Optional[str] = None, @@ -287,12 +287,12 @@ async def list_tasks( Args: user_google_email (str): The user's Google email address. Required. task_list_id (str): The ID of the task list to retrieve tasks from. - max_results (Optional[int]): Maximum number of tasks to return (default: 20, max: 10000). + max_results (Optional[int]): Maximum number of tasks to return. (default: 20, max: 10000). page_token (Optional[str]): Token for pagination. - show_completed (Optional[bool]): Whether to include completed tasks (default: True). - show_deleted (Optional[bool]): Whether to include deleted tasks (default: False). - show_hidden (Optional[bool]): Whether to include hidden tasks (default: False). - show_assigned (Optional[bool]): Whether to include assigned tasks (default: False). + show_completed (bool): Whether to include completed tasks (default: True). + show_deleted (bool): Whether to include deleted tasks (default: False). + show_hidden (bool): Whether to include hidden tasks (default: False). + show_assigned (bool): Whether to include assigned tasks (default: False). completed_max (Optional[str]): Upper bound for completion date (RFC 3339 timestamp). completed_min (Optional[str]): Lower bound for completion date (RFC 3339 timestamp). due_max (Optional[str]): Upper bound for due date (RFC 3339 timestamp). diff --git a/helm-chart/workspace-mcp/templates/NOTES.txt b/helm-chart/workspace-mcp/templates/NOTES.txt index 8a4ed04..32d8be3 100644 --- a/helm-chart/workspace-mcp/templates/NOTES.txt +++ b/helm-chart/workspace-mcp/templates/NOTES.txt @@ -49,7 +49,7 @@ 4. Important Notes: - Make sure you have configured your Google OAuth credentials in the secret - The application requires internet access to reach Google APIs - - OAuth callback URL: {{ default "http://localhost" .Values.env.WORKSPACE_MCP_BASE_URI }}:{{ .Values.env.WORKSPACE_MCP_PORT }}/oauth2callback + - OAuth callback URL: {{ if .Values.env.WORKSPACE_EXTERNAL_URL }}{{ .Values.env.WORKSPACE_EXTERNAL_URL }}{{ else }}{{ default "http://localhost" .Values.env.WORKSPACE_MCP_BASE_URI }}:{{ .Values.env.WORKSPACE_MCP_PORT }}{{ end }}/oauth2callback For more information about the Google Workspace MCP Server, visit: https://github.com/taylorwilsdon/google_workspace_mcp \ No newline at end of file diff --git a/helm-chart/workspace-mcp/values.yaml b/helm-chart/workspace-mcp/values.yaml index a82ca42..0054f7c 100644 --- a/helm-chart/workspace-mcp/values.yaml +++ b/helm-chart/workspace-mcp/values.yaml @@ -80,6 +80,10 @@ env: # For external access: "https://your-domain.com" or "http://your-ingress-host" WORKSPACE_MCP_BASE_URI: "" + # External URL for reverse proxy setups (e.g., "https://your-domain.com") + # If set, this overrides the base_uri:port combination for OAuth endpoints + WORKSPACE_EXTERNAL_URL: "" + # OAuth 2.1 support MCP_ENABLE_OAUTH21: "false" diff --git a/main.py b/main.py index 9a9a8dd..965d0bc 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ from importlib import metadata from dotenv import load_dotenv from auth.oauth_config import reload_oauth_config +from core.log_formatter import EnhancedLogFormatter 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 @@ -58,11 +59,33 @@ def safe_print(text): 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 ['', '']: + safe_formatter = SafeEnhancedFormatter(use_colors=True) + handler.setFormatter(safe_formatter) + def main(): """ Main entry point for the Google Workspace MCP server. Uses FastMCP's native streamable-http transport. """ + # 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', @@ -79,6 +102,8 @@ def main(): # 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") + 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) @@ -90,8 +115,8 @@ def main(): safe_print(f" 📦 Version: {version}") safe_print(f" 🌐 Transport: {args.transport}") if args.transport == 'streamable-http': - safe_print(f" 🔗 URL: {base_uri}:{port}") - safe_print(f" 🔐 OAuth Callback: {base_uri}:{port}/oauth2callback") + 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'}") safe_print(f" 🐍 Python: {sys.version.split()[0]}") safe_print("") @@ -225,6 +250,8 @@ def main(): 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") @@ -232,7 +259,7 @@ def main(): 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 {base_uri}:{port}/oauth2callback") + safe_print(f" OAuth callback server started on {display_url}/oauth2callback") else: warning_msg = " ⚠️ Warning: Failed to start OAuth callback server" if error_msg: diff --git a/manifest.json b/manifest.json index 199e57e..bad821f 100644 --- a/manifest.json +++ b/manifest.json @@ -34,6 +34,7 @@ "GOOGLE_CLIENT_SECRETS": "${user_config.GOOGLE_CLIENT_SECRETS}", "WORKSPACE_MCP_BASE_URI": "${user_config.WORKSPACE_MCP_BASE_URI}", "WORKSPACE_MCP_PORT": "${user_config.WORKSPACE_MCP_PORT}", + "WORKSPACE_EXTERNAL_URL": "${user_config.WORKSPACE_EXTERNAL_URL}", "OAUTHLIB_INSECURE_TRANSPORT": "${user_config.OAUTHLIB_INSECURE_TRANSPORT}", "GOOGLE_PSE_API_KEY": "${user_config.GOOGLE_PSE_API_KEY}", "GOOGLE_PSE_ENGINE_ID": "${user_config.GOOGLE_PSE_ENGINE_ID}" @@ -186,6 +187,16 @@ "max": 65535 } }, + "WORKSPACE_EXTERNAL_URL": { + "type": "string", + "title": "External URL", + "description": "External URL for reverse proxy setups (e.g., https://your-domain.com). Overrides base_uri:port for OAuth endpoints", + "required": false, + "sensitive": false, + "validation": { + "pattern": "^https?://[a-zA-Z0-9.-]+(:[0-9]+)?$" + } + }, "OAUTHLIB_INSECURE_TRANSPORT": { "type": "boolean", "title": "OAuth Insecure Transport", diff --git a/pyproject.toml b/pyproject.toml index b1274f9..77496b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "workspace-mcp" -version = "1.4.2" +version = "1.4.3" description = "Comprehensive, highly performant Google Workspace Streamable HTTP & SSE MCP Server for Calendar, Gmail, Docs, Sheets, Slides & Drive" readme = "README.md" keywords = [ "mcp", "google", "workspace", "llm", "ai", "claude", "model", "context", "protocol", "server"] requires-python = ">=3.10" dependencies = [ "fastapi>=0.115.12", - "fastmcp==2.11.1", + "fastmcp==2.11.3", "google-api-python-client>=2.168.0", "google-auth-httplib2>=0.2.0", "google-auth-oauthlib>=1.2.2", diff --git a/smithery.yaml b/smithery.yaml index 4aee08c..422057b 100644 --- a/smithery.yaml +++ b/smithery.yaml @@ -85,6 +85,7 @@ startCommand: WORKSPACE_MCP_BASE_URI: config.workspaceMcpBaseUri, WORKSPACE_MCP_PORT: String(config.workspaceMcpPort), PORT: String(config.workspaceMcpPort), + ...(config.workspaceExternalUrl && { WORKSPACE_EXTERNAL_URL: config.workspaceExternalUrl }), ...(config.mcpSingleUserMode && { MCP_SINGLE_USER_MODE: '1' }), ...(config.mcpEnableOauth21 && { MCP_ENABLE_OAUTH21: 'true' }), ...(config.oauthlibInsecureTransport && { OAUTHLIB_INSECURE_TRANSPORT: '1' }), diff --git a/uv.lock b/uv.lock index 7e5b3dc..6f5127e 100644 --- a/uv.lock +++ b/uv.lock @@ -465,7 +465,7 @@ wheels = [ [[package]] name = "fastmcp" -version = "2.11.1" +version = "2.11.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "authlib" }, @@ -480,9 +480,9 @@ dependencies = [ { name = "python-dotenv" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/89/d100073d15cdfa5fa029107b44ef55916b04ed6010ff2b0f7bed92a35ed9/fastmcp-2.11.1.tar.gz", hash = "sha256:2b5af21b093d4926fef17a9a162d5729a2fcb46f3b195699762fa01f61ac3c60", size = 2672724, upload_time = "2025-08-04T15:39:29.623Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/80/13aec687ec21727b0fe6d26c6fe2febb33ae24e24c980929a706db3a8bc2/fastmcp-2.11.3.tar.gz", hash = "sha256:e8e3834a3e0b513712b8e63a6f0d4cbe19093459a1da3f7fbf8ef2810cfd34e3", size = 2692092, upload_time = "2025-08-11T21:38:46.493Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/9f/f3703867a8be93f2a139f6664fa7ff46c5c844e28998ce288f7b919ed197/fastmcp-2.11.1-py3-none-any.whl", hash = "sha256:9f0b6a3f61dcf6f688a0a24b8b507be24bfae051a00b7d590c01395d63da8c00", size = 256573, upload_time = "2025-08-04T15:39:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/61/05/63f63ad5b6789a730d94b8cb3910679c5da1ed5b4e38c957140ac9edcf0e/fastmcp-2.11.3-py3-none-any.whl", hash = "sha256:28f22126c90fd36e5de9cc68b9c271b6d832dcf322256f23d220b68afb3352cc", size = 260231, upload_time = "2025-08-11T21:38:44.746Z" }, ] [[package]] @@ -2089,7 +2089,7 @@ requires-dist = [ { name = "cachetools", specifier = ">=5.3.0" }, { name = "cryptography", specifier = ">=41.0.0" }, { name = "fastapi", specifier = ">=0.115.12" }, - { name = "fastmcp", specifier = "==2.11.1" }, + { name = "fastmcp", specifier = "==2.11.3" }, { name = "google-api-python-client", specifier = ">=2.168.0" }, { name = "google-auth-httplib2", specifier = ">=0.2.0" }, { name = "google-auth-oauthlib", specifier = ">=1.2.2" },