diff --git a/Dockerfile b/Dockerfile index 5a7fbab3..6f786bf3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,45 +1,29 @@ FROM python:3.11-slim - WORKDIR /app - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - curl \ - && rm -rf /var/lib/apt/lists/* - # Install uv for faster dependency management RUN pip install --no-cache-dir uv - COPY . . - # Install Python dependencies using uv sync 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) EXPOSE 8000 # Expose additional port if PORT environment variable is set to a different value ARG PORT EXPOSE ${PORT:-8000} - -# Health check +# Health check using Python's urllib (no external dependencies needed) HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD sh -c 'curl -f http://localhost:${PORT:-8000}/health || exit 1' - + CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()" || exit 1 # Set environment variables for Python startup args ENV TOOL_TIER="" ENV TOOLS="" - # Use entrypoint for the base command and CMD for args ENTRYPOINT ["/bin/sh", "-c"] CMD ["uv run main.py --transport streamable-http ${TOOL_TIER:+--tool-tier \"$TOOL_TIER\"} ${TOOLS:+--tools $TOOLS}"] diff --git a/auth/service_decorator.py b/auth/service_decorator.py index b1b3ccc0..3a9b9be7 100644 --- a/auth/service_decorator.py +++ b/auth/service_decorator.py @@ -285,13 +285,22 @@ async def get_authenticated_google_service_oauth21( return service, user_google_email -def _extract_oauth21_user_email(authenticated_user: Optional[str], func_name: str) -> str: +def _extract_oauth21_user_email( + authenticated_user: Optional[str], + func_name: str, + args: tuple = (), + kwargs: dict = {}, + wrapper_sig: Optional[inspect.Signature] = None +) -> str: """ Extract user email for OAuth 2.1 mode. Args: authenticated_user: The authenticated user from context func_name: Name of the function being decorated (for error messages) + args: Positional arguments (for external OAuth mode) + kwargs: Keyword arguments (for external OAuth mode) + wrapper_sig: Function signature (for external OAuth mode) Returns: User email string @@ -299,6 +308,21 @@ def _extract_oauth21_user_email(authenticated_user: Optional[str], func_name: st Raises: Exception: If no authenticated user found in OAuth 2.1 mode """ + # When using external OAuth provider, authenticated_user comes from protocol-level auth + # But if protocol-level auth is disabled, we need to extract from function parameters + config = get_oauth_config() + if config.is_external_oauth21_provider() and not authenticated_user: + # External OAuth mode without protocol-level auth - extract from parameters + if wrapper_sig: + return _extract_oauth20_user_email(args, kwargs, wrapper_sig) + # Fallback: try to get from kwargs directly + if "user_google_email" in kwargs: + return kwargs["user_google_email"] + raise Exception( + f"OAuth 2.1 external provider mode requires user_google_email parameter for {func_name}" + ) + + # Standard OAuth 2.1 mode - requires authenticated user from context if not authenticated_user: raise Exception( f"OAuth 2.1 mode requires an authenticated user for {func_name}, but none was found." @@ -522,16 +546,17 @@ def decorator(func: Callable) -> Callable: ) # Create a new signature for the wrapper that excludes the 'service' parameter. - # In OAuth 2.1 mode, also exclude 'user_google_email' since it's automatically determined. - if is_oauth21_enabled(): - # Remove both 'service' and 'user_google_email' parameters + # In OAuth 2.1 mode with external provider, keep user_google_email parameter + config = get_oauth_config() + if is_oauth21_enabled() and not config.is_external_oauth21_provider(): + # Standard OAuth 2.1: Remove both 'service' and 'user_google_email' parameters filtered_params = [ p for p in params[1:] if p.name != 'user_google_email' ] wrapper_sig = original_sig.replace(parameters=filtered_params) else: - # Only remove 'service' parameter for OAuth 2.0 mode + # OAuth 2.0 or External OAuth 2.1: Only remove 'service' parameter wrapper_sig = original_sig.replace(parameters=params[1:]) @wraps(func) @@ -546,7 +571,9 @@ async def wrapper(*args, **kwargs): # Extract user_google_email based on OAuth mode if is_oauth21_enabled(): - user_google_email = _extract_oauth21_user_email(authenticated_user, func.__name__) + user_google_email = _extract_oauth21_user_email( + authenticated_user, func.__name__, args, kwargs, wrapper_sig + ) else: user_google_email = _extract_oauth20_user_email(args, kwargs, wrapper_sig) @@ -658,8 +685,10 @@ async def get_doc_with_metadata(drive_service, docs_service, user_google_email: def decorator(func: Callable) -> Callable: original_sig = inspect.signature(func) - # In OAuth 2.1 mode, remove user_google_email from the signature - if is_oauth21_enabled(): + # In OAuth 2.1 mode with external provider, keep user_google_email parameter + config = get_oauth_config() + if is_oauth21_enabled() and not config.is_external_oauth21_provider(): + # Standard OAuth 2.1: Remove user_google_email from signature params = list(original_sig.parameters.values()) filtered_params = [ p for p in params @@ -667,6 +696,7 @@ def decorator(func: Callable) -> Callable: ] wrapper_sig = original_sig.replace(parameters=filtered_params) else: + # OAuth 2.0 or External OAuth 2.1: Keep original signature wrapper_sig = original_sig @wraps(func) @@ -677,7 +707,9 @@ async def wrapper(*args, **kwargs): # Extract user_google_email based on OAuth mode if is_oauth21_enabled(): - user_google_email = _extract_oauth21_user_email(authenticated_user, tool_name) + user_google_email = _extract_oauth21_user_email( + authenticated_user, tool_name, args, kwargs, wrapper_sig + ) else: # OAuth 2.0 mode: extract from arguments (original logic) param_names = list(original_sig.parameters.keys()) @@ -778,6 +810,3 @@ async def wrapper(*args, **kwargs): return wrapper return decorator - - - diff --git a/core/server.py b/core/server.py index 86efb2a7..d7098f7a 100644 --- a/core/server.py +++ b/core/server.py @@ -110,6 +110,8 @@ def configure_server_for_http(): ) # Disable protocol-level auth, expect bearer tokens in tool calls server.auth = None + # Register legacy callback route for external OAuth mode + _ensure_legacy_callback_route() logger.info("OAuth 2.1 enabled with EXTERNAL provider mode - protocol-level auth disabled") logger.info("Expecting Authorization bearer tokens in tool call headers") else: diff --git a/docker-compose.yml b/docker-compose.yml index 425c45ae..5ed7c412 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,13 @@ services: - gws_mcp: + gws_mcp_original: build: . - container_name: gws_mcp + container_name: gws_mcp_original ports: - "8000:8000" environment: - GOOGLE_MCP_CREDENTIALS_DIR=/app/store_creds + - TOOLS=sheets drive calendar + - OAUTHLIB_INSECURE_TRANSPORT=1 volumes: - ./client_secret.json:/app/client_secret.json:ro - store_creds:/app/store_creds:rw @@ -13,4 +15,4 @@ services: - .env volumes: - store_creds: \ No newline at end of file + store_creds: diff --git a/main.py b/main.py index 35497ed1..23faff63 100644 --- a/main.py +++ b/main.py @@ -213,6 +213,9 @@ def main(): safe_print(f" 📝 Log Level: {logging.getLogger().getEffectiveLevel()}") safe_print("") + # import pdb + # pdb.set_trace() + # Set global single-user mode flag if args.single_user: if is_stateless_mode(): diff --git a/sheetsTest1_list_spreadsheets.py b/sheetsTest1_list_spreadsheets.py new file mode 100644 index 00000000..010e1185 --- /dev/null +++ b/sheetsTest1_list_spreadsheets.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +""" +Simple MCP client to test Google Sheets tools. +Uses FastMCP's Client class to connect to the running MCP server. + +Usage: + python list_spreadsheets.py + +Example: + python list_spreadsheets.py robert.smith97879@gmail.com +""" + +import asyncio +import sys +from fastmcp import Client + + +async def test_sheets_tools(user_email: str): + """Test Google Sheets tools using FastMCP Client.""" + + print("\n" + "="*70) + print("Google Sheets MCP Client Test") + print("="*70) + print(f"Email: {user_email}") + print(f"Server: http://localhost:8000/mcp") + print("="*70 + "\n") + + # Connect to the MCP server via SSE transport + async with Client("http://localhost:8000/mcp") as client: + + # Test: List spreadsheets + print("\n" + "="*70) + print("TEST: List Spreadsheets") + print("="*70) + try: + result = await client.call_tool("list_spreadsheets", { + "user_google_email": user_email + }) + print("Success!") + for content in result.content: + print(content.text) + except Exception as e: + print(f"Error: {e}") + if "Authorization URL" in str(e) or "ACTION REQUIRED" in str(e): + print("\nAuthentication required. Check the server output for the authorization URL.") + return + + print("\n" + "="*70) + print("Test suite completed!") + print("="*70 + "\n") + + +def main(): + """Main entry point.""" + if len(sys.argv) < 2: + print("Error: Please provide your Google email address") + print(f"\nUsage: python {sys.argv[0]} ") + print("\nExample:") + print(f" python {sys.argv[0]} robert.smith97879@gmail.com") + sys.exit(1) + + user_email = sys.argv[1] + + # Run the async test function + asyncio.run(test_sheets_tools(user_email)) + + +if __name__ == "__main__": + main()