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 new file mode 100644 index 0000000..3190838 --- /dev/null +++ b/src/humcp/auth.py @@ -0,0 +1,249 @@ +import base64 +import binascii +import hmac +import logging +import os +import time +from urllib.parse import unquote + +from dotenv import load_dotenv +from fastmcp.server.auth.providers.google import GoogleProvider +from mcp.server.auth.handlers.token import TokenHandler +from mcp.server.auth.middleware.client_auth import ( + AuthenticationError, + ClientAuthenticator, +) + +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 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. + + 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) 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") + 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 | None: + """Create and configure the Google OAuth authentication provider. + + Returns: + Configured GoogleProvider instance if AUTH_ENABLED=true and credentials are set. + None if AUTH_ENABLED=false or credentials are missing. + """ + 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: + 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, + 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/routes.py b/src/humcp/routes.py index 944a9b8..d2b5275 100644 --- a/src/humcp/routes.py +++ b/src/humcp/routes.py @@ -1,10 +1,19 @@ """REST route generation for tools.""" +import asyncio +import base64 +import hashlib 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.decorator import RegisteredTool @@ -21,32 +30,390 @@ 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() + 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. + + 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, + tools: list[RegisteredTool], + 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. + 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. + """ + 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(tools), + "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"
{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 = None,
+ state: str | None = None,
+ error: str | None = None,
+ ):
+ """OAuth callback handler for browser login.
+
+ Exchanges authorization code for tokens and creates a session.
+ """
+ if error:
+ return HTMLResponse(
+ content=f"{error}
Try again", + status_code=400, + ) + + if not code or not state: + return HTMLResponse( + content="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="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_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"""
+
+ You are now authenticated.
+ + + + + """, + 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 register_routes( app: FastAPI, tools_path: Path, tools: list[RegisteredTool], + auth_provider: Any = None, + title: str = "HuMCP Server", + version: str = "1.0.0", ) -> None: - """Register REST routes for tools.""" + """Register all REST routes including tools and auth endpoints. + + Args: + app: FastAPI application. + 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 + 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 tools: - _add_tool_route(app, reg) + _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, tools, auth_provider, title, version) + # Info endpoints @app.get("/tools", tags=["Info"], response_model=ListToolsResponse) async def list_tools() -> ListToolsResponse: @@ -120,14 +487,23 @@ async def list_categories() -> ListCategoriesResponse: ) -def _add_tool_route(app: FastAPI, reg: RegisteredTool) -> None: - """Add POST /tools/{name} endpoint for a tool.""" +def _add_tool_route(app: FastAPI, reg: RegisteredTool, 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). + """ tool = reg.tool InputModel = _create_model_from_schema( tool.parameters, f"{_pascal(tool.name)}Input" ) - async def endpoint(data: BaseModel = Body(...)) -> dict[str, Any]: + 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 tool.fn(**params) diff --git a/src/humcp/server.py b/src/humcp/server.py index 4620f21..2e0b22a 100644 --- a/src/humcp/server.py +++ b/src/humcp/server.py @@ -3,15 +3,16 @@ import importlib.util import inspect import logging -import os import sys from contextlib import asynccontextmanager from pathlib import Path from types import ModuleType +from dotenv import load_dotenv from fastapi import FastAPI from fastmcp import FastMCP +from src.humcp.auth import create_auth_provider from src.humcp.config import DEFAULT_CONFIG_PATH, filter_tools, load_config from src.humcp.decorator import ( RegisteredTool, @@ -21,6 +22,8 @@ ) from src.humcp.routes import build_openapi_tags, register_routes +load_dotenv() + logger = logging.getLogger("humcp") @@ -97,8 +100,11 @@ def create_app( """Create FastAPI app with REST (/tools) and MCP (/mcp) endpoints.""" path = Path(tools_path) if tools_path else Path(__file__).parent.parent / "tools" + # Create auth provider (respects AUTH_ENABLED env var) + auth_provider = create_auth_provider() + # Create MCP server - mcp = FastMCP("HuMCP Server") + mcp = FastMCP("HuMCP Server", auth=auth_provider) # Load modules and register tools with FastMCP modules = _load_modules(path) @@ -128,21 +134,24 @@ async def lifespan(_: FastAPI): openapi_tags=build_openapi_tags(filtered), ) - # Register REST routes - register_routes(app, tools_path=path, tools=filtered) - - # Root 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(filtered), - "endpoints": {"docs": "/docs", "tools": "/tools", "mcp": "/mcp"}, - } + # Register all REST routes (tools, auth, info endpoints) + register_routes( + app, + tools_path=path, + tools=filtered, + auth_provider=auth_provider, + title=title, + version=version, + ) + logger.info("Registered %d REST endpoints", len(filtered)) + + # 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") + for route in oauth_routes: + app.routes.append(route) + logger.info("Mounted %d OAuth routes at root level", len(oauth_routes)) app.mount("/mcp", mcp_http_app) return app diff --git a/src/tools/google/auth.py b/src/tools/google/auth.py index 20209d5..54950b9 100644 --- a/src/tools/google/auth.py +++ b/src/tools/google/auth.py @@ -1,191 +1,70 @@ -import json +import contextvars import logging -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" - -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", -} - - -def _ensure_config_dir() -> None: - """Ensure the config directory exists.""" - TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True) - - -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 +# 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 +) - # 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 - if not creds or not creds.valid: - creds = _run_auth_flow(scopes) +def set_rest_access_token(token: str) -> None: + """Set the access token for REST API calls. - return creds + Called by require_rest_auth when authenticating via cookie/header. + """ + rest_access_token.set(token) -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, - ) - return None +def get_google_service_from_mcp(service_name: str, version: str): + """Build Google API service using the authenticated user's access token. - try: - # Allow http for localhost during development - os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" + 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) - flow = InstalledAppFlow.from_client_config(client_config, scopes) - logger.info("Opening browser for Google authentication...") - creds = flow.run_local_server(port=0) + Args: + service_name: Google API service name (e.g., 'calendar', 'gmail') + version: API version (e.g., 'v3', 'v1') - _save_credentials(creds) - logger.info("Authentication successful") - return creds + Returns: + Authenticated Google API service client - except Exception as e: - logger.error("OAuth flow failed: %s", e) - return None + Raises: + ValueError: If unable to get access token from any source + """ + token_value = None + # 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 -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) + # Try FastMCP's get_access_token (for MCP session context) + if not token_value: + try: + from fastmcp.server.dependencies import get_access_token + 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) -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: + if not token_value: 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" + "No access token available. Please authenticate via /login (REST) or MCP client." ) - 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, - } + # Create credentials from the access token + creds = Credentials(token=token_value) - 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 + # Build and return the Google API service + return build(service_name, version, credentials=creds) diff --git a/src/tools/google/calendar.py b/src/tools/google/calendar.py index eb6a055..e452810 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() async def google_calendar_list() -> dict: @@ -28,7 +24,8 @@ async def google_calendar_list() -> 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 google_calendar_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 google_calendar_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 google_calendar_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 4069eff..f1659d3 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() async def google_chat_list_spaces( @@ -30,7 +27,7 @@ async def google_chat_list_spaces( 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", []) @@ -74,7 +71,7 @@ async def google_chat_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 { @@ -115,7 +112,7 @@ async def google_chat_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() @@ -160,7 +157,7 @@ async def google_chat_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 { @@ -202,7 +199,7 @@ async def google_chat_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 0727e7c..d4c3da8 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() async def google_docs_search(query: str, max_results: int = 25) -> dict: @@ -28,7 +25,7 @@ async def google_docs_search(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 google_docs_get_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 google_docs_create(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 google_docs_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 google_docs_find_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 google_docs_list_in_folder(folder_id: str, max_results: int = 50) -> d 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 c66466e..bcab754 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() async def google_drive_list( @@ -36,7 +33,7 @@ async def google_drive_list( 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 google_drive_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 google_drive_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 google_drive_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 8dee7ee..d140c91 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() async def google_forms_list_forms(max_results: int = 25) -> dict: @@ -28,7 +24,7 @@ async def google_forms_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 google_forms_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 google_forms_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 google_forms_list_responses(form_id: str, max_results: int = 100) -> d 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 google_forms_get_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 3e92b89..f07a217 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() async def google_gmail_search(query: str = "", max_results: int = 10) -> dict: @@ -33,7 +29,7 @@ async def google_gmail_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 google_gmail_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 google_gmail_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 google_gmail_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 dd85baf..cfda4eb 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() async def google_sheets_list_spreadsheets(max_results: int = 25) -> dict: @@ -27,7 +24,7 @@ async def google_sheets_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 google_sheets_get_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 google_sheets_read_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 google_sheets_write_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 google_sheets_append_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() @@ -278,7 +275,7 @@ async def google_sheets_create_spreadsheet( try: def _create(): - service = get_google_service("sheets", "v4", SHEETS_FULL_SCOPES) + service = get_google_service_from_mcp("sheets", "v3") body = {"properties": {"title": title}} @@ -322,7 +319,7 @@ async def google_sheets_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() @@ -360,7 +357,7 @@ async def google_sheets_clear_values(spreadsheet_id: str, range_notation: str) - 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 7b6cd29..1f6f37d 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() async def google_slides_list_presentations(max_results: int = 25) -> dict: @@ -27,7 +24,7 @@ async def google_slides_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 google_slides_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 google_slides_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 google_slides_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 google_slides_add_text( 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 google_slides_get_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 effde65..02d8390 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() async def google_tasks_list_task_lists(max_results: int = 100) -> dict: @@ -27,7 +24,7 @@ async def google_tasks_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 google_tasks_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 google_tasks_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 google_tasks_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 google_tasks_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 google_tasks_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 google_tasks_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 google_tasks_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 google_tasks_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 google_tasks_clear_completed(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} 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/humcp/test_server.py b/tests/humcp/test_server.py index 8bc7fb4..8d4743c 100644 --- a/tests/humcp/test_server.py +++ b/tests/humcp/test_server.py @@ -88,16 +88,100 @@ def test_mounts_mcp(self): routes = [r.path for r in app.routes] assert "/mcp" in routes or any("/mcp" in str(r) for r in routes) - def test_discovers_and_registers_tools(self, sample_tools): - """Should discover and register tools.""" - app = create_app(tools_path=str(sample_tools)) + def test_create_app_default_tools_path(self): + """Should use default tools path when none provided.""" + # This test verifies the code path works even with real tools + app = create_app() + assert isinstance(app, FastAPI) + + def test_create_app_has_openapi_tags(self, tmp_path, register_sample_tools): + """Should include OpenAPI tags for categories.""" + app = create_app(tools_path=str(tmp_path)) + + openapi = app.openapi() + assert "tags" in openapi + + tag_names = [t["name"] for t in openapi["tags"]] + assert "Info" in tag_names + assert "Test" in tag_names # From register_sample_tools fixture + assert "Other" in tag_names + + def test_openapi_tags_have_descriptions(self, tmp_path, register_sample_tools): + """OpenAPI tags should have descriptions.""" + app = create_app(tools_path=str(tmp_path)) + + openapi = app.openapi() + for tag in openapi["tags"]: + assert "name" in tag + assert "description" in tag + + +class TestCreateAppWithTools: + """Tests for create_app with registered tools.""" + + def test_create_app_registers_tools(self, tmp_path, register_sample_tools): + """Should register tools from TOOL_REGISTRY.""" + app = create_app(tools_path=str(tmp_path)) + client = TestClient(app) + + response = client.get("/tools") + assert response.status_code == 200 + data = response.json() + assert data["total_tools"] >= 3 # At least our 3 sample tools + + def test_create_app_deduplicates_tools(self, tmp_path): + """Should not register the same function twice.""" + from src.humcp.decorator import tool + + @tool("dedupe_test_1", category="test") + async def shared_func(): + return {"success": True} + + # Register same function with different name (shouldn't happen normally) + # but this tests the deduplication logic + app = create_app(tools_path=str(tmp_path)) + assert isinstance(app, FastAPI) + + def test_root_endpoint_shows_tool_count(self, tmp_path, register_sample_tools): + """Root endpoint should show correct tool count.""" + app = create_app(tools_path=str(tmp_path)) + client = TestClient(app) + + response = client.get("/") + data = response.json() + assert data["tools_count"] >= 3 + + +class TestAppIntegration: + """Integration tests for the full app.""" + + 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) resp = client.get("/tools") assert resp.json()["total_tools"] == 3 - def test_tool_execution(self, sample_tools): - """Should execute tools via REST.""" - app = create_app(tools_path=str(sample_tools)) + response = client.post( + "/tools/test_tool_one", + json={"value": "test_value"}, + ) + assert response.status_code == 200 + data = response.json() + 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, 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) resp = client.post("/tools/test_tool_one", json={"value": "hello"}) assert resp.status_code == 200 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