From fe308a6e1ac936e2f686344edc2ac8512b655ba9 Mon Sep 17 00:00:00 2001 From: Maska Chung Date: Fri, 23 Jan 2026 01:15:57 +0000 Subject: [PATCH 1/4] added auth for mcp client --- src/humcp/auth.py | 201 +++++++++++++++++++++++++++++++++++ src/humcp/server.py | 14 ++- src/tools/google/auth.py | 197 ++++++---------------------------- src/tools/google/calendar.py | 15 ++- src/tools/google/chat.py | 15 ++- src/tools/google/docs.py | 17 ++- src/tools/google/drive.py | 14 +-- src/tools/google/forms.py | 16 ++- src/tools/google/gmail.py | 14 +-- src/tools/google/sheets.py | 21 ++-- src/tools/google/slides.py | 17 ++- src/tools/google/tasks.py | 25 ++--- 12 files changed, 305 insertions(+), 261 deletions(-) create mode 100644 src/humcp/auth.py diff --git a/src/humcp/auth.py b/src/humcp/auth.py new file mode 100644 index 0000000..6875597 --- /dev/null +++ b/src/humcp/auth.py @@ -0,0 +1,201 @@ +import base64 +import binascii +import logging +import os +import hmac +import time +from urllib.parse import unquote + +from dotenv import load_dotenv +from fastmcp.server.auth.providers.google import GoogleProvider + +from mcp.server.auth.middleware.client_auth import ClientAuthenticator +from mcp.server.auth.handlers.token import TokenHandler +from mcp.server.auth.middleware.client_auth import AuthenticationError + +load_dotenv() + +logger = logging.getLogger("humcp.auth") + +# Enable debug logging for OAuth to diagnose authentication issues +logging.getLogger("fastmcp.server.auth").setLevel(logging.DEBUG) +logging.getLogger("mcp.server.auth").setLevel(logging.DEBUG) +logging.getLogger("mcp.server.auth.middleware.client_auth").setLevel(logging.DEBUG) +logging.getLogger("mcp.server.auth.handlers.token").setLevel(logging.DEBUG) + + +def apply_authentication_patches(): + """Apply monkey patches to support Postman's Basic Auth behavior. + + Postman sends client_id in Authorization header but not in form data. + These patches extract the client_id from the Basic Auth header when needed. + """ + _original_authenticate = ClientAuthenticator.authenticate_request + + async def patched_authenticate_request(self, request): + """Patched authenticate_request that extracts client_id from Basic Auth header if missing.""" + form_data = await request.form() + client_id = form_data.get("client_id") + + # If client_id is missing from form data, try to extract from Authorization header + if not client_id: + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Basic "): + try: + encoded_credentials = auth_header[6:] + decoded = base64.b64decode(encoded_credentials).decode("utf-8") + if ":" in decoded: + basic_client_id, _ = decoded.split(":", 1) + client_id = unquote(basic_client_id) + logger.info("Extracted client_id from Basic Auth header: %s", client_id) + except (ValueError, UnicodeDecodeError, binascii.Error) as e: + logger.warning("Failed to extract client_id from Basic Auth header: %s", e) + + if not client_id: + raise AuthenticationError("Missing client_id") + + client = await self.provider.get_client(str(client_id)) + if not client: + raise AuthenticationError("Invalid client_id") + + request_client_secret: str | None = None + auth_header = request.headers.get("Authorization", "") + + if client.token_endpoint_auth_method == "client_secret_basic": + if not auth_header.startswith("Basic "): + raise AuthenticationError("Missing or invalid Basic authentication in Authorization header") + + try: + encoded_credentials = auth_header[6:] + decoded = base64.b64decode(encoded_credentials).decode("utf-8") + if ":" not in decoded: + raise ValueError("Invalid Basic auth format") + basic_client_id, request_client_secret = decoded.split(":", 1) + + basic_client_id = unquote(basic_client_id) + request_client_secret = unquote(request_client_secret) + + if basic_client_id != client_id: + raise AuthenticationError("Client ID mismatch in Basic auth") + except (ValueError, UnicodeDecodeError, binascii.Error): + raise AuthenticationError("Invalid Basic authentication header") + + elif client.token_endpoint_auth_method == "client_secret_post": + raw_form_data = form_data.get("client_secret") + if isinstance(raw_form_data, str): + request_client_secret = str(raw_form_data) + + elif client.token_endpoint_auth_method == "none": + request_client_secret = None + else: + raise AuthenticationError( + f"Unsupported auth method: {client.token_endpoint_auth_method}" + ) + + # Validate client secret if required + if client.client_secret: + if not request_client_secret: + raise AuthenticationError("Client secret is required") + + if not hmac.compare_digest(client.client_secret.encode(), request_client_secret.encode()): + raise AuthenticationError("Invalid client_secret") + + if client.client_secret_expires_at and client.client_secret_expires_at < int(time.time()): + raise AuthenticationError("Client secret has expired") + + return client + + ClientAuthenticator.authenticate_request = patched_authenticate_request + + # Also patch TokenHandler to add client_id to form data for Pydantic validation + _original_token_handle = TokenHandler.handle + + async def patched_token_handle(self, request): + """Patched token handler that adds client_id to form data if missing.""" + # Check if client_id is missing from form data but present in Basic Auth header + form_data = await request.form() + if not form_data.get("client_id"): + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Basic "): + try: + encoded_credentials = auth_header[6:] + decoded = base64.b64decode(encoded_credentials).decode("utf-8") + if ":" in decoded: + basic_client_id, _ = decoded.split(":", 1) + client_id = unquote(basic_client_id) + + # Add client_id to form data dict for Pydantic validation + from starlette.datastructures import FormData as StarletteFormData + mutable_form = dict(form_data) + mutable_form["client_id"] = client_id + + # Create a new FormData object with the client_id added + request._form = StarletteFormData(mutable_form) + + logger.debug("Added client_id to form data for Pydantic validation: %s", client_id) + except (ValueError, UnicodeDecodeError, binascii.Error) as e: + logger.warning("Failed to extract client_id for form validation: %s", e) + + # Call original handle method + return await _original_token_handle(self, request) + + TokenHandler.handle = patched_token_handle + logger.info("Applied authentication patches for Postman compatibility") + + +def create_auth_provider() -> GoogleProvider: + """Create and configure the Google OAuth authentication provider. + + Returns: + Configured GoogleProvider instance. + + Raises: + ValueError: If required environment variables are not set. + """ + # Apply patches before creating provider + apply_authentication_patches() + + client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID") + client_secret = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET") + + if not client_id or not client_secret: + raise ValueError( + "GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET must be set in environment" + ) + + auth_provider = GoogleProvider( + client_id=client_id, + client_secret=client_secret, + base_url="http://localhost:8080", + required_scopes=[ + # OpenID Connect + "openid", + "https://www.googleapis.com/auth/userinfo.email", + # Gmail + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/gmail.modify", + # Calendar + "https://www.googleapis.com/auth/calendar", + # Drive + "https://www.googleapis.com/auth/drive", + # Tasks + "https://www.googleapis.com/auth/tasks", + # Docs + "https://www.googleapis.com/auth/documents", + # Sheets + "https://www.googleapis.com/auth/spreadsheets", + # Slides + "https://www.googleapis.com/auth/presentations", + # Forms + "https://www.googleapis.com/auth/forms.body", + "https://www.googleapis.com/auth/forms.responses.readonly", + # Chat + "https://www.googleapis.com/auth/chat.spaces.readonly", + "https://www.googleapis.com/auth/chat.messages", + ], + allowed_client_redirect_uris=["https://oauth.pstmn.io/v1/callback", "http://localhost:*"], + ) + + logger.info("Created Google OAuth provider") + return auth_provider diff --git a/src/humcp/server.py b/src/humcp/server.py index a19bb9e..b03a6fb 100644 --- a/src/humcp/server.py +++ b/src/humcp/server.py @@ -8,15 +8,17 @@ from contextlib import asynccontextmanager from pathlib import Path from typing import Any +from dotenv import load_dotenv from fastapi import FastAPI from fastmcp import FastMCP +from src.humcp.auth import create_auth_provider from src.humcp.registry import TOOL_REGISTRY from src.humcp.routes import build_openapi_tags, register_routes logger = logging.getLogger("humcp") - +auth_provider = create_auth_provider() def _discover_tools(tools_path: Path) -> int: """Auto-discover and import tool modules from a directory. @@ -90,7 +92,7 @@ def create_app( logger.info("Discovered %d tool modules from %s", loaded, path) # Create MCP server - mcp = FastMCP("HuMCP Server") + mcp = FastMCP("HuMCP Server", auth=auth_provider) seen: set[Callable[..., Any]] = set() for reg in TOOL_REGISTRY: if reg.func not in seen: @@ -133,6 +135,14 @@ async def root(): "endpoints": {"docs": "/docs", "tools": "/tools", "mcp": "/mcp"}, } + # Mount ALL OAuth routes at root level + # This includes: /.well-known/*, /authorize, /token, /register, /auth/callback, /consent + if auth_provider: + oauth_routes = auth_provider.get_routes(mcp_path="/mcp") + for route in oauth_routes: + app.routes.append(route) + logger.info("Mounted %d OAuth routes at root level", len(oauth_routes)) + # Mount MCP app.mount("/mcp", mcp_http_app) return app diff --git a/src/tools/google/auth.py b/src/tools/google/auth.py index 20209d5..6867c4b 100644 --- a/src/tools/google/auth.py +++ b/src/tools/google/auth.py @@ -3,189 +3,52 @@ import os from pathlib import Path -from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials -from google_auth_oauthlib.flow import InstalledAppFlow from googleapiclient.discovery import build logger = logging.getLogger("humcp.google.auth") -TOKEN_PATH = Path.home() / ".humcp" / "google_token.json" -CLIENT_SECRET_PATH = Path.home() / ".humcp" / "client_secret.json" +def get_google_service_from_mcp(service_name: str, version: str): + """Build Google API service using the MCP session's access token. -SCOPES = { - # Gmail - "gmail_readonly": "https://www.googleapis.com/auth/gmail.readonly", - "gmail_send": "https://www.googleapis.com/auth/gmail.send", - "gmail_modify": "https://www.googleapis.com/auth/gmail.modify", - # Calendar - "calendar": "https://www.googleapis.com/auth/calendar", - "calendar_readonly": "https://www.googleapis.com/auth/calendar.readonly", - # Drive - "drive": "https://www.googleapis.com/auth/drive", - "drive_readonly": "https://www.googleapis.com/auth/drive.readonly", - "drive_file": "https://www.googleapis.com/auth/drive.file", - # Tasks - "tasks": "https://www.googleapis.com/auth/tasks", - "tasks_readonly": "https://www.googleapis.com/auth/tasks.readonly", - # Docs - "docs": "https://www.googleapis.com/auth/documents", - "docs_readonly": "https://www.googleapis.com/auth/documents.readonly", - # Sheets - "sheets": "https://www.googleapis.com/auth/spreadsheets", - "sheets_readonly": "https://www.googleapis.com/auth/spreadsheets.readonly", - # Slides - "slides": "https://www.googleapis.com/auth/presentations", - "slides_readonly": "https://www.googleapis.com/auth/presentations.readonly", - # Forms - "forms": "https://www.googleapis.com/auth/forms.body", - "forms_readonly": "https://www.googleapis.com/auth/forms.body.readonly", - "forms_responses": "https://www.googleapis.com/auth/forms.responses.readonly", - # Chat - "chat_spaces": "https://www.googleapis.com/auth/chat.spaces.readonly", - "chat_messages": "https://www.googleapis.com/auth/chat.messages", - "chat_messages_readonly": "https://www.googleapis.com/auth/chat.messages.readonly", -} + This function gets the access token from the authenticated MCP session + instead of using a separate OAuth flow. The user authenticates once + when connecting to the MCP server, and that token is reused for all + Google API calls. + Args: + service_name: Google API service name (e.g., 'calendar', 'gmail') + version: API version (e.g., 'v3', 'v1') -def _ensure_config_dir() -> None: - """Ensure the config directory exists.""" - TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True) + Returns: + Authenticated Google API service client + Raises: + ValueError: If unable to get access token from MCP session + """ + try: + from fastmcp.server.dependencies import get_access_token -def load_client_config() -> dict | None: - """Load OAuth client configuration from environment or file.""" - client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID") - client_secret = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET") - - if client_id and client_secret: - logger.debug("Using OAuth credentials from environment variables") - return { - "installed": { - "client_id": client_id, - "client_secret": client_secret, - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "redirect_uris": ["http://localhost"], - } - } - - if CLIENT_SECRET_PATH.exists(): - logger.debug("Using OAuth credentials from %s", CLIENT_SECRET_PATH) - with open(CLIENT_SECRET_PATH) as f: - return json.load(f) - - return None - - -def get_credentials(scopes: list[str]) -> Credentials | None: - """Get valid credentials, refreshing or re-authenticating as needed.""" - _ensure_config_dir() - creds = None - - # Load existing token if available - if TOKEN_PATH.exists(): - try: - creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), scopes) - logger.debug("Loaded existing credentials from token file") - except Exception as e: - logger.warning("Failed to load existing token: %s", e) - creds = None - - # Refresh or get new credentials - if creds and creds.expired and creds.refresh_token: - try: - logger.info("Refreshing expired credentials") - creds.refresh(Request()) - _save_credentials(creds) - except Exception as e: - logger.warning("Failed to refresh token: %s", e) - creds = None + # Get the access token from the MCP session + access_token = get_access_token() - if not creds or not creds.valid: - creds = _run_auth_flow(scopes) + if not access_token or not access_token.token: + raise ValueError("No access token available in MCP session") - return creds + # Create credentials from the access token + creds = Credentials(token=access_token.token) + # Build and return the Google API service + return build(service_name, version, credentials=creds) -def _run_auth_flow(scopes: list[str]) -> Credentials | None: - """Run the OAuth authorization flow.""" - client_config = load_client_config() - if not client_config: - logger.error( - "Google OAuth not configured. Set GOOGLE_OAUTH_CLIENT_ID and " - "GOOGLE_OAUTH_CLIENT_SECRET environment variables, or create " - "%s", - CLIENT_SECRET_PATH, + except ImportError: + raise ValueError( + "FastMCP dependencies not available. This function must be called " + "from within an MCP tool context." ) - return None - - try: - # Allow http for localhost during development - os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" - - flow = InstalledAppFlow.from_client_config(client_config, scopes) - logger.info("Opening browser for Google authentication...") - creds = flow.run_local_server(port=0) - - _save_credentials(creds) - logger.info("Authentication successful") - return creds - except Exception as e: - logger.error("OAuth flow failed: %s", e) - return None - - -def _save_credentials(creds: Credentials) -> None: - """Save credentials to token file.""" - _ensure_config_dir() - with open(TOKEN_PATH, "w") as f: - f.write(creds.to_json()) - logger.debug("Credentials saved to %s", TOKEN_PATH) - - -def get_google_service(service_name: str, version: str, scopes: list[str]): - """Build an authenticated Google API service client.""" - creds = get_credentials(scopes) - if not creds: + logger.error("Failed to build Google service from MCP token: %s", e) raise ValueError( - "Google authentication failed. Please configure OAuth credentials:\n" - "1. Set GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET env vars\n" - "2. Or create ~/.humcp/client_secret.json with OAuth credentials" + f"Failed to authenticate with Google using MCP session: {e}" ) - return build(service_name, version, credentials=creds) - - -def check_auth_status() -> dict: - """Check current authentication status.""" - config = load_client_config() - has_config = config is not None - has_token = TOKEN_PATH.exists() - - status = { - "configured": has_config, - "authenticated": False, - "token_path": str(TOKEN_PATH), - "config_source": None, - } - - if has_config: - if os.getenv("GOOGLE_OAUTH_CLIENT_ID"): - status["config_source"] = "environment" - else: - status["config_source"] = str(CLIENT_SECRET_PATH) - - if has_token: - try: - creds = Credentials.from_authorized_user_file(str(TOKEN_PATH)) - status["authenticated"] = creds.valid or creds.refresh_token is not None - if creds.expired: - status["token_status"] = "expired (will refresh)" - elif creds.valid: - status["token_status"] = "valid" - except Exception: - status["token_status"] = "invalid" - - return status diff --git a/src/tools/google/calendar.py b/src/tools/google/calendar.py index e506313..c3ce6cc 100644 --- a/src/tools/google/calendar.py +++ b/src/tools/google/calendar.py @@ -5,14 +5,10 @@ from datetime import UTC, datetime, timedelta from src.humcp.decorator import tool -from src.tools.google.auth import SCOPES, get_google_service +from src.tools.google.auth import get_google_service_from_mcp logger = logging.getLogger("humcp.tools.google.calendar") -# Scopes required for Calendar operations -CALENDAR_READONLY_SCOPES = [SCOPES["calendar_readonly"]] -CALENDAR_FULL_SCOPES = [SCOPES["calendar"]] - @tool("google_calendar_list") async def list_calendars() -> dict: @@ -28,7 +24,8 @@ async def list_calendars() -> dict: try: def _list(): - service = get_google_service("calendar", "v3", CALENDAR_READONLY_SCOPES) + # Use MCP session token instead of separate OAuth flow + service = get_google_service_from_mcp("calendar", "v3") results = service.calendarList().list().execute() calendars = results.get("items", []) return { @@ -75,7 +72,7 @@ async def events( try: def _list_events(): - service = get_google_service("calendar", "v3", CALENDAR_READONLY_SCOPES) + service = get_google_service_from_mcp("calendar", "v3") now = datetime.now(UTC) time_min = now.isoformat() @@ -155,7 +152,7 @@ async def create_event( try: def _create(): - service = get_google_service("calendar", "v3", CALENDAR_FULL_SCOPES) + service = get_google_service_from_mcp("calendar", "v3") event_body = { "summary": title, @@ -211,7 +208,7 @@ async def delete_event( try: def _delete(): - service = get_google_service("calendar", "v3", CALENDAR_FULL_SCOPES) + service = get_google_service_from_mcp("calendar", "v3") service.events().delete(calendarId=calendar_id, eventId=event_id).execute() return {"deleted_event_id": event_id} diff --git a/src/tools/google/chat.py b/src/tools/google/chat.py index b367be2..6a14eb1 100644 --- a/src/tools/google/chat.py +++ b/src/tools/google/chat.py @@ -4,13 +4,10 @@ import logging from src.humcp.decorator import tool -from src.tools.google.auth import SCOPES, get_google_service +from src.tools.google.auth import get_google_service_from_mcp logger = logging.getLogger("humcp.tools.google.chat") -CHAT_READONLY_SCOPES = [SCOPES["chat_spaces"], SCOPES["chat_messages_readonly"]] -CHAT_FULL_SCOPES = [SCOPES["chat_spaces"], SCOPES["chat_messages"]] - @tool("google_chat_list_spaces") async def list_spaces(space_type: str = "all", max_results: int = 100) -> dict: @@ -28,7 +25,7 @@ async def list_spaces(space_type: str = "all", max_results: int = 100) -> dict: try: def _list(): - service = get_google_service("chat", "v1", CHAT_READONLY_SCOPES) + service = get_google_service_from_mcp("chat", "v3") results = service.spaces().list(pageSize=max_results).execute() spaces = results.get("spaces", []) @@ -72,7 +69,7 @@ async def get_space(space_name: str) -> dict: try: def _get(): - service = get_google_service("chat", "v1", CHAT_READONLY_SCOPES) + service = get_google_service_from_mcp("chat", "v") space = service.spaces().get(name=space_name).execute() return { @@ -113,7 +110,7 @@ async def get_messages( try: def _get(): - service = get_google_service("chat", "v1", CHAT_READONLY_SCOPES) + service = get_google_service_from_mcp("chat", "v3") results = ( service.spaces() .messages() @@ -158,7 +155,7 @@ async def get_message(message_name: str) -> dict: try: def _get(): - service = get_google_service("chat", "v1", CHAT_READONLY_SCOPES) + service = get_google_service_from_mcp("chat", "v3") message = service.spaces().messages().get(name=message_name).execute() return { @@ -200,7 +197,7 @@ async def send_message( try: def _send(): - service = get_google_service("chat", "v1", CHAT_FULL_SCOPES) + service = get_google_service_from_mcp("chat", "v3") body = {"text": text} kwargs = {"parent": space_name, "body": body} diff --git a/src/tools/google/docs.py b/src/tools/google/docs.py index b3097eb..47d6777 100644 --- a/src/tools/google/docs.py +++ b/src/tools/google/docs.py @@ -4,13 +4,10 @@ import logging from src.humcp.decorator import tool -from src.tools.google.auth import SCOPES, get_google_service +from src.tools.google.auth import get_google_service_from_mcp logger = logging.getLogger("humcp.tools.google.docs") -DOCS_READONLY_SCOPES = [SCOPES["docs_readonly"], SCOPES["drive_readonly"]] -DOCS_FULL_SCOPES = [SCOPES["docs"], SCOPES["drive"]] - @tool("google_docs_search") async def search_docs(query: str, max_results: int = 25) -> dict: @@ -28,7 +25,7 @@ async def search_docs(query: str, max_results: int = 25) -> dict: try: def _search(): - service = get_google_service("drive", "v3", DOCS_READONLY_SCOPES) + service = get_google_service_from_mcp("drive", "v3") drive_query = ( f"name contains '{query}' and " "mimeType='application/vnd.google-apps.document' and " @@ -80,7 +77,7 @@ async def get_doc_content(document_id: str) -> dict: try: def _get(): - service = get_google_service("docs", "v1", DOCS_READONLY_SCOPES) + service = get_google_service_from_mcp("docs", "v3") doc = service.documents().get(documentId=document_id).execute() # Extract text content @@ -122,7 +119,7 @@ async def create_doc(title: str, content: str = "") -> dict: try: def _create(): - service = get_google_service("docs", "v1", DOCS_FULL_SCOPES) + service = get_google_service_from_mcp("docs", "v3") # Create empty document doc = service.documents().create(body={"title": title}).execute() @@ -165,7 +162,7 @@ async def append_text(document_id: str, text: str) -> dict: try: def _append(): - service = get_google_service("docs", "v1", DOCS_FULL_SCOPES) + service = get_google_service_from_mcp("docs", "v3") # Get current document to find end index doc = service.documents().get(documentId=document_id).execute() @@ -208,7 +205,7 @@ async def find_and_replace( try: def _replace(): - service = get_google_service("docs", "v1", DOCS_FULL_SCOPES) + service = get_google_service_from_mcp("docs", "v3") requests = [ { "replaceAllText": { @@ -259,7 +256,7 @@ async def list_docs_in_folder(folder_id: str, max_results: int = 50) -> dict: try: def _list(): - service = get_google_service("drive", "v3", DOCS_READONLY_SCOPES) + service = get_google_service_from_mcp("docs", "v3") query = ( f"'{folder_id}' in parents and " "mimeType='application/vnd.google-apps.document' and " diff --git a/src/tools/google/drive.py b/src/tools/google/drive.py index 312fd97..63f369f 100644 --- a/src/tools/google/drive.py +++ b/src/tools/google/drive.py @@ -7,13 +7,10 @@ from googleapiclient.http import MediaIoBaseDownload from src.humcp.decorator import tool -from src.tools.google.auth import SCOPES, get_google_service +from src.tools.google.auth import get_google_service_from_mcp logger = logging.getLogger("humcp.tools.google.drive") -# Scopes required for Drive operations -DRIVE_READONLY_SCOPES = [SCOPES["drive_readonly"]] - @tool("google_drive_list") async def list_files( @@ -36,7 +33,7 @@ async def list_files( try: def _list(): - service = get_google_service("drive", "v3", DRIVE_READONLY_SCOPES) + service = get_google_service_from_mcp("drive", "v3") query = f"'{folder_id}' in parents and trashed = false" if file_type: @@ -93,7 +90,7 @@ async def search(query: str, max_results: int = 50) -> dict: try: def _search(): - service = get_google_service("drive", "v3", DRIVE_READONLY_SCOPES) + service = get_google_service_from_mcp("drive", "v3") drive_query = f"fullText contains '{query}' and trashed = false" @@ -153,7 +150,7 @@ async def get_file(file_id: str) -> dict: try: def _get(): - service = get_google_service("drive", "v3", DRIVE_READONLY_SCOPES) + service = get_google_service_from_mcp("drive", "v3") file = ( service.files() @@ -206,8 +203,7 @@ async def read_text_file(file_id: str) -> dict: try: def _read(): - service = get_google_service("drive", "v3", DRIVE_READONLY_SCOPES) - + service = get_google_service_from_mcp("drive", "v3") file = ( service.files().get(fileId=file_id, fields="name, mimeType").execute() ) diff --git a/src/tools/google/forms.py b/src/tools/google/forms.py index eb33673..bb3d208 100644 --- a/src/tools/google/forms.py +++ b/src/tools/google/forms.py @@ -4,14 +4,10 @@ import logging from src.humcp.decorator import tool -from src.tools.google.auth import SCOPES, get_google_service +from src.tools.google.auth import get_google_service_from_mcp logger = logging.getLogger("humcp.tools.google.forms") -FORMS_READONLY_SCOPES = [SCOPES["forms_readonly"], SCOPES["drive_readonly"]] -FORMS_FULL_SCOPES = [SCOPES["forms"], SCOPES["drive"]] -FORMS_RESPONSES_SCOPES = [SCOPES["forms_responses"]] - @tool("google_forms_list_forms") async def list_forms(max_results: int = 25) -> dict: @@ -28,7 +24,7 @@ async def list_forms(max_results: int = 25) -> dict: try: def _list(): - service = get_google_service("drive", "v3", FORMS_READONLY_SCOPES) + service = get_google_service_from_mcp("drive", "v3") query = "mimeType='application/vnd.google-apps.form' and trashed=false" results = ( service.files() @@ -77,7 +73,7 @@ async def get_form(form_id: str) -> dict: try: def _get(): - service = get_google_service("forms", "v1", FORMS_READONLY_SCOPES) + service = get_google_service_from_mcp("forms", "v3") form = service.forms().get(formId=form_id).execute() questions = [] @@ -150,7 +146,7 @@ async def create_form(title: str, document_title: str = "") -> dict: try: def _create(): - service = get_google_service("forms", "v1", FORMS_FULL_SCOPES) + service = get_google_service_from_mcp("forms", "v3") body = { "info": { "title": title, @@ -191,7 +187,7 @@ async def list_form_responses(form_id: str, max_results: int = 100) -> dict: try: def _list(): - service = get_google_service("forms", "v1", FORMS_RESPONSES_SCOPES) + service = get_google_service_from_mcp("forms", "v3") results = ( service.forms() .responses() @@ -237,7 +233,7 @@ async def get_form_response(form_id: str, response_id: str) -> dict: try: def _get(): - service = get_google_service("forms", "v1", FORMS_RESPONSES_SCOPES) + service = get_google_service_from_mcp("forms", "v3") response = ( service.forms() .responses() diff --git a/src/tools/google/gmail.py b/src/tools/google/gmail.py index 96eeff6..4353d79 100644 --- a/src/tools/google/gmail.py +++ b/src/tools/google/gmail.py @@ -6,14 +6,10 @@ from email.mime.text import MIMEText from src.humcp.decorator import tool -from src.tools.google.auth import SCOPES, get_google_service +from src.tools.google.auth import get_google_service_from_mcp logger = logging.getLogger("humcp.tools.google.gmail") -# Scopes required for Gmail operations -GMAIL_READONLY_SCOPES = [SCOPES["gmail_readonly"]] -GMAIL_SEND_SCOPES = [SCOPES["gmail_send"]] - @tool("google_gmail_search") async def search(query: str = "", max_results: int = 10) -> dict: @@ -33,7 +29,7 @@ async def search(query: str = "", max_results: int = 10) -> dict: max_results = min(max_results, 100) def _search(): - service = get_google_service("gmail", "v1", GMAIL_READONLY_SCOPES) + service = get_google_service_from_mcp("gmail", "v3") results = ( service.users() .messages() @@ -94,7 +90,7 @@ async def read(message_id: str) -> dict: try: def _read(): - service = get_google_service("gmail", "v1", GMAIL_READONLY_SCOPES) + service = get_google_service_from_mcp("gmail", "v3") msg = ( service.users() .messages() @@ -171,7 +167,7 @@ async def send( try: def _send(): - service = get_google_service("gmail", "v1", GMAIL_SEND_SCOPES) + service = get_google_service_from_mcp("gmail", "v3") message = MIMEText(body) message["to"] = to @@ -215,7 +211,7 @@ async def labels() -> dict: try: def _list_labels(): - service = get_google_service("gmail", "v1", GMAIL_READONLY_SCOPES) + service = get_google_service_from_mcp("gmail", "v3") results = service.users().labels().list(userId="me").execute() items = results.get("labels", []) return { diff --git a/src/tools/google/sheets.py b/src/tools/google/sheets.py index 05ccb84..791ff96 100644 --- a/src/tools/google/sheets.py +++ b/src/tools/google/sheets.py @@ -4,13 +4,10 @@ import logging from src.humcp.decorator import tool -from src.tools.google.auth import SCOPES, get_google_service +from src.tools.google.auth import get_google_service_from_mcp logger = logging.getLogger("humcp.tools.google.sheets") -SHEETS_READONLY_SCOPES = [SCOPES["sheets_readonly"], SCOPES["drive_readonly"]] -SHEETS_FULL_SCOPES = [SCOPES["sheets"], SCOPES["drive"]] - @tool("google_sheets_list_spreadsheets") async def list_spreadsheets(max_results: int = 25) -> dict: @@ -27,7 +24,7 @@ async def list_spreadsheets(max_results: int = 25) -> dict: try: def _list(): - service = get_google_service("drive", "v3", SHEETS_READONLY_SCOPES) + service = get_google_service_from_mcp("drive", "v3") query = ( "mimeType='application/vnd.google-apps.spreadsheet' and trashed=false" ) @@ -78,7 +75,7 @@ async def get_spreadsheet_info(spreadsheet_id: str) -> dict: try: def _get(): - service = get_google_service("sheets", "v4", SHEETS_READONLY_SCOPES) + service = get_google_service_from_mcp("sheets", "v3") spreadsheet = ( service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute() ) @@ -130,7 +127,7 @@ async def read_sheet_values( try: def _read(): - service = get_google_service("sheets", "v4", SHEETS_READONLY_SCOPES) + service = get_google_service_from_mcp("sheets", "v3") result = ( service.spreadsheets() .values() @@ -176,7 +173,7 @@ async def write_sheet_values( try: def _write(): - service = get_google_service("sheets", "v4", SHEETS_FULL_SCOPES) + service = get_google_service_from_mcp("sheets", "v3") body = {"values": values} result = ( service.spreadsheets() @@ -229,7 +226,7 @@ async def append_sheet_values( try: def _append(): - service = get_google_service("sheets", "v4", SHEETS_FULL_SCOPES) + service = get_google_service_from_mcp("sheets", "v3") body = {"values": values} result = ( service.spreadsheets() @@ -276,7 +273,7 @@ async def create_spreadsheet(title: str, sheet_names: list[str] | None = None) - try: def _create(): - service = get_google_service("sheets", "v4", SHEETS_FULL_SCOPES) + service = get_google_service_from_mcp("sheets", "v3") body = {"properties": {"title": title}} @@ -320,7 +317,7 @@ async def add_sheet(spreadsheet_id: str, sheet_title: str) -> dict: try: def _add(): - service = get_google_service("sheets", "v4", SHEETS_FULL_SCOPES) + service = get_google_service_from_mcp("sheets", "v3") request = {"addSheet": {"properties": {"title": sheet_title}}} result = ( service.spreadsheets() @@ -358,7 +355,7 @@ async def clear_sheet_values(spreadsheet_id: str, range_notation: str) -> dict: try: def _clear(): - service = get_google_service("sheets", "v4", SHEETS_FULL_SCOPES) + service = get_google_service_from_mcp("sheets", "v3") result = ( service.spreadsheets() .values() diff --git a/src/tools/google/slides.py b/src/tools/google/slides.py index 6f6730b..818faca 100644 --- a/src/tools/google/slides.py +++ b/src/tools/google/slides.py @@ -4,13 +4,10 @@ import logging from src.humcp.decorator import tool -from src.tools.google.auth import SCOPES, get_google_service +from src.tools.google.auth import get_google_service_from_mcp logger = logging.getLogger("humcp.tools.google.slides") -SLIDES_READONLY_SCOPES = [SCOPES["slides_readonly"], SCOPES["drive_readonly"]] -SLIDES_FULL_SCOPES = [SCOPES["slides"], SCOPES["drive"]] - @tool("google_slides_list_presentations") async def list_presentations(max_results: int = 25) -> dict: @@ -27,7 +24,7 @@ async def list_presentations(max_results: int = 25) -> dict: try: def _list(): - service = get_google_service("drive", "v3", SLIDES_READONLY_SCOPES) + service = get_google_service_from_mcp("drive", "v3") query = ( "mimeType='application/vnd.google-apps.presentation' and trashed=false" ) @@ -78,7 +75,7 @@ async def get_presentation(presentation_id: str) -> dict: try: def _get(): - service = get_google_service("slides", "v1", SLIDES_READONLY_SCOPES) + service = get_google_service_from_mcp("slides", "v3") presentation = ( service.presentations().get(presentationId=presentation_id).execute() ) @@ -147,7 +144,7 @@ async def create_presentation(title: str) -> dict: try: def _create(): - service = get_google_service("slides", "v1", SLIDES_FULL_SCOPES) + service = get_google_service_from_mcp("slides", "v3") presentation = ( service.presentations().create(body={"title": title}).execute() ) @@ -187,7 +184,7 @@ async def add_slide( try: def _add(): - service = get_google_service("slides", "v1", SLIDES_FULL_SCOPES) + service = get_google_service_from_mcp("slides", "v3") request = { "createSlide": { @@ -253,7 +250,7 @@ async def add_text_to_slide( try: def _add_text(): - service = get_google_service("slides", "v1", SLIDES_FULL_SCOPES) + service = get_google_service_from_mcp("slides", "v3") # Create text box shape shape_id = f"textbox_{slide_id}_{int(x)}_{int(y)}" @@ -327,7 +324,7 @@ async def get_slide_thumbnail( try: def _get_thumbnail(): - service = get_google_service("slides", "v1", SLIDES_READONLY_SCOPES) + service = get_google_service_from_mcp("slides", "v3") size_map = { "SMALL": "SMALL", diff --git a/src/tools/google/tasks.py b/src/tools/google/tasks.py index df04455..3b073ec 100644 --- a/src/tools/google/tasks.py +++ b/src/tools/google/tasks.py @@ -4,13 +4,10 @@ import logging from src.humcp.decorator import tool -from src.tools.google.auth import SCOPES, get_google_service +from src.tools.google.auth import get_google_service_from_mcp logger = logging.getLogger("humcp.tools.google.tasks") -TASKS_READONLY_SCOPES = [SCOPES["tasks_readonly"]] -TASKS_FULL_SCOPES = [SCOPES["tasks"]] - @tool("google_tasks_list_task_lists") async def list_task_lists(max_results: int = 100) -> dict: @@ -27,7 +24,7 @@ async def list_task_lists(max_results: int = 100) -> dict: try: def _list(): - service = get_google_service("tasks", "v1", TASKS_READONLY_SCOPES) + service = get_google_service_from_mcp("tasks", "v3") results = service.tasklists().list(maxResults=max_results).execute() items = results.get("items", []) return { @@ -63,7 +60,7 @@ async def get_task_list(task_list_id: str) -> dict: try: def _get(): - service = get_google_service("tasks", "v1", TASKS_READONLY_SCOPES) + service = get_google_service_from_mcp("tasks", "v3") tl = service.tasklists().get(tasklist=task_list_id).execute() return { "id": tl["id"], @@ -92,7 +89,7 @@ async def create_task_list(title: str) -> dict: try: def _create(): - service = get_google_service("tasks", "v1", TASKS_FULL_SCOPES) + service = get_google_service_from_mcp("tasks", "v3") tl = service.tasklists().insert(body={"title": title}).execute() return { "id": tl["id"], @@ -123,7 +120,7 @@ async def delete_task_list(task_list_id: str) -> dict: try: def _delete(): - service = get_google_service("tasks", "v1", TASKS_FULL_SCOPES) + service = get_google_service_from_mcp("tasks", "v1") service.tasklists().delete(tasklist=task_list_id).execute() return {"deleted_task_list_id": task_list_id} @@ -158,7 +155,7 @@ async def list_tasks( try: def _list(): - service = get_google_service("tasks", "v1", TASKS_READONLY_SCOPES) + service = get_google_service_from_mcp("tasks", "v1") results = ( service.tasks() .list( @@ -209,7 +206,7 @@ async def get_task(task_list_id: str, task_id: str) -> dict: try: def _get(): - service = get_google_service("tasks", "v1", TASKS_READONLY_SCOPES) + service = get_google_service_from_mcp("tasks", "v1") t = service.tasks().get(tasklist=task_list_id, task=task_id).execute() return { "id": t["id"], @@ -256,7 +253,7 @@ async def create_task( try: def _create(): - service = get_google_service("tasks", "v1", TASKS_FULL_SCOPES) + service = get_google_service_from_mcp("tasks", "v1") body = {"title": title} if notes: body["notes"] = notes @@ -311,7 +308,7 @@ async def update_task( try: def _update(): - service = get_google_service("tasks", "v1", TASKS_FULL_SCOPES) + service = get_google_service_from_mcp("tasks", "v1") # Get current task first current = service.tasks().get(tasklist=task_list_id, task=task_id).execute() @@ -363,7 +360,7 @@ async def delete_task(task_list_id: str, task_id: str) -> dict: try: def _delete(): - service = get_google_service("tasks", "v1", TASKS_FULL_SCOPES) + service = get_google_service_from_mcp("tasks", "v1") service.tasks().delete(tasklist=task_list_id, task=task_id).execute() return {"deleted_task_id": task_id} @@ -406,7 +403,7 @@ async def clear_completed_tasks(task_list_id: str = "@default") -> dict: try: def _clear(): - service = get_google_service("tasks", "v1", TASKS_FULL_SCOPES) + service = get_google_service_from_mcp("tasks", "v1") service.tasks().clear(tasklist=task_list_id).execute() return {"cleared": True, "task_list_id": task_list_id} From 2a3c8d8bc5f3c44cf2acd5f2262e81e15dfa6aeb Mon Sep 17 00:00:00 2001 From: Maska Chung Date: Sat, 24 Jan 2026 22:24:59 +0000 Subject: [PATCH 2/4] added auth for openapi --- .env.example | 3 + src/humcp/auth.py | 96 ++++++--- src/humcp/routes.py | 385 ++++++++++++++++++++++++++++++++++++- src/humcp/server.py | 35 ++-- src/tools/google/auth.py | 72 ++++--- tests/humcp/test_server.py | 12 +- 6 files changed, 523 insertions(+), 80 deletions(-) diff --git a/.env.example b/.env.example index 8532092..a3ed1e9 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,6 @@ MCP_SERVER_URL=http://localhost:8080 GOOGLE_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com GOOGLE_OAUTH_CLIENT_SECRET=GOCSPX-your-client-secret +FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.google.GoogleProvider + +AUTH_ENABLED=false \ No newline at end of file diff --git a/src/humcp/auth.py b/src/humcp/auth.py index 6875597..3190838 100644 --- a/src/humcp/auth.py +++ b/src/humcp/auth.py @@ -1,17 +1,18 @@ import base64 import binascii +import hmac import logging import os -import hmac import time from urllib.parse import unquote from dotenv import load_dotenv from fastmcp.server.auth.providers.google import GoogleProvider - -from mcp.server.auth.middleware.client_auth import ClientAuthenticator from mcp.server.auth.handlers.token import TokenHandler -from mcp.server.auth.middleware.client_auth import AuthenticationError +from mcp.server.auth.middleware.client_auth import ( + AuthenticationError, + ClientAuthenticator, +) load_dotenv() @@ -24,6 +25,28 @@ logging.getLogger("mcp.server.auth.handlers.token").setLevel(logging.DEBUG) +def is_auth_enabled() -> bool: + """Check if authentication is enabled via AUTH_ENABLED env var. + + Returns: + True if AUTH_ENABLED is not set or set to 'true' + False if AUTH_ENABLED is set to 'false' + """ + auth_enabled = os.getenv("AUTH_ENABLED", "true").lower() + return auth_enabled in ("true") + + +def has_google_credentials() -> bool: + """Check if Google OAuth credentials are configured. + + Returns: + True if both GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET are set. + """ + client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID") + client_secret = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET") + return bool(client_id and client_secret) + + def apply_authentication_patches(): """Apply monkey patches to support Postman's Basic Auth behavior. @@ -47,9 +70,13 @@ async def patched_authenticate_request(self, request): if ":" in decoded: basic_client_id, _ = decoded.split(":", 1) client_id = unquote(basic_client_id) - logger.info("Extracted client_id from Basic Auth header: %s", client_id) + logger.info( + "Extracted client_id from Basic Auth header: %s", client_id + ) except (ValueError, UnicodeDecodeError, binascii.Error) as e: - logger.warning("Failed to extract client_id from Basic Auth header: %s", e) + logger.warning( + "Failed to extract client_id from Basic Auth header: %s", e + ) if not client_id: raise AuthenticationError("Missing client_id") @@ -63,7 +90,9 @@ async def patched_authenticate_request(self, request): if client.token_endpoint_auth_method == "client_secret_basic": if not auth_header.startswith("Basic "): - raise AuthenticationError("Missing or invalid Basic authentication in Authorization header") + raise AuthenticationError( + "Missing or invalid Basic authentication in Authorization header" + ) try: encoded_credentials = auth_header[6:] @@ -77,8 +106,8 @@ async def patched_authenticate_request(self, request): if basic_client_id != client_id: raise AuthenticationError("Client ID mismatch in Basic auth") - except (ValueError, UnicodeDecodeError, binascii.Error): - raise AuthenticationError("Invalid Basic authentication header") + except (ValueError, UnicodeDecodeError, binascii.Error) as e: + raise AuthenticationError("Invalid Basic authentication header") from e elif client.token_endpoint_auth_method == "client_secret_post": raw_form_data = form_data.get("client_secret") @@ -97,10 +126,15 @@ async def patched_authenticate_request(self, request): if not request_client_secret: raise AuthenticationError("Client secret is required") - if not hmac.compare_digest(client.client_secret.encode(), request_client_secret.encode()): + if not hmac.compare_digest( + client.client_secret.encode(), request_client_secret.encode() + ): raise AuthenticationError("Invalid client_secret") - if client.client_secret_expires_at and client.client_secret_expires_at < int(time.time()): + if ( + client.client_secret_expires_at + and client.client_secret_expires_at < int(time.time()) + ): raise AuthenticationError("Client secret has expired") return client @@ -125,16 +159,24 @@ async def patched_token_handle(self, request): client_id = unquote(basic_client_id) # Add client_id to form data dict for Pydantic validation - from starlette.datastructures import FormData as StarletteFormData + from starlette.datastructures import ( + FormData as StarletteFormData, + ) + mutable_form = dict(form_data) mutable_form["client_id"] = client_id # Create a new FormData object with the client_id added request._form = StarletteFormData(mutable_form) - logger.debug("Added client_id to form data for Pydantic validation: %s", client_id) + logger.debug( + "Added client_id to form data for Pydantic validation: %s", + client_id, + ) except (ValueError, UnicodeDecodeError, binascii.Error) as e: - logger.warning("Failed to extract client_id for form validation: %s", e) + logger.warning( + "Failed to extract client_id for form validation: %s", e + ) # Call original handle method return await _original_token_handle(self, request) @@ -143,25 +185,28 @@ async def patched_token_handle(self, request): logger.info("Applied authentication patches for Postman compatibility") -def create_auth_provider() -> GoogleProvider: +def create_auth_provider() -> GoogleProvider | None: """Create and configure the Google OAuth authentication provider. Returns: - Configured GoogleProvider instance. - - Raises: - ValueError: If required environment variables are not set. + Configured GoogleProvider instance if AUTH_ENABLED=true and credentials are set. + None if AUTH_ENABLED=false or credentials are missing. """ - # Apply patches before creating provider - apply_authentication_patches() + if not is_auth_enabled(): + logger.info("Authentication is disabled (AUTH_ENABLED=false)") + return None client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID") client_secret = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET") if not client_id or not client_secret: - raise ValueError( - "GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET must be set in environment" + logger.warning( + "Google OAuth credentials not set. Authentication disabled. " + "Set GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET to enable auth." ) + return None + + apply_authentication_patches() auth_provider = GoogleProvider( client_id=client_id, @@ -194,7 +239,10 @@ def create_auth_provider() -> GoogleProvider: "https://www.googleapis.com/auth/chat.spaces.readonly", "https://www.googleapis.com/auth/chat.messages", ], - allowed_client_redirect_uris=["https://oauth.pstmn.io/v1/callback", "http://localhost:*"], + allowed_client_redirect_uris=[ + "https://oauth.pstmn.io/v1/callback", + "http://localhost:*", + ], ) logger.info("Created Google OAuth provider") diff --git a/src/humcp/routes.py b/src/humcp/routes.py index 3e12c6e..0e93b25 100644 --- a/src/humcp/routes.py +++ b/src/humcp/routes.py @@ -1,11 +1,20 @@ """REST route generation for tools.""" +import asyncio +import base64 +import hashlib import inspect import logging +import os +import secrets from pathlib import Path from typing import Any +from urllib.parse import urlencode -from fastapi import Body, FastAPI, HTTPException +import httpx +from fastapi import Body, Depends, FastAPI, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastmcp.server.dependencies import get_access_token from pydantic import BaseModel, Field, create_model from src.humcp.registry import TOOL_REGISTRY, ToolRegistration @@ -20,9 +29,338 @@ ToolSummary, ) from src.humcp.skills import discover_skills +from src.tools.google.auth import set_rest_access_token logger = logging.getLogger("humcp.routes") +# Store for PKCE verifiers and registered client +_pkce_store: dict[str, str] = {} +_browser_client: dict[str, str] = {} +_registration_lock = asyncio.Lock() + + +async def _extract_token(request: Request) -> str | None: + """Extract access token from request if available. + + Checks multiple sources in order: + 1. FastMCP's get_access_token() for MCP session context + 2. Cookie-based access_token from browser login + 3. Bearer token in Authorization header + + Also sets the access token in context variable for Google tools to use. + + Args: + request: FastAPI request object + + Returns: + Access token string if found, None otherwise + """ + # Try FastMCP's get_access_token() for MCP context + try: + token = get_access_token() + if token and token.token: + logger.debug("REST request authenticated via MCP session") + set_rest_access_token(token.token) + return token.token + except Exception: + pass # Fall through to other methods + + # Check for cookie (from /login flow) + access_token = request.cookies.get("access_token") + if access_token: + logger.debug("REST request authenticated via cookie") + set_rest_access_token(access_token) + return access_token + + # Check for Bearer token in Authorization header + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + access_token = auth_header[7:] + logger.debug("REST request authenticated via Bearer token") + set_rest_access_token(access_token) + return access_token + + return None + + +async def require_rest_auth(request: Request): + """Require OAuth authentication for REST endpoints. + + This dependency checks for authentication and raises 401 if not found. + Used when AUTH_ENABLED=true. + + Args: + request: FastAPI request object + + Returns: + Access token string if authenticated + + Raises: + HTTPException: 401 if not authenticated or token is invalid + """ + token = await _extract_token(request) + if token: + return token + + # No valid authentication found + raise HTTPException( + status_code=401, + detail="Authentication required. Please visit /login to authenticate.", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +async def optional_rest_auth(request: Request): + """Optional authentication for REST endpoints. + + This dependency extracts and sets the token if available, but doesn't + require authentication. Used when AUTH_ENABLED=false but Google tools + still need the Bearer token if provided. + + Args: + request: FastAPI request object + + Returns: + Access token string if found, None otherwise + """ + return await _extract_token(request) + + +def _register_auth_routes( + app: FastAPI, + auth_provider: Any, + title: str, + version: str, +) -> None: + """Register authentication and info routes. + + When auth_provider is None (AUTH_ENABLED=false), only the root info endpoint + is registered. Login/logout endpoints are skipped. + + Args: + app: FastAPI application. + auth_provider: FastMCP auth provider for OAuth operations (None if disabled). + title: App title for info endpoint. + version: App version for info endpoint. + """ + mcp_url = os.getenv("MCP_SERVER_URL", "http://0.0.0.0:8080/mcp") + auth_enabled = auth_provider is not None + + @app.get("/", tags=["Info"]) + async def root(): + """Server information endpoint.""" + endpoints = {"docs": "/docs", "tools": "/tools", "mcp": "/mcp"} + if auth_enabled: + endpoints["login"] = "/login" + return { + "name": title, + "version": version, + "mcp_server": mcp_url, + "tools_count": len(TOOL_REGISTRY), + "auth_enabled": auth_enabled, + "endpoints": endpoints, + } + + # Skip login/logout endpoints if auth is disabled + if not auth_provider: + logger.info( + "Skipping auth endpoints (/login, /logout) - authentication disabled" + ) + return + + @app.get("/login", tags=["Auth"]) + async def login(request: Request): + """Browser login endpoint - initiates OAuth flow for Swagger UI users. + + This endpoint: + 1. Registers a browser OAuth client with FastMCP (if not already registered) + 2. Generates PKCE challenge + 3. Redirects to /authorize with proper parameters + """ + + async with _registration_lock: + if not _browser_client.get("client_id"): + async with httpx.AsyncClient() as client: + register_response = await client.post( + "http://localhost:8080/register", + json={ + "client_name": "HuMCP Browser Client", + "redirect_uris": ["http://localhost:8080/login/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", # Public client (PKCE only) + }, + ) + + if register_response.status_code != 201: + logger.error( + "Failed to register browser client: %s", + register_response.text, + ) + return HTMLResponse( + content=f"

Registration Failed

{register_response.text}
", + status_code=500, + ) + + client_data = register_response.json() + _browser_client["client_id"] = client_data["client_id"] + _browser_client["client_secret"] = client_data.get( + "client_secret", "" + ) + logger.info( + "Registered browser client: %s", _browser_client["client_id"] + ) + + # Generate PKCE code verifier and challenge + code_verifier = secrets.token_urlsafe(32) + code_challenge = ( + base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()) + .decode() + .rstrip("=") + ) + + # Store verifier for later token exchange + state = secrets.token_urlsafe(16) + _pkce_store[state] = code_verifier + + # Use the same scopes as MCP authentication + scopes = ( + " ".join(auth_provider.required_scopes) if auth_provider else "openid email" + ) + + # Build authorization URL with registered client + params = { + "client_id": _browser_client["client_id"], + "response_type": "code", + "redirect_uri": "http://localhost:8080/login/callback", + "scope": scopes, + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + } + + auth_url = f"http://localhost:8080/authorize?{urlencode(params)}" + + accept_header = request.headers.get("accept", "") + if "application/json" in accept_header and "text/html" not in accept_header: + return { + "message": "Please visit the login URL in your browser to authenticate", + "login_url": auth_url, + "instructions": "Open the login_url in a new browser tab to complete authentication", + } + + # Direct browser visit - redirect to OAuth + return RedirectResponse(url=auth_url) + + @app.get("/login/callback", tags=["Auth"]) + async def login_callback( + request: Request, + code: str = None, + state: str = None, + error: str = None, + ): + """OAuth callback handler for browser login. + + Exchanges authorization code for tokens and creates a session. + """ + if error: + return HTMLResponse( + content=f"

Authentication Error

{error}

Try again", + status_code=400, + ) + + if not code or not state: + return HTMLResponse( + content="

Missing Parameters

Missing code or state parameter

Try again", + status_code=400, + ) + + # Get the stored PKCE verifier + code_verifier = _pkce_store.pop(state, None) + if not code_verifier: + return HTMLResponse( + content="

Invalid State

Session expired or invalid state

Try again", + status_code=400, + ) + + # Exchange code for token using registered browser client + import httpx + + async with httpx.AsyncClient() as client: + token_response = await client.post( + "http://localhost:8080/token", + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "http://localhost:8080/login/callback", + "client_id": _browser_client.get("client_id", ""), + "code_verifier": code_verifier, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + if token_response.status_code != 200: + logger.error("Token exchange failed: %s", token_response.text) + return HTMLResponse( + content=f"

Token Exchange Failed

{token_response.text}
Try again", + status_code=400, + ) + + tokens = token_response.json() + fastmcp_jwt = tokens.get("access_token") + + # FastMCP returns its own JWT, but we need the actual Google access token + # Use FastMCP's load_access_token to get the upstream Google token + access_token = fastmcp_jwt + if auth_provider: + try: + access_token_obj = await auth_provider.load_access_token(fastmcp_jwt) + if access_token_obj: + access_token = access_token_obj.token + logger.info("Retrieved upstream Google token from FastMCP JWT") + else: + logger.warning("Could not load upstream token, using FastMCP JWT") + except Exception as e: + logger.warning( + "Failed to load upstream token: %s, using FastMCP JWT", e + ) + + # Create response with session cookie + response = HTMLResponse( + content=f""" + + Login Successful + +

Login Successful!

+

You are now authenticated.

+

Go to Swagger UI

+ + + + """, + status_code=200, + ) + + # Set the access token as a cookie for REST API calls + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + samesite="lax", + max_age=3600, # 1 hour + ) + + return response + + @app.get("/logout", tags=["Auth"]) + async def logout(): + """Logout endpoint - clears the session cookie.""" + response = RedirectResponse(url="/") + response.delete_cookie("access_token") + return response + def _format_tag(category: str) -> str: """Format category name as a display-friendly tag. @@ -33,16 +371,37 @@ def _format_tag(category: str) -> str: return category.replace("_", " ").title() -def register_routes(app: FastAPI, tools_path: Path | None = None) -> None: - """Register REST routes from TOOL_REGISTRY. +def register_routes( + app: FastAPI, + auth_provider: Any = None, + tools_path: Path | None = None, + title: str = "HuMCP Server", + version: str = "1.0.0", +) -> None: + """Register all REST routes including tools and auth endpoints. Args: app: FastAPI application. + auth_provider: FastMCP auth provider for OAuth operations (None if auth disabled). tools_path: Path to tools directory for skill discovery. + title: App title for info endpoint. + version: App version for info endpoint. """ + # Choose auth dependency based on whether auth is enabled + # When auth_provider is None (AUTH_ENABLED=false), use optional auth + # This still allows Bearer tokens for Google tools but doesn't require auth + auth_dependency = require_rest_auth if auth_provider else optional_rest_auth + + if auth_provider: + logger.info("Authentication enabled - REST endpoints require auth") + else: + logger.info( + "Authentication disabled - REST endpoints open (Bearer token optional for Google tools)" + ) + # Tool execution endpoints for reg in TOOL_REGISTRY: - _add_tool_route(app, reg) + _add_tool_route(app, reg, auth_dependency) # Build cached lookup structures once at startup categories = _build_categories() @@ -54,6 +413,9 @@ def register_routes(app: FastAPI, tools_path: Path | None = None) -> None: tools_path = Path(__file__).parent.parent / "tools" skills = discover_skills(tools_path) + # Register auth endpoints only when auth is enabled + _register_auth_routes(app, auth_provider, title, version) + # Info endpoints @app.get("/tools", tags=["Info"], response_model=ListToolsResponse) async def list_tools() -> ListToolsResponse: @@ -114,12 +476,21 @@ async def get_tool(category: str, tool_name: str) -> GetToolResponse: ) -def _add_tool_route(app: FastAPI, reg: ToolRegistration) -> None: - """Add POST /tools/{name} endpoint for a tool.""" +def _add_tool_route(app: FastAPI, reg: ToolRegistration, auth_dependency: Any) -> None: + """Add POST /tools/{name} endpoint for a tool. + + Args: + app: FastAPI application. + reg: Tool registration info. + auth_dependency: Authentication dependency (require_rest_auth or optional_rest_auth). + """ schema = _get_schema_from_func(reg.func) InputModel = _create_model(schema, f"{_pascal(reg.name)}Input") - async def endpoint(data: BaseModel = Body(...)) -> dict[str, Any]: # type: ignore[assignment] + async def endpoint( + data: BaseModel = Body(...), # type: ignore[assignment] + token=Depends(auth_dependency), + ) -> dict[str, Any]: try: params = data.model_dump(exclude_none=True) result = await reg.func(**params) diff --git a/src/humcp/server.py b/src/humcp/server.py index b03a6fb..4d25afc 100644 --- a/src/humcp/server.py +++ b/src/humcp/server.py @@ -2,14 +2,13 @@ import importlib.util import logging -import os import sys from collections.abc import Callable from contextlib import asynccontextmanager from pathlib import Path from typing import Any -from dotenv import load_dotenv +from dotenv import load_dotenv from fastapi import FastAPI from fastmcp import FastMCP @@ -17,8 +16,10 @@ from src.humcp.registry import TOOL_REGISTRY from src.humcp.routes import build_openapi_tags, register_routes +load_dotenv() + logger = logging.getLogger("humcp") -auth_provider = create_auth_provider() + def _discover_tools(tools_path: Path) -> int: """Auto-discover and import tool modules from a directory. @@ -91,6 +92,9 @@ def create_app( loaded = _discover_tools(path) logger.info("Discovered %d tool modules from %s", loaded, path) + # Create auth provider (respects AUTH_ENABLED env var) + auth_provider = create_auth_provider() + # Create MCP server mcp = FastMCP("HuMCP Server", auth=auth_provider) seen: set[Callable[..., Any]] = set() @@ -118,24 +122,17 @@ async def lifespan(_app: FastAPI): openapi_tags=openapi_tags, ) - # Register REST routes from TOOL_REGISTRY - register_routes(app) + # Register all REST routes (tools, auth, info endpoints) + register_routes( + app, + auth_provider=auth_provider, + tools_path=path, + title=title, + version=version, + ) logger.info("Registered %d REST endpoints", len(TOOL_REGISTRY)) - # Root info endpoint - mcp_url = os.getenv("MCP_SERVER_URL", "http://0.0.0.0:8080/mcp") - - @app.get("/", tags=["Info"]) - async def root(): - return { - "name": title, - "version": version, - "mcp_server": mcp_url, - "tools_count": len(TOOL_REGISTRY), - "endpoints": {"docs": "/docs", "tools": "/tools", "mcp": "/mcp"}, - } - - # Mount ALL OAuth routes at root level + # Mount OAuth routes at root level # This includes: /.well-known/*, /authorize, /token, /register, /auth/callback, /consent if auth_provider: oauth_routes = auth_provider.get_routes(mcp_path="/mcp") diff --git a/src/tools/google/auth.py b/src/tools/google/auth.py index 6867c4b..54950b9 100644 --- a/src/tools/google/auth.py +++ b/src/tools/google/auth.py @@ -1,20 +1,32 @@ -import json +import contextvars import logging -import os -from pathlib import Path from google.oauth2.credentials import Credentials from googleapiclient.discovery import build logger = logging.getLogger("humcp.google.auth") +# Context variable to store access token from REST API calls +# This allows tools to access the token when called via REST (not MCP) +rest_access_token: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "rest_access_token", default=None +) + + +def set_rest_access_token(token: str) -> None: + """Set the access token for REST API calls. + + Called by require_rest_auth when authenticating via cookie/header. + """ + rest_access_token.set(token) + + def get_google_service_from_mcp(service_name: str, version: str): - """Build Google API service using the MCP session's access token. + """Build Google API service using the authenticated user's access token. - This function gets the access token from the authenticated MCP session - instead of using a separate OAuth flow. The user authenticates once - when connecting to the MCP server, and that token is reused for all - Google API calls. + This function tries to get the access token from multiple sources: + 1. REST context variable (set by require_rest_auth for cookie/header auth) + 2. MCP session context (via FastMCP's get_access_token) Args: service_name: Google API service name (e.g., 'calendar', 'gmail') @@ -24,31 +36,35 @@ def get_google_service_from_mcp(service_name: str, version: str): Authenticated Google API service client Raises: - ValueError: If unable to get access token from MCP session + ValueError: If unable to get access token from any source """ - try: - from fastmcp.server.dependencies import get_access_token - - # Get the access token from the MCP session - access_token = get_access_token() + token_value = None - if not access_token or not access_token.token: - raise ValueError("No access token available in MCP session") + # Check REST context variable (from cookie/header auth) + rest_token = rest_access_token.get() + if rest_token: + logger.debug("Using access token from REST context") + token_value = rest_token - # Create credentials from the access token - creds = Credentials(token=access_token.token) + # Try FastMCP's get_access_token (for MCP session context) + if not token_value: + try: + from fastmcp.server.dependencies import get_access_token - # Build and return the Google API service - return build(service_name, version, credentials=creds) + access_token = get_access_token() + if access_token and access_token.token: + logger.debug("Using access token from MCP session") + token_value = access_token.token + except Exception as e: + logger.debug("Could not get token from MCP session: %s", e) - except ImportError: + if not token_value: raise ValueError( - "FastMCP dependencies not available. This function must be called " - "from within an MCP tool context." - ) - except Exception as e: - logger.error("Failed to build Google service from MCP token: %s", e) - raise ValueError( - f"Failed to authenticate with Google using MCP session: {e}" + "No access token available. Please authenticate via /login (REST) or MCP client." ) + # Create credentials from the access token + creds = Credentials(token=token_value) + + # Build and return the Google API service + return build(service_name, version, credentials=creds) diff --git a/tests/humcp/test_server.py b/tests/humcp/test_server.py index acaa72e..5c023c5 100644 --- a/tests/humcp/test_server.py +++ b/tests/humcp/test_server.py @@ -214,8 +214,12 @@ def test_root_endpoint_shows_tool_count(self, tmp_path, register_sample_tools): class TestAppIntegration: """Integration tests for the full app.""" - def test_tool_execution_endpoint(self, tmp_path, register_sample_tools): + def test_tool_execution_endpoint( + self, tmp_path, register_sample_tools, monkeypatch + ): """Should be able to execute tools via REST.""" + # Disable auth for this test + monkeypatch.setenv("AUTH_ENABLED", "false") app = create_app(tools_path=str(tmp_path)) client = TestClient(app) @@ -228,8 +232,12 @@ def test_tool_execution_endpoint(self, tmp_path, register_sample_tools): assert data["result"]["success"] is True assert data["result"]["data"]["value"] == "test_value" - def test_tool_with_optional_params(self, tmp_path, register_sample_tools): + def test_tool_with_optional_params( + self, tmp_path, register_sample_tools, monkeypatch + ): """Should handle optional parameters.""" + # Disable auth for this test + monkeypatch.setenv("AUTH_ENABLED", "false") app = create_app(tools_path=str(tmp_path)) client = TestClient(app) From 77193201a8a53d831b5f48626a55b0eedb4a1e4f Mon Sep 17 00:00:00 2001 From: Maska Chung Date: Tue, 27 Jan 2026 21:50:34 +0000 Subject: [PATCH 3/4] fixed merge conflicts --- src/humcp/routes.py | 35 +++++++++++++++++++++-------------- src/humcp/server.py | 19 ++++++++++--------- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/humcp/routes.py b/src/humcp/routes.py index 1ab8437..7a09886 100644 --- a/src/humcp/routes.py +++ b/src/humcp/routes.py @@ -3,7 +3,6 @@ import asyncio import base64 import hashlib -import inspect import logging import os import secrets @@ -40,6 +39,9 @@ _browser_client: dict[str, str] = {} _registration_lock = asyncio.Lock() +def _format_tag(category: str) -> str: + """Format category as display tag: 'local_files' -> 'Local Files'.""" + return category.replace("_", " ").title() async def _extract_token(request: Request) -> str | None: """Extract access token from request if available. @@ -130,6 +132,7 @@ async def optional_rest_auth(request: Request): def _register_auth_routes( app: FastAPI, + tools: list[RegisteredTool], auth_provider: Any, title: str, version: str, @@ -141,6 +144,7 @@ def _register_auth_routes( Args: app: FastAPI application. + tools: List of registered tools. auth_provider: FastMCP auth provider for OAuth operations (None if disabled). title: App title for info endpoint. version: App version for info endpoint. @@ -158,7 +162,7 @@ async def root(): "name": title, "version": version, "mcp_server": mcp_url, - "tools_count": len(TOOL_REGISTRY), + "tools_count": len(tools), "auth_enabled": auth_enabled, "endpoints": endpoints, } @@ -364,15 +368,11 @@ async def logout(): return response -def _format_tag(category: str) -> str: - """Format category as display tag: 'local_files' -> 'Local Files'.""" - return category.replace("_", " ").title() - - def register_routes( app: FastAPI, + tools_path: Path, + tools: list[RegisteredTool], auth_provider: Any = None, - tools_path: Path | None = None, title: str = "HuMCP Server", version: str = "1.0.0", ) -> None: @@ -380,11 +380,16 @@ def register_routes( Args: app: FastAPI application. - auth_provider: FastMCP auth provider for OAuth operations (None if auth disabled). tools_path: Path to tools directory for skill discovery. + tools: List of registered tools. + auth_provider: FastMCP auth provider for OAuth operations (None if auth disabled). title: App title for info endpoint. version: App version for info endpoint. """ + # Build lookups + categories = _build_categories(tools) + tool_lookup = {(t.category, t.tool.name): t for t in tools} + # Choose auth dependency based on whether auth is enabled # When auth_provider is None (AUTH_ENABLED=false), use optional auth # This still allows Bearer tokens for Google tools but doesn't require auth @@ -398,14 +403,14 @@ def register_routes( ) # Tool execution endpoints - for reg in TOOL_REGISTRY: + for reg in tools: _add_tool_route(app, reg, auth_dependency) # Discover skills skills = discover_skills(tools_path) # Register auth endpoints only when auth is enabled - _register_auth_routes(app, auth_provider, title, version) + _register_auth_routes(app, tools, auth_provider, title, version) # Info endpoints @app.get("/tools", tags=["Info"], response_model=ListToolsResponse) @@ -480,7 +485,7 @@ async def list_categories() -> ListCategoriesResponse: ) -def _add_tool_route(app: FastAPI, reg: ToolRegistration, auth_dependency: Any) -> None: +def _add_tool_route(app: FastAPI, reg: RegisteredTool, auth_dependency: Any) -> None: """Add POST /tools/{name} endpoint for a tool. Args: @@ -488,8 +493,10 @@ def _add_tool_route(app: FastAPI, reg: ToolRegistration, auth_dependency: Any) - reg: Tool registration info. auth_dependency: Authentication dependency (require_rest_auth or optional_rest_auth). """ - schema = _get_schema_from_func(reg.func) - InputModel = _create_model(schema, f"{_pascal(reg.name)}Input") + tool = reg.tool + InputModel = _create_model_from_schema( + tool.parameters, f"{_pascal(tool.name)}Input" + ) async def endpoint( data: BaseModel = Body(...), # type: ignore[assignment] diff --git a/src/humcp/server.py b/src/humcp/server.py index 4ff446e..2e0b22a 100644 --- a/src/humcp/server.py +++ b/src/humcp/server.py @@ -13,7 +13,13 @@ from fastmcp import FastMCP from src.humcp.auth import create_auth_provider -from src.humcp.registry import TOOL_REGISTRY +from src.humcp.config import DEFAULT_CONFIG_PATH, filter_tools, load_config +from src.humcp.decorator import ( + RegisteredTool, + get_tool_category, + get_tool_name, + is_tool, +) from src.humcp.routes import build_openapi_tags, register_routes load_dotenv() @@ -99,12 +105,6 @@ def create_app( # Create MCP server mcp = FastMCP("HuMCP Server", auth=auth_provider) - seen: set[Callable[..., Any]] = set() - for reg in TOOL_REGISTRY: - if reg.func not in seen: - seen.add(reg.func) - mcp.tool(name=reg.name)(reg.func) - logger.info("Registered MCP tool: %s", reg.name) # Load modules and register tools with FastMCP modules = _load_modules(path) @@ -137,12 +137,13 @@ async def lifespan(_: FastAPI): # Register all REST routes (tools, auth, info endpoints) register_routes( app, - auth_provider=auth_provider, tools_path=path, + tools=filtered, + auth_provider=auth_provider, title=title, version=version, ) - logger.info("Registered %d REST endpoints", len(TOOL_REGISTRY)) + logger.info("Registered %d REST endpoints", len(filtered)) # Mount OAuth routes at root level # This includes: /.well-known/*, /authorize, /token, /register, /auth/callback, /consent From 60ed42eb5f38f431cd07a1816d68c6acc80a16e9 Mon Sep 17 00:00:00 2001 From: Maska Chung Date: Tue, 27 Jan 2026 21:59:07 +0000 Subject: [PATCH 4/4] fixed linting and tests --- src/humcp/routes.py | 8 +++-- tests/conftest.py | 45 +++++++++++++++++++++++++++++ tests/tools/google/test_calendar.py | 2 +- tests/tools/google/test_chat.py | 2 +- tests/tools/google/test_docs.py | 2 +- tests/tools/google/test_drive.py | 2 +- tests/tools/google/test_forms.py | 2 +- tests/tools/google/test_gmail.py | 2 +- tests/tools/google/test_sheets.py | 2 +- tests/tools/google/test_slides.py | 2 +- tests/tools/google/test_tasks.py | 2 +- 11 files changed, 59 insertions(+), 12 deletions(-) diff --git a/src/humcp/routes.py b/src/humcp/routes.py index 7a09886..d2b5275 100644 --- a/src/humcp/routes.py +++ b/src/humcp/routes.py @@ -39,10 +39,12 @@ _browser_client: dict[str, str] = {} _registration_lock = asyncio.Lock() + def _format_tag(category: str) -> str: """Format category as display tag: 'local_files' -> 'Local Files'.""" return category.replace("_", " ").title() + async def _extract_token(request: Request) -> str | None: """Extract access token from request if available. @@ -261,9 +263,9 @@ async def login(request: Request): @app.get("/login/callback", tags=["Auth"]) async def login_callback( request: Request, - code: str = None, - state: str = None, - error: str = None, + code: str | None = None, + state: str | None = None, + error: str | None = None, ): """OAuth callback handler for browser login. diff --git a/tests/conftest.py b/tests/conftest.py index 039666a..b7f8835 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -48,3 +48,48 @@ async def other_tool() -> dict: ''') return tmp_path + + +@pytest.fixture +def register_sample_tools(tmp_path): + """Create and register sample tool files for testing. + + This fixture creates sample tool files in tmp_path that will be + discovered and registered when create_app is called with tools_path=tmp_path. + """ + # test category + test_dir = tmp_path / "test" + test_dir.mkdir() + + (test_dir / "tool_one.py").write_text(''' +from src.humcp.decorator import tool + +@tool(category="test") +async def test_tool_one(value: str) -> dict: + """First test tool.""" + return {"success": True, "data": {"value": value}} +''') + + (test_dir / "tool_two.py").write_text(''' +from src.humcp.decorator import tool + +@tool(category="test") +async def test_tool_two(a: int, b: int = 10) -> dict: + """Second test tool.""" + return {"success": True, "data": {"result": a + b}} +''') + + # other category + other_dir = tmp_path / "other" + other_dir.mkdir() + + (other_dir / "tool_three.py").write_text(''' +from src.humcp.decorator import tool + +@tool(category="other") +async def other_tool() -> dict: + """Tool in other category.""" + return {"success": True, "data": {}} +''') + + return tmp_path diff --git a/tests/tools/google/test_calendar.py b/tests/tools/google/test_calendar.py index 508e48e..34f0e8f 100644 --- a/tests/tools/google/test_calendar.py +++ b/tests/tools/google/test_calendar.py @@ -12,7 +12,7 @@ @pytest.fixture def mock_calendar_service(): - with patch("src.tools.google.calendar.get_google_service") as mock: + with patch("src.tools.google.calendar.get_google_service_from_mcp") as mock: service = MagicMock() mock.return_value = service yield service diff --git a/tests/tools/google/test_chat.py b/tests/tools/google/test_chat.py index e6aae1d..4742de3 100644 --- a/tests/tools/google/test_chat.py +++ b/tests/tools/google/test_chat.py @@ -13,7 +13,7 @@ @pytest.fixture def mock_chat_service(): - with patch("src.tools.google.chat.get_google_service") as mock: + with patch("src.tools.google.chat.get_google_service_from_mcp") as mock: service = MagicMock() mock.return_value = service yield service diff --git a/tests/tools/google/test_docs.py b/tests/tools/google/test_docs.py index 97cd0f1..53fc1e2 100644 --- a/tests/tools/google/test_docs.py +++ b/tests/tools/google/test_docs.py @@ -14,7 +14,7 @@ @pytest.fixture def mock_docs_service(): - with patch("src.tools.google.docs.get_google_service") as mock: + with patch("src.tools.google.docs.get_google_service_from_mcp") as mock: service = MagicMock() mock.return_value = service yield service diff --git a/tests/tools/google/test_drive.py b/tests/tools/google/test_drive.py index 2b026fa..7e8936d 100644 --- a/tests/tools/google/test_drive.py +++ b/tests/tools/google/test_drive.py @@ -12,7 +12,7 @@ @pytest.fixture def mock_drive_service(): - with patch("src.tools.google.drive.get_google_service") as mock: + with patch("src.tools.google.drive.get_google_service_from_mcp") as mock: service = MagicMock() mock.return_value = service yield service diff --git a/tests/tools/google/test_forms.py b/tests/tools/google/test_forms.py index 5fa755f..0408861 100644 --- a/tests/tools/google/test_forms.py +++ b/tests/tools/google/test_forms.py @@ -13,7 +13,7 @@ @pytest.fixture def mock_forms_service(): - with patch("src.tools.google.forms.get_google_service") as mock: + with patch("src.tools.google.forms.get_google_service_from_mcp") as mock: service = MagicMock() mock.return_value = service yield service diff --git a/tests/tools/google/test_gmail.py b/tests/tools/google/test_gmail.py index 33e28e8..c21e522 100644 --- a/tests/tools/google/test_gmail.py +++ b/tests/tools/google/test_gmail.py @@ -12,7 +12,7 @@ @pytest.fixture def mock_gmail_service(): - with patch("src.tools.google.gmail.get_google_service") as mock: + with patch("src.tools.google.gmail.get_google_service_from_mcp") as mock: service = MagicMock() mock.return_value = service yield service diff --git a/tests/tools/google/test_sheets.py b/tests/tools/google/test_sheets.py index ed7d3a9..d97fdb0 100644 --- a/tests/tools/google/test_sheets.py +++ b/tests/tools/google/test_sheets.py @@ -16,7 +16,7 @@ @pytest.fixture def mock_sheets_service(): - with patch("src.tools.google.sheets.get_google_service") as mock: + with patch("src.tools.google.sheets.get_google_service_from_mcp") as mock: service = MagicMock() mock.return_value = service yield service diff --git a/tests/tools/google/test_slides.py b/tests/tools/google/test_slides.py index bb3aae5..890a0e7 100644 --- a/tests/tools/google/test_slides.py +++ b/tests/tools/google/test_slides.py @@ -14,7 +14,7 @@ @pytest.fixture def mock_slides_service(): - with patch("src.tools.google.slides.get_google_service") as mock: + with patch("src.tools.google.slides.get_google_service_from_mcp") as mock: service = MagicMock() mock.return_value = service yield service diff --git a/tests/tools/google/test_tasks.py b/tests/tools/google/test_tasks.py index 5fbd83c..58857ee 100644 --- a/tests/tools/google/test_tasks.py +++ b/tests/tools/google/test_tasks.py @@ -19,7 +19,7 @@ @pytest.fixture def mock_tasks_service(): - with patch("src.tools.google.tasks.get_google_service") as mock: + with patch("src.tools.google.tasks.get_google_service_from_mcp") as mock: service = MagicMock() mock.return_value = service yield service