From 15665d409e91a7206352b4d059f81a6bc6763b71 Mon Sep 17 00:00:00 2001 From: Henry Abravanel Date: Wed, 23 Jul 2025 00:28:59 +0300 Subject: [PATCH 01/11] Added browser authentication flow --- pyproject.toml | 1 + src/realize/auth.py | 99 +++++--- src/realize/config.py | 14 -- src/realize/realize_server.py | 6 +- src/realize/tools/browser_auth_handlers.py | 274 +++++++++++++++++++++ src/realize/tools/registry.py | 11 + 6 files changed, 360 insertions(+), 45 deletions(-) create mode 100644 src/realize/tools/browser_auth_handlers.py diff --git a/pyproject.toml b/pyproject.toml index 8f4a6a0..72db84d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "httpx>=0.25.0", "pydantic>=2.0.0", "python-dotenv>=1.0.0", + "aiohttp>=3.9.0", ] [project.urls] diff --git a/src/realize/auth.py b/src/realize/auth.py index 8b0f2c7..f4e05c6 100644 --- a/src/realize/auth.py +++ b/src/realize/auth.py @@ -9,13 +9,53 @@ logger = logging.getLogger(__name__) -class RealizeAuth: - """Handles authentication with Realize API.""" +class AuthBase: + """Base class for authentication handlers.""" def __init__(self): self.token: Optional[Token] = None self.base_url = config.realize_base_url + async def get_auth_header(self) -> dict: + """Get authorization header for API requests.""" + if not self.token or self._is_token_expired(): + await self.get_auth_token() + + return {"Authorization": f"Bearer {self.token.access_token}"} + + def _is_token_expired(self) -> bool: + """Check if current token is expired.""" + if not self.token or not self.token.created_at: + return True + + expiry_time = self.token.created_at + timedelta(seconds=self.token.expires_in) + return datetime.now() >= expiry_time + + async def get_auth_token(self) -> Token: + """Get OAuth token - must be implemented by subclasses.""" + raise NotImplementedError + + async def get_token_details(self) -> Dict[str, Any]: + """Get details about current token - returns raw JSON response.""" + if not self.token: + await self.get_auth_token() + + url = f"{self.base_url}/api/1.0/token-details" + headers = {"Authorization": f"Bearer {self.token.access_token}"} + + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers) + response.raise_for_status() + + return response.json() + + +class RealizeAuth(AuthBase): + """Handles authentication with Realize API using client credentials.""" + + def __init__(self): + super().__init__() + async def get_auth_token(self) -> Token: """Get OAuth token using client credentials.""" url = f"{self.base_url}/oauth/token" @@ -35,36 +75,35 @@ async def get_auth_token(self) -> Token: logger.info("Successfully obtained auth token") return self.token + + +class BrowserAuth(AuthBase): + """Handles authentication with Realize API using browser flow.""" - async def get_token_details(self) -> Dict[str, Any]: - """Get details about current token - returns raw JSON response.""" - if not self.token: - await self.get_auth_token() - - url = f"{self.base_url}/api/1.0/token-details" - headers = {"Authorization": f"Bearer {self.token.access_token}"} - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers) - response.raise_for_status() - - return response.json() + def __init__(self): + super().__init__() - async def get_auth_header(self) -> dict: - """Get authorization header for API requests.""" - if not self.token or self._is_token_expired(): - await self.get_auth_token() - - return {"Authorization": f"Bearer {self.token.access_token}"} + def set_token(self, access_token: str, expires_in: int): + """Set token from browser authentication.""" + self.token = Token( + access_token=access_token, + token_type="Bearer", + expires_in=expires_in, + created_at=datetime.now() + ) + logger.info("Browser auth token set successfully") - def _is_token_expired(self) -> bool: - """Check if current token is expired.""" - if not self.token or not self.token.created_at: - return True - - expiry_time = self.token.created_at + timedelta(seconds=self.token.expires_in) - return datetime.now() >= expiry_time + async def get_auth_token(self) -> Token: + """Get OAuth token - for browser auth, this raises an error as token must be set via browser flow.""" + if not self.token: + raise Exception("No browser auth token available. Please authenticate using browser_authenticate tool first.") + return self.token -# Global auth instance -auth = RealizeAuth() \ No newline at end of file +# Global auth instance - default to client credentials if available +if (config.realize_client_id and config.realize_client_id != "your_client_id" and + config.realize_client_secret and config.realize_client_secret != "your_client_secret"): + auth = RealizeAuth() +else: + # No client credentials configured, will need browser auth + auth = BrowserAuth() \ No newline at end of file diff --git a/src/realize/config.py b/src/realize/config.py index 043dba7..e8d82fa 100644 --- a/src/realize/config.py +++ b/src/realize/config.py @@ -13,20 +13,6 @@ class Config(BaseSettings): realize_base_url: str = "https://backstage.taboola.com/backstage" log_level: str = "INFO" - @field_validator('realize_client_id') - @classmethod - def validate_client_id(cls, v): - if not v: - raise ValueError("REALIZE_CLIENT_ID is required") - return v - - @field_validator('realize_client_secret') - @classmethod - def validate_client_secret(cls, v): - if not v: - raise ValueError("REALIZE_CLIENT_SECRET is required") - return v - class Config: env_file = ".env" case_sensitive = False diff --git a/src/realize/realize_server.py b/src/realize/realize_server.py index 40c209e..bf8e8b9 100644 --- a/src/realize/realize_server.py +++ b/src/realize/realize_server.py @@ -53,7 +53,11 @@ async def handle_call_tool( try: # Dynamic handler import and execution - if handler_path == "auth_handlers.get_auth_token": + if handler_path == "browser_auth_handlers.browser_authenticate": + from realize.tools.browser_auth_handlers import browser_authenticate + return await browser_authenticate() + + elif handler_path == "auth_handlers.get_auth_token": from realize.tools.auth_handlers import get_auth_token return await get_auth_token() diff --git a/src/realize/tools/browser_auth_handlers.py b/src/realize/tools/browser_auth_handlers.py new file mode 100644 index 0000000..4e12ee3 --- /dev/null +++ b/src/realize/tools/browser_auth_handlers.py @@ -0,0 +1,274 @@ +"""Browser authentication tool handlers.""" +import logging +import webbrowser +import asyncio +import secrets +import urllib.parse +from typing import List, Optional +from aiohttp import web +import mcp.types as types +from realize.auth import auth + +logger = logging.getLogger(__name__) + +# Hardcoded OAuth2 configuration +CLIENT_ID = "5d76124a06234466bb65ee7680afc082" +REDIRECT_URI = "http://localhost:3456/oauth/callback" +AUTH_URL = "https://authentication.taboola.com/authentication/oauth/authorize" +PORT = 3456 + +# Global state storage for OAuth flow +oauth_state = None +auth_result = None +auth_event = None + + +async def browser_authenticate() -> List[types.TextContent]: + """Initiate browser-based OAuth2 authentication flow.""" + global oauth_state, auth_result, auth_event + + # Reset global state + oauth_state = None + auth_result = None + auth_event = asyncio.Event() + + try: + # Generate random state for security + state = secrets.token_urlsafe(32) + oauth_state = state + + # Build authorization URL + params = { + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "response_type": "token", + "state": state, + "appName": "Realize MCP" + } + auth_url = f"{AUTH_URL}?{urllib.parse.urlencode(params)}" + + # Create aiohttp app for callback + app = web.Application() + app.router.add_get('/oauth/callback', handle_callback) + + # Start server + runner = web.AppRunner(app) + await runner.setup() + + try: + site = web.TCPSite(runner, 'localhost', PORT) + await site.start() + except OSError as e: + if "Address already in use" in str(e): + return [ + types.TextContent( + type="text", + text="Authentication server port is already in use. Please try again in a moment." + ) + ] + raise + + logger.info(f"Started OAuth callback server on port {PORT}") + + # Open browser + if not webbrowser.open(auth_url): + logger.warning("Failed to open browser automatically") + return [ + types.TextContent( + type="text", + text=f"Please open this URL in your browser to authenticate:\n{auth_url}" + ) + ] + + logger.info("Opened browser for authentication") + + # Wait for callback (with timeout) + try: + await asyncio.wait_for(auth_event.wait(), timeout=300) # 5 minute timeout + except asyncio.TimeoutError: + await runner.cleanup() + return [ + types.TextContent( + type="text", + text="Authentication timed out. Please try again." + ) + ] + + # Cleanup server + await runner.cleanup() + + # Process result + if auth_result and auth_result.get("success"): + # Update the existing global auth instance + from realize.auth import auth, BrowserAuth + + # Verify we have a BrowserAuth instance + if isinstance(auth, BrowserAuth): + # Update the existing instance's token + auth.set_token(auth_result["access_token"], auth_result["expires_in"]) + logger.info("Updated browser auth token successfully") + else: + # This shouldn't happen if config is correct + logger.error(f"Expected BrowserAuth instance but got {type(auth).__name__}") + return [ + types.TextContent( + type="text", + text="Error: Authentication system is not configured for browser auth. Please check configuration." + ) + ] + + return [ + types.TextContent( + type="text", + text=f"Successfully authenticated via browser. Token expires in {auth_result['expires_in']} seconds." + ) + ] + else: + error_msg = auth_result.get("error", "Unknown error") if auth_result else "No response received" + return [ + types.TextContent( + type="text", + text=f"Authentication failed: {error_msg}" + ) + ] + + except Exception as e: + logger.error(f"Browser authentication failed: {e}") + return [ + types.TextContent( + type="text", + text=f"Browser authentication failed: {str(e)}" + ) + ] + + +async def handle_callback(request): + """Handle OAuth callback from browser.""" + global oauth_state, auth_result, auth_event + + # Extract fragment from URL (for implicit flow, token comes in fragment) + # Since fragments aren't sent to server, we use JavaScript to extract and send as query params + html_content = """ + + + + Realize MCP - Authentication + + + +
+
+

Processing authentication...

+
+
+ + + + """ + + # Check if this is the processed callback with params + if request.query.get('processed'): + # Validate state + state = request.query.get('state') + if state != oauth_state: + auth_result = {"success": False, "error": "Invalid state parameter"} + elif request.query.get('access_token'): + # Success + auth_result = { + "success": True, + "access_token": request.query.get('access_token'), + "expires_in": int(request.query.get('expires_in', 3600)) + } + else: + # Error + auth_result = { + "success": False, + "error": request.query.get('error', 'Unknown error') + } + + # Signal completion + auth_event.set() + + return web.Response(text=html_content, content_type='text/html') \ No newline at end of file diff --git a/src/realize/tools/registry.py b/src/realize/tools/registry.py index cdc8125..640ac94 100644 --- a/src/realize/tools/registry.py +++ b/src/realize/tools/registry.py @@ -3,6 +3,17 @@ # Tool Registry - Add new tools here TOOL_REGISTRY = { # Authentication & Token Tools + "browser_authenticate": { + "description": "Authenticate with Realize API using browser-based OAuth2 flow (read-only). Opens a browser for Taboola SSO login. No client credentials needed. Token stored in memory only.", + "schema": { + "type": "object", + "properties": {}, + "required": [] + }, + "handler": "browser_auth_handlers.browser_authenticate", + "category": "authentication" + }, + "get_auth_token": { "description": "Authenticate with Realize API using client credentials (read-only)", "schema": { From 9936435b13d74e938d2ec55dfc637547192406fe Mon Sep 17 00:00:00 2001 From: Henry Abravanel Date: Wed, 23 Jul 2025 12:12:07 +0300 Subject: [PATCH 02/11] Fixed state extraction on success/failure flows Fixed server cleanup on failure flow --- src/realize/tools/assets/oauth_callback.html | 137 ++++++++++++ src/realize/tools/browser_auth_handlers.py | 214 +++++++------------ 2 files changed, 213 insertions(+), 138 deletions(-) create mode 100644 src/realize/tools/assets/oauth_callback.html diff --git a/src/realize/tools/assets/oauth_callback.html b/src/realize/tools/assets/oauth_callback.html new file mode 100644 index 0000000..57b728b --- /dev/null +++ b/src/realize/tools/assets/oauth_callback.html @@ -0,0 +1,137 @@ + + + + Realize MCP - Authentication + + + +
+
+

Processing authentication...

+
+
+ + + \ No newline at end of file diff --git a/src/realize/tools/browser_auth_handlers.py b/src/realize/tools/browser_auth_handlers.py index 4e12ee3..8ee57da 100644 --- a/src/realize/tools/browser_auth_handlers.py +++ b/src/realize/tools/browser_auth_handlers.py @@ -4,6 +4,7 @@ import asyncio import secrets import urllib.parse +import os from typing import List, Optional from aiohttp import web import mcp.types as types @@ -50,6 +51,7 @@ async def browser_authenticate() -> List[types.TextContent]: # Create aiohttp app for callback app = web.Application() app.router.add_get('/oauth/callback', handle_callback) + app.router.add_post('/oauth/process', handle_process) # Start server runner = web.AppRunner(app) @@ -59,6 +61,7 @@ async def browser_authenticate() -> List[types.TextContent]: site = web.TCPSite(runner, 'localhost', PORT) await site.start() except OSError as e: + await runner.cleanup() # Cleanup runner if site start fails if "Address already in use" in str(e): return [ types.TextContent( @@ -70,32 +73,33 @@ async def browser_authenticate() -> List[types.TextContent]: logger.info(f"Started OAuth callback server on port {PORT}") - # Open browser - if not webbrowser.open(auth_url): - logger.warning("Failed to open browser automatically") - return [ - types.TextContent( - type="text", - text=f"Please open this URL in your browser to authenticate:\n{auth_url}" - ) - ] - - logger.info("Opened browser for authentication") - - # Wait for callback (with timeout) try: - await asyncio.wait_for(auth_event.wait(), timeout=300) # 5 minute timeout - except asyncio.TimeoutError: + # Open browser + if not webbrowser.open(auth_url): + logger.warning("Failed to open browser automatically") + return [ + types.TextContent( + type="text", + text=f"Please open this URL in your browser to authenticate:\n{auth_url}" + ) + ] + + logger.info("Opened browser for authentication") + + # Wait for callback (with timeout) + try: + await asyncio.wait_for(auth_event.wait(), timeout=300) # 5 minute timeout + except asyncio.TimeoutError: + return [ + types.TextContent( + type="text", + text="Authentication timed out. Please try again." + ) + ] + finally: + # Always cleanup server, even if there's an error await runner.cleanup() - return [ - types.TextContent( - type="text", - text="Authentication timed out. Please try again." - ) - ] - - # Cleanup server - await runner.cleanup() + logger.info("Cleaned up OAuth callback server") # Process result if auth_result and auth_result.get("success"): @@ -143,132 +147,66 @@ async def browser_authenticate() -> List[types.TextContent]: async def handle_callback(request): - """Handle OAuth callback from browser.""" - global oauth_state, auth_result, auth_event + """Handle OAuth callback from browser - serves the HTML page.""" + # Load HTML content from external file + html_file_path = os.path.join(os.path.dirname(__file__), 'assets', 'oauth_callback.html') + try: + with open(html_file_path, 'r') as f: + html_content = f.read() + except Exception as e: + logger.error(f"Failed to load OAuth callback HTML: {e}") + html_content = """ + + + +

Error

+

Failed to load authentication page. Please try again.

+ + + """ - # Extract fragment from URL (for implicit flow, token comes in fragment) - # Since fragments aren't sent to server, we use JavaScript to extract and send as query params - html_content = """ - - - - Realize MCP - Authentication - - - -
-
-

Processing authentication...

-
-
- - - - """ + return web.Response(text=html_content, content_type='text/html') + + +async def handle_process(request): + """Handle OAuth token processing via POST.""" + global oauth_state, auth_result, auth_event - # Check if this is the processed callback with params - if request.query.get('processed'): + try: + # Get JSON data from request body + data = await request.json() + # Validate state - state = request.query.get('state') + state = data.get('state') if state != oauth_state: auth_result = {"success": False, "error": "Invalid state parameter"} - elif request.query.get('access_token'): + auth_event.set() + return web.json_response({"status": "error", "message": "Invalid state parameter"}, status=400) + + # Check for access token + if data.get('access_token'): # Success auth_result = { "success": True, - "access_token": request.query.get('access_token'), - "expires_in": int(request.query.get('expires_in', 3600)) + "access_token": data.get('access_token'), + "expires_in": int(data.get('expires_in', 3600)) } + # Signal completion + auth_event.set() + return web.json_response({"status": "success", "message": "Authentication successful"}) else: # Error + error_msg = data.get('error', 'No access token received') auth_result = { "success": False, - "error": request.query.get('error', 'Unknown error') + "error": error_msg } - - # Signal completion + # Signal completion + auth_event.set() + return web.json_response({"status": "error", "message": error_msg}, status=400) + + except Exception as e: + logger.error(f"Error processing OAuth callback: {e}") + auth_result = {"success": False, "error": str(e)} auth_event.set() - - return web.Response(text=html_content, content_type='text/html') \ No newline at end of file + return web.json_response({"status": "error", "message": "Failed to process authentication"}, status=500) \ No newline at end of file From e0cf46618f4b3e8a7e6be2e2542bbd449ce58c0f Mon Sep 17 00:00:00 2001 From: Henry Abravanel Date: Wed, 23 Jul 2025 13:20:20 +0300 Subject: [PATCH 03/11] Added more time to authenticate Added system termination signal listening to clean up callback server (if running) --- src/realize/tools/browser_auth_handlers.py | 61 +++++++++++++++++++--- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/src/realize/tools/browser_auth_handlers.py b/src/realize/tools/browser_auth_handlers.py index 8ee57da..71b169c 100644 --- a/src/realize/tools/browser_auth_handlers.py +++ b/src/realize/tools/browser_auth_handlers.py @@ -5,6 +5,8 @@ import secrets import urllib.parse import os +import signal +import atexit from typing import List, Optional from aiohttp import web import mcp.types as types @@ -22,11 +24,53 @@ oauth_state = None auth_result = None auth_event = None +current_runner = None # Track active server for cleanup + + +def cleanup_server(): + """Cleanup function for signal handlers and atexit.""" + global current_runner + if current_runner: + try: + # Create a new event loop if needed (for signal handlers) + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + if not loop.is_running(): + loop.run_until_complete(current_runner.cleanup()) + else: + # If loop is running, schedule cleanup + loop.create_task(current_runner.cleanup()) + + logger.info("Cleaned up OAuth server on process exit") + current_runner = None + except Exception as e: + logger.error(f"Error during server cleanup: {e}") + + +def setup_cleanup_handlers(): + """Setup signal handlers and atexit callback for cleanup.""" + # Register cleanup for normal exit + atexit.register(cleanup_server) + + # Register signal handlers for graceful shutdown + def signal_handler(signum, frame): + logger.info(f"Received signal {signum}, cleaning up OAuth server") + cleanup_server() + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) async def browser_authenticate() -> List[types.TextContent]: """Initiate browser-based OAuth2 authentication flow.""" - global oauth_state, auth_result, auth_event + global oauth_state, auth_result, auth_event, current_runner + + # Setup cleanup handlers + setup_cleanup_handlers() # Reset global state oauth_state = None @@ -55,6 +99,7 @@ async def browser_authenticate() -> List[types.TextContent]: # Start server runner = web.AppRunner(app) + current_runner = runner # Track for cleanup await runner.setup() try: @@ -62,11 +107,12 @@ async def browser_authenticate() -> List[types.TextContent]: await site.start() except OSError as e: await runner.cleanup() # Cleanup runner if site start fails + current_runner = None # Clear tracking if "Address already in use" in str(e): return [ types.TextContent( type="text", - text="Authentication server port is already in use. Please try again in a moment." + text="Authentication server port 3456 is already in use. This might be from a previous authentication session. Please wait 30 seconds and try again, or restart your terminal to force cleanup." ) ] raise @@ -80,7 +126,7 @@ async def browser_authenticate() -> List[types.TextContent]: return [ types.TextContent( type="text", - text=f"Please open this URL in your browser to authenticate:\n{auth_url}" + text=f"Could not open browser automatically. Please copy and paste this URL into your browser to authenticate:\n\n{auth_url}\n\nYou have 15 minutes to complete the authentication." ) ] @@ -88,17 +134,18 @@ async def browser_authenticate() -> List[types.TextContent]: # Wait for callback (with timeout) try: - await asyncio.wait_for(auth_event.wait(), timeout=300) # 5 minute timeout + await asyncio.wait_for(auth_event.wait(), timeout=900) # 15 minute timeout except asyncio.TimeoutError: return [ types.TextContent( type="text", - text="Authentication timed out. Please try again." + text="Authentication timed out after 15 minutes. Please try again." ) ] finally: # Always cleanup server, even if there's an error await runner.cleanup() + current_runner = None # Clear tracking logger.info("Cleaned up OAuth callback server") # Process result @@ -132,7 +179,7 @@ async def browser_authenticate() -> List[types.TextContent]: return [ types.TextContent( type="text", - text=f"Authentication failed: {error_msg}" + text=f"Authentication failed: {error_msg}. Please try running the authentication command again." ) ] @@ -141,7 +188,7 @@ async def browser_authenticate() -> List[types.TextContent]: return [ types.TextContent( type="text", - text=f"Browser authentication failed: {str(e)}" + text=f"Browser authentication failed due to an unexpected error: {str(e)}. Please try again or check your network connection." ) ] From 8bf6f7c0722bb572832b6cb33698fbc70c9e22d0 Mon Sep 17 00:00:00 2001 From: Henry Abravanel Date: Wed, 23 Jul 2025 14:30:21 +0300 Subject: [PATCH 04/11] Update README to reflect browser authentication and restructure for clarity. --- README.md | 56 ++++++++++++++++------ src/realize/auth.py | 5 ++ src/realize/tools/browser_auth_handlers.py | 36 +++++++++++++- src/realize/tools/registry.py | 22 ++++++++- 4 files changed, 102 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index a0a9517..b4017a8 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,34 @@ A Model Context Protocol (MCP) server that provides read-only access to Taboola' ## Quick Start -### Cursor IDE Setup +### 1. Install + +```bash +pip install realize-mcp +``` + +### 2. Choose Your Authentication Method + +**Option A: Browser Authentication** (Recommended - No credentials needed) +- Configure MCP without credentials +- Use the `browser_authenticate` tool when you start + +**Option B: API Credentials in MCP Config** +- Add credentials directly to your MCP client configuration +- Best for automated workflows + +**Option C: API Credentials via Environment Variables** +- Export credentials as environment variables +- Useful for shared configurations or CI/CD + +```bash +export REALIZE_CLIENT_ID="your_client_id" +export REALIZE_CLIENT_SECRET="your_client_secret" +``` + +### 3. Configure Your MCP Client + +**Cursor IDE** Add to Cursor Settings → Features → Model Context Protocol: @@ -22,6 +49,7 @@ Add to Cursor Settings → Features → Model Context Protocol: "realize-mcp": { "command": "realize-mcp-server", "env": { + // For Option B only - add your credentials here "REALIZE_CLIENT_ID": "your_client_id", "REALIZE_CLIENT_SECRET": "your_client_secret" } @@ -30,7 +58,7 @@ Add to Cursor Settings → Features → Model Context Protocol: } ``` -### Claude Desktop Setup +**Claude Desktop** Add to your `claude_desktop_config.json`: @@ -40,6 +68,7 @@ Add to your `claude_desktop_config.json`: "realize-mcp": { "command": "realize-mcp-server", "env": { + // For Option B only - add your credentials here "REALIZE_CLIENT_ID": "your_client_id", "REALIZE_CLIENT_SECRET": "your_client_secret" } @@ -48,18 +77,13 @@ Add to your `claude_desktop_config.json`: } ``` -### Installation - -```bash -pip install realize-mcp -``` +*Note: For Options A & C, omit the `env` section entirely.* -### Setup +### 4. Start Using -```bash -# Set credentials -export REALIZE_CLIENT_ID="your_client_id" -export REALIZE_CLIENT_SECRET="your_client_secret" +``` +User: "Show me campaigns for Marketing Corp" +AI: I'll search for that account and retrieve the campaigns. ``` ## Basic Usage @@ -92,14 +116,16 @@ AI: - `get_campaign_site_day_breakdown_report` - Site/day performance breakdown with sorting & pagination ### 🔐 Authentication -- `get_auth_token` - Authenticate with Realize API -- `get_token_details` - Check token information +- `get_auth_token` - Authenticate using client credentials (only available when credentials are configured) +- `browser_authenticate` - Interactive browser authentication via Taboola SSO +- `get_token_details` - Check current authentication token information +- `clear_auth_token` - Clear stored browser authentication token ## Prerequisites - **Python 3.10+** (Python 3.11+ recommended) -- **Taboola Realize API credentials** (client ID and secret) - **MCP-compatible client** (Claude Desktop, Cursor, VS Code, etc.) +- **Taboola Realize API credentials** (optional - can use browser authentication instead) ## Usage Examples diff --git a/src/realize/auth.py b/src/realize/auth.py index f4e05c6..a67be69 100644 --- a/src/realize/auth.py +++ b/src/realize/auth.py @@ -93,6 +93,11 @@ def set_token(self, access_token: str, expires_in: int): ) logger.info("Browser auth token set successfully") + def clear_token(self): + """Clear the stored authentication token.""" + self.token = None + logger.info("Browser auth token cleared") + async def get_auth_token(self) -> Token: """Get OAuth token - for browser auth, this raises an error as token must be set via browser flow.""" if not self.token: diff --git a/src/realize/tools/browser_auth_handlers.py b/src/realize/tools/browser_auth_handlers.py index 71b169c..d9bfef0 100644 --- a/src/realize/tools/browser_auth_handlers.py +++ b/src/realize/tools/browser_auth_handlers.py @@ -256,4 +256,38 @@ async def handle_process(request): logger.error(f"Error processing OAuth callback: {e}") auth_result = {"success": False, "error": str(e)} auth_event.set() - return web.json_response({"status": "error", "message": "Failed to process authentication"}, status=500) \ No newline at end of file + return web.json_response({"status": "error", "message": "Failed to process authentication"}, status=500) + + +async def clear_auth_token() -> List[types.TextContent]: + """Remove stored authentication token, forcing user to reauthenticate.""" + try: + from realize.auth import auth, BrowserAuth + + if isinstance(auth, BrowserAuth): + # Clear the token from the auth instance + auth.clear_token() + logger.info("Successfully cleared browser authentication token") + + return [ + types.TextContent( + type="text", + text="Authentication token has been removed from memory. You will need to authenticate again for future API requests." + ) + ] + else: + return [ + types.TextContent( + type="text", + text="No browser authentication token found to remove." + ) + ] + + except Exception as e: + logger.error(f"Failed to clear authentication token: {e}") + return [ + types.TextContent( + type="text", + text=f"Failed to remove authentication token: {str(e)}" + ) + ] \ No newline at end of file diff --git a/src/realize/tools/registry.py b/src/realize/tools/registry.py index 640ac94..66aaf42 100644 --- a/src/realize/tools/registry.py +++ b/src/realize/tools/registry.py @@ -14,6 +14,17 @@ "category": "authentication" }, + "clear_auth_token": { + "description": "Remove stored authentication token from memory, forcing user to reauthenticate. Use this when you need to switch accounts or clear credentials.", + "schema": { + "type": "object", + "properties": {}, + "required": [] + }, + "handler": "browser_auth_handlers.clear_auth_token", + "category": "authentication" + }, + "get_auth_token": { "description": "Authenticate with Realize API using client credentials (read-only)", "schema": { @@ -348,7 +359,16 @@ def get_all_tools(): """Get all registered tools.""" import copy - return copy.deepcopy(TOOL_REGISTRY) + from realize.config import config + + tools = copy.deepcopy(TOOL_REGISTRY) + + # Remove get_auth_token if client credentials are not configured + if (not config.realize_client_id or config.realize_client_id == "your_client_id" or + not config.realize_client_secret or config.realize_client_secret == "your_client_secret"): + tools.pop("get_auth_token", None) + + return tools def get_tools_by_category(category: str): From b56b7628d9c5f94d241dd159d251330b01f68c71 Mon Sep 17 00:00:00 2001 From: Henry Abravanel Date: Wed, 23 Jul 2025 14:48:28 +0300 Subject: [PATCH 05/11] Added a direct installation button for cursor IDE --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index b4017a8..bd92cac 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ export REALIZE_CLIENT_SECRET="your_client_secret" **Cursor IDE** +[![Install MCP Server](https://cursor.com/deeplink/mcp-install-light.svg)](https://cursor.com/install-mcp?name=realize-mcp&config=JTdCJTIyY29tbWFuZCUyMiUzQSUyMnJlYWxpemUtbWNwLXNlcnZlciUyMiU3RA%3D%3D) + Add to Cursor Settings → Features → Model Context Protocol: ```json From bb5bcd242d062f25bff2ff5500f4d739f9448bab Mon Sep 17 00:00:00 2001 From: Henry Abravanel Date: Wed, 23 Jul 2025 14:54:20 +0300 Subject: [PATCH 06/11] Minor readme change --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bd92cac..084f9bd 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ export REALIZE_CLIENT_SECRET="your_client_secret" [![Install MCP Server](https://cursor.com/deeplink/mcp-install-light.svg)](https://cursor.com/install-mcp?name=realize-mcp&config=JTdCJTIyY29tbWFuZCUyMiUzQSUyMnJlYWxpemUtbWNwLXNlcnZlciUyMiU3RA%3D%3D) -Add to Cursor Settings → Features → Model Context Protocol: +Or (manually) add to Cursor Settings → Features → Model Context Protocol: ```json { From 9554af59a805ff60f6a709d73276ebf442c071f9 Mon Sep 17 00:00:00 2001 From: Henry Abravanel Date: Thu, 24 Jul 2025 03:07:33 +0300 Subject: [PATCH 07/11] - Refactored registry tool loading to conditionally include `get_auth_token` based on client credentials configuration. - Fixed state extraction logic in OAuth callback to handle both buggy and correct formats. - Fixed handler support for `clear_auth_token` in `realize_server.py`. - Fixed campaign handlers to use correct API endpoint paths (`/items` instead of `/campaign_items`). --- src/realize/realize_server.py | 4 + src/realize/tools/assets/oauth_callback.html | 12 ++- src/realize/tools/campaign_handlers.py | 4 +- src/realize/tools/registry.py | 100 +++++++++---------- 4 files changed, 64 insertions(+), 56 deletions(-) diff --git a/src/realize/realize_server.py b/src/realize/realize_server.py index bf8e8b9..5d7e6a2 100644 --- a/src/realize/realize_server.py +++ b/src/realize/realize_server.py @@ -57,6 +57,10 @@ async def handle_call_tool( from realize.tools.browser_auth_handlers import browser_authenticate return await browser_authenticate() + elif handler_path == "browser_auth_handlers.clear_auth_token": + from realize.tools.browser_auth_handlers import clear_auth_token + return await clear_auth_token() + elif handler_path == "auth_handlers.get_auth_token": from realize.tools.auth_handlers import get_auth_token return await get_auth_token() diff --git a/src/realize/tools/assets/oauth_callback.html b/src/realize/tools/assets/oauth_callback.html index 57b728b..b7cf125 100644 --- a/src/realize/tools/assets/oauth_callback.html +++ b/src/realize/tools/assets/oauth_callback.html @@ -55,8 +55,16 @@ // Check for error in query params (OAuth error responses come in query string) const error = queryParams.get('error'); - // State comes from fragment for success, query for errors - const state = error ? queryParams.get('state') : params.get('state'); + + // Extract state - handle both formats + let state = null; + if (error) { + // Error case - state in query params + state = queryParams.get('state'); + } else { + // Success case - check __default__ first (only exists in buggy format), then fallback to state + state = params.get('__default__') || params.get('state'); + } let authData = {}; let status = ''; diff --git a/src/realize/tools/campaign_handlers.py b/src/realize/tools/campaign_handlers.py index 5774ee4..41404ae 100644 --- a/src/realize/tools/campaign_handlers.py +++ b/src/realize/tools/campaign_handlers.py @@ -95,7 +95,7 @@ async def get_campaign_items(arguments: dict = None) -> List[types.TextContent]: )] # Make API request to get campaign items - returns raw JSON dict - response = await client.get(f"/{account_id}/campaigns/{campaign_id}/campaign_items") + response = await client.get(f"/{account_id}/campaigns/{campaign_id}/items") return [types.TextContent( type="text", @@ -132,7 +132,7 @@ async def get_campaign_item(arguments: dict = None) -> List[types.TextContent]: )] # Make API request to get campaign item details - returns raw JSON dict - response = await client.get(f"/{account_id}/campaigns/{campaign_id}/campaign_items/{item_id}") + response = await client.get(f"/{account_id}/campaigns/{campaign_id}/items/{item_id}") return [types.TextContent( type="text", diff --git a/src/realize/tools/registry.py b/src/realize/tools/registry.py index 66aaf42..525c210 100644 --- a/src/realize/tools/registry.py +++ b/src/realize/tools/registry.py @@ -1,4 +1,5 @@ """Centralized registry for all MCP tools.""" +from realize.config import config # Tool Registry - Add new tools here TOOL_REGISTRY = { @@ -13,7 +14,7 @@ "handler": "browser_auth_handlers.browser_authenticate", "category": "authentication" }, - + "clear_auth_token": { "description": "Remove stored authentication token from memory, forcing user to reauthenticate. Use this when you need to switch accounts or clear credentials.", "schema": { @@ -24,29 +25,18 @@ "handler": "browser_auth_handlers.clear_auth_token", "category": "authentication" }, - - "get_auth_token": { - "description": "Authenticate with Realize API using client credentials (read-only)", - "schema": { - "type": "object", - "properties": {}, - "required": [] - }, - "handler": "auth_handlers.get_auth_token", - "category": "authentication" - }, - + "get_token_details": { "description": "Get details about current authentication token (read-only)", "schema": { - "type": "object", + "type": "object", "properties": {}, "required": [] }, "handler": "auth_handlers.get_token_details", "category": "authentication" }, - + # Account Management Tools "search_accounts": { "description": "Search for accounts by numeric ID or text query to get account_id values needed for other tools (read-only). Returns account data including 'account_id' field (camelCase string) required for campaign and report operations. WORKFLOW: Use this tool FIRST to get account_id values, then use those values with other tools.", @@ -76,7 +66,7 @@ "handler": "account_handlers.search_accounts", "category": "accounts" }, - + # Campaign Management Tools (READ-ONLY) "get_all_campaigns": { "description": "Get all campaigns for an account (read-only). WORKFLOW REQUIRED: First use search_accounts to get account_id, then use that value here. Example: 1) search_accounts('company_name') 2) Extract 'account_id' from results 3) Use account_id parameter here", @@ -104,7 +94,7 @@ "description": "Account ID (string from search_accounts response 'account_id' field - NOT numeric ID). Workflow: 1) search_accounts('query') 2) Use 'account_id' from results" }, "campaign_id": { - "type": "string", + "type": "string", "description": "Campaign ID to get details for" } }, @@ -121,11 +111,11 @@ "type": "object", "properties": { "account_id": { - "type": "string", + "type": "string", "description": "Account ID (string from search_accounts response 'account_id' field - NOT numeric ID). Workflow: 1) search_accounts('query') 2) Use 'account_id' from results" }, "campaign_id": { - "type": "string", + "type": "string", "description": "Campaign ID" } }, @@ -141,15 +131,15 @@ "type": "object", "properties": { "account_id": { - "type": "string", + "type": "string", "description": "Account ID (string from search_accounts response 'account_id' field - NOT numeric ID). Workflow: 1) search_accounts('query') 2) Use 'account_id' from results" }, "campaign_id": { - "type": "string", + "type": "string", "description": "Campaign ID" }, "item_id": { - "type": "string", + "type": "string", "description": "Item ID to get details for" } }, @@ -167,15 +157,15 @@ "type": "object", "properties": { "account_id": { - "type": "string", + "type": "string", "description": "Account ID (string from search_accounts response 'account_id' field - NOT numeric ID). Workflow: 1) search_accounts('query') 2) Use 'account_id' from results" }, "start_date": { - "type": "string", + "type": "string", "description": "Start date (YYYY-MM-DD)" }, "end_date": { - "type": "string", + "type": "string", "description": "End date (YYYY-MM-DD)" }, "page": { @@ -215,15 +205,15 @@ "type": "object", "properties": { "account_id": { - "type": "string", + "type": "string", "description": "Account ID (string from search_accounts response 'account_id' field - NOT numeric ID). Workflow: 1) search_accounts('query') 2) Use 'account_id' from results" }, "start_date": { - "type": "string", + "type": "string", "description": "Start date (YYYY-MM-DD)" }, "end_date": { - "type": "string", + "type": "string", "description": "End date (YYYY-MM-DD)" }, "page": { @@ -252,19 +242,19 @@ "type": "object", "properties": { "account_id": { - "type": "string", + "type": "string", "description": "Account ID (string from search_accounts response 'account_id' field - NOT numeric ID). Workflow: 1) search_accounts('query') 2) Use 'account_id' from results" }, "start_date": { - "type": "string", + "type": "string", "description": "Start date (YYYY-MM-DD)" }, "end_date": { - "type": "string", + "type": "string", "description": "End date (YYYY-MM-DD)" }, "filters": { - "type": "object", + "type": "object", "description": "Optional filters (flexible JSON object)", "additionalProperties": {"type": "string"} }, @@ -305,19 +295,19 @@ "type": "object", "properties": { "account_id": { - "type": "string", + "type": "string", "description": "Account ID (string from search_accounts response 'account_id' field - NOT numeric ID). Workflow: 1) search_accounts('query') 2) Use 'account_id' from results" }, "start_date": { - "type": "string", + "type": "string", "description": "Start date (YYYY-MM-DD)" }, "end_date": { - "type": "string", + "type": "string", "description": "End date (YYYY-MM-DD)" }, "filters": { - "type": "object", + "type": "object", "description": "Optional filters (flexible JSON object)", "additionalProperties": {"type": "string"} }, @@ -350,35 +340,41 @@ }, "handler": "report_handlers.get_campaign_site_day_breakdown_report", "category": "reports" - }, - - + } } +# Remove get_auth_token if client credentials are not configured +if (config.realize_client_id and config.realize_client_id.strip() and + config.realize_client_id != "your_client_id" and + config.realize_client_secret and config.realize_client_secret.strip() and + config.realize_client_secret != "your_client_secret"): + TOOL_REGISTRY["get_auth_token"] = { + "description": "Authenticate with Realize API using client credentials (read-only)", + "schema": { + "type": "object", + "properties": {}, + "required": [] + }, + "handler": "auth_handlers.get_auth_token", + "category": "authentication" + } + def get_all_tools(): """Get all registered tools.""" import copy - from realize.config import config - - tools = copy.deepcopy(TOOL_REGISTRY) - - # Remove get_auth_token if client credentials are not configured - if (not config.realize_client_id or config.realize_client_id == "your_client_id" or - not config.realize_client_secret or config.realize_client_secret == "your_client_secret"): - tools.pop("get_auth_token", None) - - return tools + + return copy.deepcopy(TOOL_REGISTRY) def get_tools_by_category(category: str): """Get tools filtered by category.""" import copy - return {name: copy.deepcopy(tool) for name, tool in TOOL_REGISTRY.items() + return {name: copy.deepcopy(tool) for name, tool in TOOL_REGISTRY.items() if tool.get("category") == category} def get_tool_categories(): """Get list of all available categories.""" - return list(set(tool.get("category", "uncategorized") - for tool in TOOL_REGISTRY.values())) \ No newline at end of file + return list(set(tool.get("category", "uncategorized") + for tool in TOOL_REGISTRY.values())) From 17a814df69d99f92d6c2bc7a837cde1f4f86fc7b Mon Sep 17 00:00:00 2001 From: Henry Abravanel Date: Thu, 24 Jul 2025 03:35:50 +0300 Subject: [PATCH 08/11] - Reintroduced `browser_authenticate` tool with conditional inclusion based on client credentials. - Added `tools/assets` to package data for proper asset management. - Enhanced documentation to reflect browser-based authentication flow and updated installation details. --- design.md | 83 ++++++++++++++++++++++------------- pyproject.toml | 2 +- src/realize/tools/registry.py | 26 +++++------ 3 files changed, 67 insertions(+), 44 deletions(-) diff --git a/design.md b/design.md index 2a79691..f35a575 100644 --- a/design.md +++ b/design.md @@ -25,6 +25,7 @@ This document contains detailed technical information, architectural details, an - ✅ Converted ALL 15 remaining relative imports in realize_server.py to absolute imports - ✅ MCP Inspector can now successfully list and execute tools - ✅ No more import errors when starting the MCP server +- ✅ Added browser-based authentication support **v1.0.4+** - Enhanced package structure and import system: - ✅ Migrated most imports to use full path imports instead of relative imports @@ -43,6 +44,7 @@ pip install --upgrade realize-mcp ### Code Organization Enhancement - **Recent Update (January 2025)** - Moved data models closer to authentication logic for cleaner import paths and better module organization +- **Browser Authentication** - Added OAuth2 browser-based authentication flow that doesn't require API credentials ## Architecture @@ -74,9 +76,11 @@ pip install --upgrade realize-mcp - **Error Handling**: Process API responses and handle errors gracefully #### 3. Tool Implementations -- **Campaign Management**: Create, update, and manage campaigns -- **Performance Reporting**: Retrieve and analyze campaign data -- **Campaign Items**: Manage campaign creative items and assets +- **Authentication**: Browser-based OAuth2 and client credentials support +- **Account Management**: Search and access account information +- **Campaign Management**: List and retrieve campaign details (read-only) +- **Performance Reporting**: Retrieve and analyze campaign data in CSV format +- **Campaign Items**: List and retrieve campaign creative items (read-only) ## Detailed Installation @@ -93,13 +97,21 @@ pip install -r requirements.txt ### 3. Configure Credentials -**Option A: Environment Variables** +**Option A: Browser Authentication (Recommended)** +- No credentials needed - configure MCP without credentials +- Use the `browser_authenticate` tool when you start + +**Option B: API Credentials via MCP Config** +- Add credentials directly to your MCP client configuration +- Best for automated workflows + +**Option C: Environment Variables** ```bash export REALIZE_CLIENT_ID="your_client_id" export REALIZE_CLIENT_SECRET="your_client_secret" ``` -**Option B: .env File** +**Option D: .env File** ```bash # Create .env file echo "REALIZE_CLIENT_ID=your_client_id" > .env @@ -108,7 +120,7 @@ echo "REALIZE_CLIENT_SECRET=your_client_secret" >> .env ### 4. Test Installation ```bash -python src/realize_server.py +python src/realize/realize_server.py ``` ### 5. Verify Installation @@ -142,9 +154,9 @@ Server listening on stdio transport { "mcpServers": { "realize-mcp": { - "command": "python", - "args": ["/absolute/path/to/realize-mcp/src/realize_server.py"], + "command": "realize-mcp-server", "env": { + // For Option B only - add your credentials here "REALIZE_CLIENT_ID": "your_client_id", "REALIZE_CLIENT_SECRET": "your_client_secret" } @@ -160,9 +172,11 @@ Server listening on stdio transport #### VS Code ```bash -code --add-mcp '{"name":"realize-mcp","command":"python","args":["/path/to/realize-mcp/src/realize_server.py"],"env":{"REALIZE_CLIENT_ID":"your_id","REALIZE_CLIENT_SECRET":"your_secret"}}' +code --add-mcp '{"name":"realize-mcp","command":"realize-mcp-server","env":{"REALIZE_CLIENT_ID":"your_id","REALIZE_CLIENT_SECRET":"your_secret"}}' ``` +*Note: For Options A & C, omit the `env` section entirely.* + ## Advanced Features ### Enhanced CSV Response Format @@ -260,12 +274,12 @@ realize-mcp/ │ │ ├── __init__.py # Package initialization with version │ │ ├── _version.py # Version management │ │ ├── realize_server.py # Main MCP server (entry point) -│ │ ├── auth.py # Authentication +│ │ ├── auth.py # Authentication logic │ │ ├── client.py # HTTP client -│ │ └── py.typed # Type hints support -│ ├── tools/ # Tool implementations -│ ├── models/ # Data models -│ └── config.py # Configuration +│ │ ├── config.py # Configuration management +│ │ ├── models.py # Data models +│ │ ├── py.typed # Type hints support +│ │ └── tools/ # Tool implementations ├── scripts/ │ └── deploy.py # Comprehensive deployment script └── env.template # Environment variable template @@ -486,7 +500,7 @@ The testing suite validates that the MCP server works correctly in all scenarios ✅ **Package Installation**: Both development (`pip install -e .`) and production installs ✅ **Import Resolution**: Proper module imports when installed vs running from source -✅ **Tool Discovery**: All 11 tools properly registered and discoverable via MCP +✅ **Tool Discovery**: All 12 tools properly registered and discoverable via MCP ✅ **Error Resilience**: Graceful handling of network failures, malformed inputs, and edge cases ✅ **Configuration Flexibility**: Works with/without .env files and environment variables ✅ **MCP Compliance**: Proper MCP protocol implementation with correct types and schemas @@ -594,19 +608,26 @@ The test suite ensures consistent behavior across different deployment environme ### Project Structure ``` src/ -├── realize_server.py # Main MCP server -├── config.py # Configuration management -├── tools/ # Tool implementations -│ ├── registry.py # Tool registry -│ ├── auth_handlers.py # Authentication tools -│ ├── account_handlers.py # Account management -│ ├── campaign_handlers.py# Campaign tools -│ └── report_handlers.py # Reporting tools -├── realize/ # API client -│ ├── auth.py # Authentication -│ └── client.py # HTTP client -└── models/ - └── realize.py # Data models +├── realize/ +│ ├── __init__.py # Package initialization with version +│ ├── _version.py # Version management +│ ├── realize_server.py # Main MCP server (entry point) +│ ├── auth.py # Authentication logic +│ ├── client.py # HTTP client +│ ├── config.py # Configuration management +│ ├── models.py # Data models +│ ├── py.typed # Type hints support +│ └── tools/ # Tool implementations +│ ├── __init__.py +│ ├── registry.py # Tool registry +│ ├── auth_handlers.py # Authentication tools +│ ├── browser_auth_handlers.py # Browser OAuth authentication +│ ├── account_handlers.py # Account management +│ ├── campaign_handlers.py# Campaign tools +│ ├── report_handlers.py # Reporting tools +│ ├── utils.py # Utility functions +│ └── assets/ # Static assets +│ └── oauth_callback.html # OAuth callback page ``` ### Running Tests @@ -681,7 +702,7 @@ pip install -r requirements.txt # Run server with debug logging export LOG_LEVEL=DEBUG -python src/realize_server.py +python src/realize/realize_server.py # Run tests with various options python -m pytest tests/ -v # All tests @@ -902,7 +923,7 @@ print(f'Found {len(get_all_tools())} tools') Enable detailed logging for troubleshooting: ```bash export LOG_LEVEL=DEBUG -python src/realize_server.py +python src/realize/realize_server.py ``` Debug mode provides: @@ -970,6 +991,7 @@ Common log patterns to look for: - **httpx** - Modern async HTTP client for Realize API calls - **Pydantic** - Data validation and serialization (minimal usage for flexibility) - **python-dotenv** - Environment configuration management +- **aiohttp** - Async HTTP server for browser authentication flow ### Development Dependencies @@ -1025,6 +1047,7 @@ Common log patterns to look for: #### Authentication - `POST /oauth/token` - Get access token using client credentials +- Browser OAuth2 Flow - Uses Taboola SSO at `https://authentication.taboola.com` #### Account Management - `GET /users/current/account` - Get account information diff --git a/pyproject.toml b/pyproject.toml index 72db84d..954762f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,4 +44,4 @@ realize-mcp-server = "realize.realize_server:cli_main" where = ["src"] [tool.setuptools.package-data] -realize = ["py.typed"] \ No newline at end of file +realize = ["py.typed", "tools/assets/**/*"] \ No newline at end of file diff --git a/src/realize/tools/registry.py b/src/realize/tools/registry.py index 525c210..72432c1 100644 --- a/src/realize/tools/registry.py +++ b/src/realize/tools/registry.py @@ -4,16 +4,6 @@ # Tool Registry - Add new tools here TOOL_REGISTRY = { # Authentication & Token Tools - "browser_authenticate": { - "description": "Authenticate with Realize API using browser-based OAuth2 flow (read-only). Opens a browser for Taboola SSO login. No client credentials needed. Token stored in memory only.", - "schema": { - "type": "object", - "properties": {}, - "required": [] - }, - "handler": "browser_auth_handlers.browser_authenticate", - "category": "authentication" - }, "clear_auth_token": { "description": "Remove stored authentication token from memory, forcing user to reauthenticate. Use this when you need to switch accounts or clear credentials.", @@ -343,10 +333,9 @@ } } -# Remove get_auth_token if client credentials are not configured -if (config.realize_client_id and config.realize_client_id.strip() and +if (config.realize_client_id and config.realize_client_id.strip() and config.realize_client_id != "your_client_id" and - config.realize_client_secret and config.realize_client_secret.strip() and + config.realize_client_secret and config.realize_client_secret.strip() and config.realize_client_secret != "your_client_secret"): TOOL_REGISTRY["get_auth_token"] = { "description": "Authenticate with Realize API using client credentials (read-only)", @@ -358,6 +347,17 @@ "handler": "auth_handlers.get_auth_token", "category": "authentication" } +else: + TOOL_REGISTRY["browser_authenticate"] = { + "description": "Authenticate with Realize API using browser-based OAuth2 flow (read-only). Opens a browser for Taboola SSO login. No client credentials needed. Token stored in memory only.", + "schema": { + "type": "object", + "properties": {}, + "required": [] + }, + "handler": "browser_auth_handlers.browser_authenticate", + "category": "authentication" + } def get_all_tools(): From a71ad3e4fe3a6b142fb1c22a379b5fe029041f45 Mon Sep 17 00:00:00 2001 From: Henry Abravanel Date: Thu, 24 Jul 2025 04:22:53 +0300 Subject: [PATCH 09/11] - Migrated `browser_auth_handlers` functionality to `auth_handlers` for better modularity. - Updated registry to reference the new `auth_handlers` namespace. - Refactored tests to support both credential and browser-based authentication tools. - Added `tomli` dependency for Python versions below 3.11. --- design.md | 150 ++++------- pyproject.toml | 1 + src/realize/realize_server.py | 8 +- src/realize/tools/auth_handlers.py | 288 +++++++++++++++++++- src/realize/tools/browser_auth_handlers.py | 293 --------------------- src/realize/tools/registry.py | 4 +- tests/test_deployment.py | 23 +- tests/test_error_handling.py | 54 ++-- tests/test_integration.py | 242 ++++++++++++++++- tests/test_mcp_protocol.py | 144 +++++++--- tests/test_production.py | 82 ++++-- tests/test_tool_registration.py | 21 +- 12 files changed, 804 insertions(+), 506 deletions(-) delete mode 100644 src/realize/tools/browser_auth_handlers.py diff --git a/design.md b/design.md index f35a575..4669d6f 100644 --- a/design.md +++ b/design.md @@ -431,13 +431,15 @@ The Realize MCP Server includes comprehensive testing to ensure reliability acro ### Test Coverage -- **100 total tests**, **all passing** (removed redundant and placeholder tests) +- **116 total tests**, **all passing** (comprehensive test coverage completed) - **MCP Protocol Compliance**: Tests for proper MCP tool registration, argument handling, and response formatting - **Deployment Scenarios**: Tests for source installation, pip installation, and entry point functionality - **Tool Registration**: Comprehensive validation of tool discovery, schema validation, and handler imports - **Error Handling**: Network errors, malformed inputs, authentication failures, and edge cases - **Environment Configuration**: Environment variable handling, .env file support, and configuration validation - **Performance & Memory**: Large response handling, concurrent operations, and memory leak prevention +- **Browser Authentication**: Complete browser OAuth2 flow testing and integration +- **Production Readiness**: Comprehensive production deployment validation ### Test Architecture @@ -455,6 +457,7 @@ The test suite is organized into focused categories: - ✅ CLI entry point validation - ✅ Package metadata verification - ✅ Environment configuration testing +- ✅ Python version compatibility (3.10+) #### `test_tool_registration.py` - Tool Registry - ✅ Tool discovery edge cases @@ -462,109 +465,35 @@ The test suite is organized into focused categories: - ✅ Category organization and consistency - ✅ Description quality validation -#### `test_environment.py` - Configuration -- ✅ Environment variable handling -- ✅ .env file support and fallbacks -- ✅ Configuration validation and defaults -- ✅ Logging setup verification - #### `test_error_handling.py` - Error Resilience - ✅ Network errors (timeouts, 4xx/5xx responses) - ✅ Malformed inputs and edge cases - ✅ Authentication failures and recovery - ✅ Concurrent operations and memory handling -### Running Tests - -```bash -# Run all tests (should show 100 passed, 0 skipped) -python3 -m pytest tests/ -v - -# Run specific test categories -python3 -m pytest tests/test_mcp_protocol.py -v # MCP protocol compliance -python3 -m pytest tests/test_deployment.py -v # Deployment scenarios -python3 -m pytest tests/test_error_handling.py -v # Error handling -python3 -m pytest tests/test_tool_registration.py -v # Tool registration -python3 -m pytest tests/test_environment.py -v # Configuration - -# Run with coverage reporting -python3 -m pytest tests/ --cov=src --cov-report=html - -# Run tests in parallel (faster) -python3 -m pytest tests/ -n auto -``` - -### Test Quality Standards - -The testing suite validates that the MCP server works correctly in all scenarios: - -✅ **Package Installation**: Both development (`pip install -e .`) and production installs -✅ **Import Resolution**: Proper module imports when installed vs running from source -✅ **Tool Discovery**: All 12 tools properly registered and discoverable via MCP -✅ **Error Resilience**: Graceful handling of network failures, malformed inputs, and edge cases -✅ **Configuration Flexibility**: Works with/without .env files and environment variables -✅ **MCP Compliance**: Proper MCP protocol implementation with correct types and schemas - -### Addressing Previous Issues - -This comprehensive testing directly addresses the reliability issues mentioned by users: - -- **"Sometimes shows zero tools"** → Fixed import resolution and added tool discovery tests -- **"Sometimes works but access errors"** → Added comprehensive error handling tests -- **"PyPI deployment issues"** → Added deployment scenario tests and package validation -- **"Better testing coverage"** → Created 100 tests covering all edge cases and deployment scenarios - -The test suite ensures consistent behavior across different deployment environments and prevents the inconsistent behavior that was previously experienced. - -### Test Coverage - -- **100 total tests**, **all passing** (removed redundant and placeholder tests) -- **MCP Protocol Compliance**: Tests for proper MCP tool registration, argument handling, and response formatting -- **Deployment Scenarios**: Tests for source installation, pip installation, and entry point functionality -- **Tool Registration**: Comprehensive validation of tool discovery, schema validation, and handler imports -- **Error Handling**: Network errors, malformed inputs, authentication failures, and edge cases -- **Environment Configuration**: Environment variable handling, .env file support, and configuration validation -- **Performance & Memory**: Large response handling, concurrent operations, and memory leak prevention - -### Test Architecture - -The test suite is organized into focused categories: - -#### `test_mcp_protocol.py` - MCP Protocol Compliance -- ✅ Proper MCP types (Tool, TextContent) validation -- ✅ Tool discovery and schema validation -- ✅ Server initialization and capabilities -- ✅ Error handling in MCP context -- ✅ Protocol compliance verification - -#### `test_deployment.py` - Deployment Scenarios -- ✅ Source vs installed package behavior -- ✅ CLI entry point validation -- ✅ Package metadata verification -- ✅ Environment configuration testing - -#### `test_tool_registration.py` - Tool Registry -- ✅ Tool discovery edge cases -- ✅ Schema validation and handler imports -- ✅ Category organization and consistency -- ✅ Description quality validation - -#### `test_environment.py` - Configuration -- ✅ Environment variable handling -- ✅ .env file support and fallbacks -- ✅ Configuration validation and defaults -- ✅ Logging setup verification - -#### `test_error_handling.py` - Error Resilience -- ✅ Network errors (timeouts, 4xx/5xx responses) -- ✅ Malformed inputs and edge cases -- ✅ Authentication failures and recovery -- ✅ Concurrent operations and memory handling +#### `test_production.py` - Production Readiness +- ✅ All tools properly registered and functional +- ✅ Read-only operation validation +- ✅ Tool categories and schema compliance +- ✅ Authentication flow testing (both credential and browser) +- ✅ API client error handling + +#### `test_integration.py` - Integration Testing +- ✅ End-to-end workflow testing +- ✅ Tool interaction and data flow +- ✅ Realistic usage scenarios +- ✅ Browser authentication integration + +#### `test_browser_auth.py` - Browser Authentication +- ✅ OAuth2 browser flow testing +- ✅ Token management and storage +- ✅ Error handling and recovery +- ✅ Integration with MCP tools ### Running Tests ```bash -# Run all tests (should show 100 passed, 0 skipped) +# Run all tests (should show 116 passed) python3 -m pytest tests/ -v # Run specific test categories @@ -572,7 +501,9 @@ python3 -m pytest tests/test_mcp_protocol.py -v # MCP protocol complian python3 -m pytest tests/test_deployment.py -v # Deployment scenarios python3 -m pytest tests/test_error_handling.py -v # Error handling python3 -m pytest tests/test_tool_registration.py -v # Tool registration -python3 -m pytest tests/test_environment.py -v # Configuration +python3 -m pytest tests/test_production.py -v # Production readiness +python3 -m pytest tests/test_integration.py -v # Integration testing +python3 -m pytest tests/test_browser_auth.py -v # Browser authentication # Run with coverage reporting python3 -m pytest tests/ --cov=src --cov-report=html @@ -591,6 +522,8 @@ The testing suite validates that the MCP server works correctly in all scenarios ✅ **Error Resilience**: Graceful handling of network failures, malformed inputs, and edge cases ✅ **Configuration Flexibility**: Works with/without .env files and environment variables ✅ **MCP Compliance**: Proper MCP protocol implementation with correct types and schemas +✅ **Authentication Methods**: Both credential-based and browser OAuth2 authentication +✅ **Production Deployment**: Comprehensive validation for production use ### Addressing Previous Issues @@ -599,7 +532,9 @@ This comprehensive testing directly addresses the reliability issues mentioned b - **"Sometimes shows zero tools"** → Fixed import resolution and added tool discovery tests - **"Sometimes works but access errors"** → Added comprehensive error handling tests - **"PyPI deployment issues"** → Added deployment scenario tests and package validation -- **"Better testing coverage"** → Created 100 tests covering all edge cases and deployment scenarios +- **"Better testing coverage"** → Created 116 tests covering all edge cases and deployment scenarios +- **"Authentication inconsistencies"** → Added comprehensive auth testing for both methods +- **"Browser auth support"** → Complete browser OAuth2 testing and integration The test suite ensures consistent behavior across different deployment environments and prevents the inconsistent behavior that was previously experienced. @@ -620,8 +555,7 @@ src/ │ └── tools/ # Tool implementations │ ├── __init__.py │ ├── registry.py # Tool registry -│ ├── auth_handlers.py # Authentication tools -│ ├── browser_auth_handlers.py # Browser OAuth authentication +│ ├── auth_handlers.py # Authentication tools (credential & browser OAuth) │ ├── account_handlers.py # Account management │ ├── campaign_handlers.py# Campaign tools │ ├── report_handlers.py # Reporting tools @@ -634,19 +568,23 @@ src/ #### Test Categories ```bash -# All tests +# All tests (should show 116 passed) python -m pytest tests/ -v # Specific test categories -python -m pytest tests/test_production.py -v -python -m pytest tests/test_integration.py -v -python -m pytest tests/test_account_search.py -v +python -m pytest tests/test_production.py -v # Production readiness +python -m pytest tests/test_integration.py -v # Integration testing +python -m pytest tests/test_browser_auth.py -v # Browser authentication +python -m pytest tests/test_mcp_protocol.py -v # MCP protocol compliance +python -m pytest tests/test_deployment.py -v # Deployment scenarios +python -m pytest tests/test_error_handling.py -v # Error handling +python -m pytest tests/test_tool_registration.py -v # Tool registration # With coverage python -m pytest tests/ --cov=src --cov-report=html -# Skip integration tests (if no API credentials) -python -m pytest tests/ -v -m "not integration" +# Run tests in parallel (faster) +python -m pytest tests/ -n auto ``` #### Test Configuration @@ -656,7 +594,7 @@ The project includes a `pytest.ini` configuration file that: - Defines custom markers for integration tests - Configures test discovery and output formatting -**All functionality is fully tested and working correctly.** The project is production-ready with comprehensive test coverage. +**All functionality is fully tested and working correctly.** The project is production-ready with comprehensive test coverage including browser authentication and all deployment scenarios. ### Development Setup @@ -734,7 +672,7 @@ pip install -r requirements.txt pip install -e . # Verify everything works -python3 -m pytest tests/ -v # Should show: 100 passed, 0 skipped +python3 -m pytest tests/ -v # Should show: 116 passed, 0 skipped realize-mcp-server --help # Should show command help ``` diff --git a/pyproject.toml b/pyproject.toml index 954762f..9e3685e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "pydantic>=2.0.0", "python-dotenv>=1.0.0", "aiohttp>=3.9.0", + "tomli>=1.2.0; python_version<'3.11'", ] [project.urls] diff --git a/src/realize/realize_server.py b/src/realize/realize_server.py index 5d7e6a2..ffca67b 100644 --- a/src/realize/realize_server.py +++ b/src/realize/realize_server.py @@ -53,12 +53,12 @@ async def handle_call_tool( try: # Dynamic handler import and execution - if handler_path == "browser_auth_handlers.browser_authenticate": - from realize.tools.browser_auth_handlers import browser_authenticate + if handler_path == "auth_handlers.browser_authenticate": + from realize.tools.auth_handlers import browser_authenticate return await browser_authenticate() - elif handler_path == "browser_auth_handlers.clear_auth_token": - from realize.tools.browser_auth_handlers import clear_auth_token + elif handler_path == "auth_handlers.clear_auth_token": + from realize.tools.auth_handlers import clear_auth_token return await clear_auth_token() elif handler_path == "auth_handlers.get_auth_token": diff --git a/src/realize/tools/auth_handlers.py b/src/realize/tools/auth_handlers.py index 1767b16..87f402e 100644 --- a/src/realize/tools/auth_handlers.py +++ b/src/realize/tools/auth_handlers.py @@ -1,12 +1,32 @@ """Authentication tool handlers.""" import logging -from typing import List +import webbrowser +import asyncio +import secrets +import urllib.parse +import os +import signal +import atexit +from typing import List, Optional +from aiohttp import web import json import mcp.types as types from realize.auth import auth logger = logging.getLogger(__name__) +# Hardcoded OAuth2 configuration for browser auth +CLIENT_ID = "5d76124a06234466bb65ee7680afc082" +REDIRECT_URI = "http://localhost:3456/oauth/callback" +AUTH_URL = "https://authentication.taboola.com/authentication/oauth/authorize" +PORT = 3456 + +# Global state storage for OAuth flow +oauth_state = None +auth_result = None +auth_event = None +current_runner = None # Track active server for cleanup + async def get_auth_token() -> List[types.TextContent]: """Get authentication token.""" @@ -47,4 +67,270 @@ async def get_token_details() -> List[types.TextContent]: type="text", text=f"Failed to get token details: {str(e)}" ) + ] + + +def cleanup_server(): + """Cleanup function for signal handlers and atexit.""" + global current_runner + if current_runner: + try: + # Create a new event loop if needed (for signal handlers) + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + if not loop.is_running(): + loop.run_until_complete(current_runner.cleanup()) + else: + # If loop is running, schedule cleanup + loop.create_task(current_runner.cleanup()) + + logger.info("Cleaned up OAuth server on process exit") + current_runner = None + except Exception as e: + logger.error(f"Error during server cleanup: {e}") + + +def setup_cleanup_handlers(): + """Setup signal handlers and atexit callback for cleanup.""" + # Register cleanup for normal exit + atexit.register(cleanup_server) + + # Register signal handlers for graceful shutdown + def signal_handler(signum, frame): + logger.info(f"Received signal {signum}, cleaning up OAuth server") + cleanup_server() + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + +async def browser_authenticate() -> List[types.TextContent]: + """Authenticate with Realize API using browser-based OAuth2 flow (read-only).""" + global oauth_state, auth_result, auth_event, current_runner + + # Setup cleanup handlers + setup_cleanup_handlers() + + # Reset global state + oauth_state = None + auth_result = None + auth_event = asyncio.Event() + + try: + # Generate random state for security + state = secrets.token_urlsafe(32) + oauth_state = state + + # Build authorization URL + params = { + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "response_type": "token", + "state": state, + "appName": "Realize MCP" + } + auth_url = f"{AUTH_URL}?{urllib.parse.urlencode(params)}" + + # Create aiohttp app for callback + app = web.Application() + app.router.add_get('/oauth/callback', handle_callback) + app.router.add_post('/oauth/process', handle_process) + + # Start server + runner = web.AppRunner(app) + current_runner = runner # Track for cleanup + await runner.setup() + + try: + site = web.TCPSite(runner, 'localhost', PORT) + await site.start() + except OSError as e: + await runner.cleanup() # Cleanup runner if site start fails + current_runner = None # Clear tracking + if "Address already in use" in str(e): + return [ + types.TextContent( + type="text", + text="Authentication server port 3456 is already in use. This might be from a previous authentication session. Please wait 30 seconds and try again, or restart your terminal to force cleanup." + ) + ] + raise + + logger.info(f"Started OAuth callback server on port {PORT}") + + try: + # Open browser + if not webbrowser.open(auth_url): + logger.warning("Failed to open browser automatically") + return [ + types.TextContent( + type="text", + text=f"Could not open browser automatically. Please copy and paste this URL into your browser to authenticate:\\n\\n{auth_url}\\n\\nYou have 15 minutes to complete the authentication." + ) + ] + + logger.info("Opened browser for authentication") + + # Wait for callback (with timeout) + try: + await asyncio.wait_for(auth_event.wait(), timeout=900) # 15 minute timeout + except asyncio.TimeoutError: + return [ + types.TextContent( + type="text", + text="Authentication timed out after 15 minutes. Please try again." + ) + ] + finally: + # Always cleanup server, even if there's an error + await runner.cleanup() + current_runner = None # Clear tracking + logger.info("Cleaned up OAuth callback server") + + # Process result + if auth_result and auth_result.get("success"): + # Update the existing global auth instance + from realize.auth import auth, BrowserAuth + + # Verify we have a BrowserAuth instance + if isinstance(auth, BrowserAuth): + # Update the existing instance's token + auth.set_token(auth_result["access_token"], auth_result["expires_in"]) + logger.info("Updated browser auth token successfully") + else: + # This shouldn't happen if config is correct + logger.error(f"Expected BrowserAuth instance but got {type(auth).__name__}") + return [ + types.TextContent( + type="text", + text="Error: Authentication system is not configured for browser auth. Please check configuration." + ) + ] + + return [ + types.TextContent( + type="text", + text=f"Successfully authenticated via browser. Token expires in {auth_result['expires_in']} seconds." + ) + ] + else: + error_msg = auth_result.get("error", "Unknown error") if auth_result else "No response received" + return [ + types.TextContent( + type="text", + text=f"Authentication failed: {error_msg}. Please try running the authentication command again." + ) + ] + + except Exception as e: + logger.error(f"Browser authentication failed: {e}") + return [ + types.TextContent( + type="text", + text=f"Browser authentication failed due to an unexpected error: {str(e)}. Please try again or check your network connection." + ) + ] + + +async def handle_callback(request): + """Handle OAuth callback from browser - serves the HTML page.""" + # Load HTML content from external file + html_file_path = os.path.join(os.path.dirname(__file__), 'assets', 'oauth_callback.html') + try: + with open(html_file_path, 'r') as f: + html_content = f.read() + except Exception as e: + logger.error(f"Failed to load OAuth callback HTML: {e}") + html_content = """ + + + +

Error

+

Failed to load authentication page. Please try again.

+ + + """ + + return web.Response(text=html_content, content_type='text/html') + + +async def handle_process(request): + """Handle OAuth token processing via POST.""" + global oauth_state, auth_result, auth_event + + try: + # Get JSON data from request body + data = await request.json() + + # Validate state + state = data.get('state') + if state != oauth_state: + auth_result = {"success": False, "error": "Invalid state parameter"} + auth_event.set() + return web.json_response({"status": "error", "message": "Invalid state parameter"}, status=400) + + # Check for access token + if data.get('access_token'): + # Success + auth_result = { + "success": True, + "access_token": data.get('access_token'), + "expires_in": int(data.get('expires_in', 3600)) + } + # Signal completion + auth_event.set() + return web.json_response({"status": "success", "message": "Authentication successful"}) + else: + # Error + error_msg = data.get('error', 'No access token received') + auth_result = { + "success": False, + "error": error_msg + } + # Signal completion + auth_event.set() + return web.json_response({"status": "error", "message": error_msg}, status=400) + + except Exception as e: + logger.error(f"Error processing OAuth callback: {e}") + auth_result = {"success": False, "error": str(e)} + auth_event.set() + return web.json_response({"status": "error", "message": "Failed to process authentication"}, status=500) + + +async def clear_auth_token() -> List[types.TextContent]: + """Remove stored authentication token from memory, forcing user to reauthenticate (read-only).""" + try: + from realize.auth import auth, BrowserAuth + + if isinstance(auth, BrowserAuth): + # Clear the token from the auth instance + auth.clear_token() + logger.info("Successfully cleared browser authentication token") + + return [ + types.TextContent( + type="text", + text="Authentication token has been removed from memory. You will need to authenticate again for future API requests." + ) + ] + else: + return [ + types.TextContent( + type="text", + text="No browser authentication token found to remove." + ) + ] + + except Exception as e: + logger.error(f"Failed to clear authentication token: {e}") + return [ + types.TextContent( + type="text", + text=f"Failed to remove authentication token: {str(e)}" + ) ] \ No newline at end of file diff --git a/src/realize/tools/browser_auth_handlers.py b/src/realize/tools/browser_auth_handlers.py deleted file mode 100644 index d9bfef0..0000000 --- a/src/realize/tools/browser_auth_handlers.py +++ /dev/null @@ -1,293 +0,0 @@ -"""Browser authentication tool handlers.""" -import logging -import webbrowser -import asyncio -import secrets -import urllib.parse -import os -import signal -import atexit -from typing import List, Optional -from aiohttp import web -import mcp.types as types -from realize.auth import auth - -logger = logging.getLogger(__name__) - -# Hardcoded OAuth2 configuration -CLIENT_ID = "5d76124a06234466bb65ee7680afc082" -REDIRECT_URI = "http://localhost:3456/oauth/callback" -AUTH_URL = "https://authentication.taboola.com/authentication/oauth/authorize" -PORT = 3456 - -# Global state storage for OAuth flow -oauth_state = None -auth_result = None -auth_event = None -current_runner = None # Track active server for cleanup - - -def cleanup_server(): - """Cleanup function for signal handlers and atexit.""" - global current_runner - if current_runner: - try: - # Create a new event loop if needed (for signal handlers) - try: - loop = asyncio.get_event_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - if not loop.is_running(): - loop.run_until_complete(current_runner.cleanup()) - else: - # If loop is running, schedule cleanup - loop.create_task(current_runner.cleanup()) - - logger.info("Cleaned up OAuth server on process exit") - current_runner = None - except Exception as e: - logger.error(f"Error during server cleanup: {e}") - - -def setup_cleanup_handlers(): - """Setup signal handlers and atexit callback for cleanup.""" - # Register cleanup for normal exit - atexit.register(cleanup_server) - - # Register signal handlers for graceful shutdown - def signal_handler(signum, frame): - logger.info(f"Received signal {signum}, cleaning up OAuth server") - cleanup_server() - - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - - -async def browser_authenticate() -> List[types.TextContent]: - """Initiate browser-based OAuth2 authentication flow.""" - global oauth_state, auth_result, auth_event, current_runner - - # Setup cleanup handlers - setup_cleanup_handlers() - - # Reset global state - oauth_state = None - auth_result = None - auth_event = asyncio.Event() - - try: - # Generate random state for security - state = secrets.token_urlsafe(32) - oauth_state = state - - # Build authorization URL - params = { - "client_id": CLIENT_ID, - "redirect_uri": REDIRECT_URI, - "response_type": "token", - "state": state, - "appName": "Realize MCP" - } - auth_url = f"{AUTH_URL}?{urllib.parse.urlencode(params)}" - - # Create aiohttp app for callback - app = web.Application() - app.router.add_get('/oauth/callback', handle_callback) - app.router.add_post('/oauth/process', handle_process) - - # Start server - runner = web.AppRunner(app) - current_runner = runner # Track for cleanup - await runner.setup() - - try: - site = web.TCPSite(runner, 'localhost', PORT) - await site.start() - except OSError as e: - await runner.cleanup() # Cleanup runner if site start fails - current_runner = None # Clear tracking - if "Address already in use" in str(e): - return [ - types.TextContent( - type="text", - text="Authentication server port 3456 is already in use. This might be from a previous authentication session. Please wait 30 seconds and try again, or restart your terminal to force cleanup." - ) - ] - raise - - logger.info(f"Started OAuth callback server on port {PORT}") - - try: - # Open browser - if not webbrowser.open(auth_url): - logger.warning("Failed to open browser automatically") - return [ - types.TextContent( - type="text", - text=f"Could not open browser automatically. Please copy and paste this URL into your browser to authenticate:\n\n{auth_url}\n\nYou have 15 minutes to complete the authentication." - ) - ] - - logger.info("Opened browser for authentication") - - # Wait for callback (with timeout) - try: - await asyncio.wait_for(auth_event.wait(), timeout=900) # 15 minute timeout - except asyncio.TimeoutError: - return [ - types.TextContent( - type="text", - text="Authentication timed out after 15 minutes. Please try again." - ) - ] - finally: - # Always cleanup server, even if there's an error - await runner.cleanup() - current_runner = None # Clear tracking - logger.info("Cleaned up OAuth callback server") - - # Process result - if auth_result and auth_result.get("success"): - # Update the existing global auth instance - from realize.auth import auth, BrowserAuth - - # Verify we have a BrowserAuth instance - if isinstance(auth, BrowserAuth): - # Update the existing instance's token - auth.set_token(auth_result["access_token"], auth_result["expires_in"]) - logger.info("Updated browser auth token successfully") - else: - # This shouldn't happen if config is correct - logger.error(f"Expected BrowserAuth instance but got {type(auth).__name__}") - return [ - types.TextContent( - type="text", - text="Error: Authentication system is not configured for browser auth. Please check configuration." - ) - ] - - return [ - types.TextContent( - type="text", - text=f"Successfully authenticated via browser. Token expires in {auth_result['expires_in']} seconds." - ) - ] - else: - error_msg = auth_result.get("error", "Unknown error") if auth_result else "No response received" - return [ - types.TextContent( - type="text", - text=f"Authentication failed: {error_msg}. Please try running the authentication command again." - ) - ] - - except Exception as e: - logger.error(f"Browser authentication failed: {e}") - return [ - types.TextContent( - type="text", - text=f"Browser authentication failed due to an unexpected error: {str(e)}. Please try again or check your network connection." - ) - ] - - -async def handle_callback(request): - """Handle OAuth callback from browser - serves the HTML page.""" - # Load HTML content from external file - html_file_path = os.path.join(os.path.dirname(__file__), 'assets', 'oauth_callback.html') - try: - with open(html_file_path, 'r') as f: - html_content = f.read() - except Exception as e: - logger.error(f"Failed to load OAuth callback HTML: {e}") - html_content = """ - - - -

Error

-

Failed to load authentication page. Please try again.

- - - """ - - return web.Response(text=html_content, content_type='text/html') - - -async def handle_process(request): - """Handle OAuth token processing via POST.""" - global oauth_state, auth_result, auth_event - - try: - # Get JSON data from request body - data = await request.json() - - # Validate state - state = data.get('state') - if state != oauth_state: - auth_result = {"success": False, "error": "Invalid state parameter"} - auth_event.set() - return web.json_response({"status": "error", "message": "Invalid state parameter"}, status=400) - - # Check for access token - if data.get('access_token'): - # Success - auth_result = { - "success": True, - "access_token": data.get('access_token'), - "expires_in": int(data.get('expires_in', 3600)) - } - # Signal completion - auth_event.set() - return web.json_response({"status": "success", "message": "Authentication successful"}) - else: - # Error - error_msg = data.get('error', 'No access token received') - auth_result = { - "success": False, - "error": error_msg - } - # Signal completion - auth_event.set() - return web.json_response({"status": "error", "message": error_msg}, status=400) - - except Exception as e: - logger.error(f"Error processing OAuth callback: {e}") - auth_result = {"success": False, "error": str(e)} - auth_event.set() - return web.json_response({"status": "error", "message": "Failed to process authentication"}, status=500) - - -async def clear_auth_token() -> List[types.TextContent]: - """Remove stored authentication token, forcing user to reauthenticate.""" - try: - from realize.auth import auth, BrowserAuth - - if isinstance(auth, BrowserAuth): - # Clear the token from the auth instance - auth.clear_token() - logger.info("Successfully cleared browser authentication token") - - return [ - types.TextContent( - type="text", - text="Authentication token has been removed from memory. You will need to authenticate again for future API requests." - ) - ] - else: - return [ - types.TextContent( - type="text", - text="No browser authentication token found to remove." - ) - ] - - except Exception as e: - logger.error(f"Failed to clear authentication token: {e}") - return [ - types.TextContent( - type="text", - text=f"Failed to remove authentication token: {str(e)}" - ) - ] \ No newline at end of file diff --git a/src/realize/tools/registry.py b/src/realize/tools/registry.py index 72432c1..37dde70 100644 --- a/src/realize/tools/registry.py +++ b/src/realize/tools/registry.py @@ -12,7 +12,7 @@ "properties": {}, "required": [] }, - "handler": "browser_auth_handlers.clear_auth_token", + "handler": "auth_handlers.clear_auth_token", "category": "authentication" }, @@ -355,7 +355,7 @@ "properties": {}, "required": [] }, - "handler": "browser_auth_handlers.browser_authenticate", + "handler": "auth_handlers.browser_authenticate", "category": "authentication" } diff --git a/tests/test_deployment.py b/tests/test_deployment.py index b827c3d..f62e81c 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -181,14 +181,19 @@ class TestPackageMetadata: def test_package_metadata_complete(self): """Test that package metadata is complete.""" - import toml + try: + import tomllib + with open(pathlib.Path(__file__).parent.parent / "pyproject.toml", 'rb') as f: + pyproject = tomllib.load(f) + except ImportError: + # Fallback for Python < 3.11 + import tomli + with open(pathlib.Path(__file__).parent.parent / "pyproject.toml", 'rb') as f: + pyproject = tomli.load(f) pyproject_path = pathlib.Path(__file__).parent.parent / "pyproject.toml" assert pyproject_path.exists(), "pyproject.toml not found" - with open(pyproject_path, 'r') as f: - pyproject = toml.load(f) - # Check required fields assert 'project' in pyproject project = pyproject['project'] @@ -214,12 +219,16 @@ def test_dependencies_available(self): def test_version_consistency(self): """Test that version is consistent across files.""" - import toml + try: + import tomllib + except ImportError: + # Fallback for Python < 3.11 + import tomli as tomllib # Get version from pyproject.toml pyproject_path = pathlib.Path(__file__).parent.parent / "pyproject.toml" - with open(pyproject_path, 'r') as f: - pyproject = toml.load(f) + with open(pyproject_path, 'rb') as f: + pyproject = tomllib.load(f) pyproject_version = pyproject['project']['version'] # Get version from _version.py if it exists diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py index 8727113..e44d6f6 100644 --- a/tests/test_error_handling.py +++ b/tests/test_error_handling.py @@ -104,23 +104,47 @@ async def test_malformed_tool_arguments(self): async def test_authentication_failures(self): """Test handling of authentication failures.""" from realize.realize_server import handle_call_tool + from realize.tools.registry import get_all_tools + + # Check which auth tools are available based on current config + tools = get_all_tools() + auth_tools = [name for name, tool in tools.items() if tool.get('category') == 'authentication'] + # Test with available authentication tools auth_errors = [ httpx.HTTPStatusError("401 Unauthorized", request=Mock(), response=Mock(status_code=401)), httpx.HTTPStatusError("403 Forbidden", request=Mock(), response=Mock(status_code=403)), Exception("Auth service unavailable") ] - for error in auth_errors: - with patch('realize.tools.auth_handlers.auth.get_auth_token') as mock_auth: - mock_auth.side_effect = error - - result = await handle_call_tool("get_auth_token", {}) - - # Should handle gracefully - assert len(result) == 1 - assert isinstance(result[0], types.TextContent) - assert "failed" in result[0].text.lower() or "error" in result[0].text.lower() + # Test credential-based auth if available + if "get_auth_token" in tools: + for error in auth_errors: + with patch('realize.tools.auth_handlers.auth.get_auth_token') as mock_auth: + mock_auth.side_effect = error + + result = await handle_call_tool("get_auth_token", {}) + + # Should handle gracefully + assert len(result) == 1 + assert isinstance(result[0], types.TextContent) + assert "failed" in result[0].text.lower() or "error" in result[0].text.lower() + + # Test browser-based auth if available + if "browser_authenticate" in tools: + for error in auth_errors: + with patch('realize.tools.auth_handlers.browser_authenticate') as mock_browser_auth: + mock_browser_auth.side_effect = error + + result = await handle_call_tool("browser_authenticate", {}) + + # Should handle gracefully + assert len(result) == 1 + assert isinstance(result[0], types.TextContent) + assert "failed" in result[0].text.lower() or "error" in result[0].text.lower() + + # Ensure at least one auth method was tested + assert len(auth_tools) > 0, "No authentication tools found to test" @pytest.mark.asyncio async def test_api_rate_limiting(self): @@ -275,11 +299,11 @@ async def failing_call(): return await handle_call_tool("search_accounts", {"query": "fail"}) async def succeeding_call(): - with patch('realize.tools.auth_handlers.auth.get_auth_token') as mock_auth: - mock_token = Mock() - mock_token.expires_in = 3600 - mock_auth.return_value = mock_token - return await handle_call_tool("get_auth_token", {}) + # Use an available auth tool instead of get_auth_token + with patch('realize.tools.auth_handlers.auth.get_token_details') as mock_auth: + mock_details = {"token": "valid", "expires_in": 3600} + mock_auth.return_value = mock_details + return await handle_call_tool("get_token_details", {}) # Run concurrently results = await asyncio.gather( diff --git a/tests/test_integration.py b/tests/test_integration.py index 8063a7e..e04ecb8 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -22,12 +22,16 @@ async def test_list_tools_integration(self): # Check that we have tools assert len(tools) > 0 - # Check that essential read-only tools are present + # Check that essential read-only tools are present (excluding auth which is dynamic) tool_names = [tool.name for tool in tools] - essential_tools = ['get_auth_token', 'search_accounts', 'get_all_campaigns'] + essential_tools = ['search_accounts', 'get_all_campaigns', 'get_token_details'] for tool in essential_tools: assert tool in tool_names, f"Essential read-only tool {tool} missing" + + # Check that we have at least one auth tool (either credential or browser based) + auth_tools = [name for name in tool_names if name in ['get_auth_token', 'browser_authenticate', 'clear_auth_token']] + assert len(auth_tools) > 0, "No authentication tools found" @pytest.mark.asyncio async def test_no_write_tools_available(self): @@ -43,18 +47,56 @@ async def test_no_write_tools_available(self): f"Found write operation {tool_name} - only read operations should be available" @pytest.mark.asyncio - @patch('realize.tools.auth_handlers.auth.get_auth_token') - async def test_call_tool_integration(self, mock_get_auth_token): + async def test_call_tool_integration(self): """Test tool calling integration.""" - # Mock auth token (only model used) - mock_token = Mock() - mock_token.expires_in = 3600 - mock_get_auth_token.return_value = mock_token + # Get available tools to test with the correct auth method + tools = await handle_list_tools() + tool_names = [tool.name for tool in tools] - # Test auth tool call - result = await handle_call_tool("get_auth_token", {}) - assert len(result) == 1 - assert "Successfully authenticated" in result[0].text + # Test with available auth tool + if "get_auth_token" in tool_names: + # Test credential-based auth + with patch('realize.tools.auth_handlers.auth.get_auth_token') as mock_get_auth_token: + mock_token = Mock() + mock_token.expires_in = 3600 + mock_get_auth_token.return_value = mock_token + + result = await handle_call_tool("get_auth_token", {}) + assert len(result) == 1 + assert "Successfully authenticated" in result[0].text + + elif "browser_authenticate" in tool_names: + # Test browser-based auth - mock the browser auth process + with patch('realize.tools.auth_handlers.webbrowser.open', return_value=True), \ + patch('realize.tools.auth_handlers.web.AppRunner') as mock_runner, \ + patch('realize.tools.auth_handlers.web.TCPSite') as mock_site, \ + patch('realize.tools.auth_handlers.asyncio.wait_for') as mock_wait: + + # Mock successful authentication flow + mock_runner_instance = Mock() + mock_runner_instance.setup = AsyncMock() + mock_runner_instance.cleanup = AsyncMock() + mock_runner.return_value = mock_runner_instance + + mock_site_instance = Mock() + mock_site_instance.start = AsyncMock() + mock_site.return_value = mock_site_instance + + mock_wait.return_value = None + + # Simulate successful auth result + import realize.tools.auth_handlers as handlers + handlers.auth_result = { + "success": True, + "access_token": "test_token", + "expires_in": 3600 + } + + result = await handle_call_tool("browser_authenticate", {}) + assert len(result) == 1 + assert "successfully authenticated" in result[0].text.lower() or "authentication" in result[0].text.lower() + else: + pytest.fail("No authentication tools available for testing") @pytest.mark.asyncio async def test_invalid_tool_call(self): @@ -247,6 +289,182 @@ async def test_tool_categories_integration(self): for category in expected_categories: assert category in category_counts, f"No tools found in category: {category}" assert category_counts[category] > 0, f"Category {category} has no tools" + + @pytest.mark.asyncio + @patch('realize.tools.account_handlers.client.get') + async def test_account_tools_integration(self, mock_get): + """Test account tools integration with raw JSON.""" + # Test search_accounts + mock_get.return_value = { + "results": [ + { + "account_id": "acc_123", + "name": "Integration Test Account", + "type": "advertiser", + "currency": "USD" + }, + { + "account_id": "acc_456", + "name": "Another Test Account", + "type": "advertiser", + "currency": "EUR" + } + ], + "metadata": {"total": 2} + } + + result = await handle_call_tool("search_accounts", {"query": "Test Account"}) + assert len(result) == 1 + assert "Integration Test Account" in result[0].text + assert "Another Test Account" in result[0].text + assert "acc_123" in result[0].text + + @pytest.mark.asyncio + @patch('realize.tools.report_handlers.client.get') + async def test_site_day_breakdown_report_integration(self, mock_get): + """Test site day breakdown report integration.""" + # Test get_campaign_site_day_breakdown_report + mock_get.return_value = { + "results": [ + { + "date": "2024-01-01", + "site_id": "site_123", + "site_name": "example.com", + "campaign_id": "camp_123", + "impressions": 1500, + "clicks": 75, + "ctr": 0.05, + "cost": 187.50 + } + ], + "metadata": { + "start_date": "2024-01-01", + "end_date": "2024-01-31", + "breakdown_by": ["site", "day"] + } + } + + result = await handle_call_tool("get_campaign_site_day_breakdown_report", { + "account_id": "test_account", + "start_date": "2024-01-01", + "end_date": "2024-01-31" + }) + assert len(result) == 1 + assert "Site Day Breakdown Report CSV" in result[0].text + + @pytest.mark.asyncio + async def test_workflow_integration(self): + """Test realistic workflow: search accounts → get campaigns → get campaign items.""" + # Mock account search + with patch('realize.tools.account_handlers.client.get') as mock_accounts: + mock_accounts.return_value = { + "results": [{"account_id": "workflow_acc_123", "name": "Workflow Test Account"}] + } + + # Step 1: Search for accounts + account_result = await handle_call_tool("search_accounts", {"query": "Workflow"}) + assert len(account_result) == 1 + assert "workflow_acc_123" in account_result[0].text + + # Mock campaign listing + with patch('realize.tools.campaign_handlers.client.get') as mock_campaigns: + mock_campaigns.return_value = { + "results": [ + { + "id": "workflow_camp_123", + "name": "Workflow Test Campaign", + "status": "RUNNING" + } + ] + } + + # Step 2: Get campaigns for the account + campaign_result = await handle_call_tool("get_all_campaigns", { + "account_id": "workflow_acc_123" + }) + assert len(campaign_result) == 1 + assert "Workflow Test Campaign" in campaign_result[0].text + + # Mock campaign items + with patch('realize.tools.campaign_handlers.client.get') as mock_items: + mock_items.return_value = { + "results": [ + { + "id": "workflow_item_123", + "campaign_id": "workflow_camp_123", + "title": "Workflow Test Item", + "status": "APPROVED" + } + ] + } + + # Step 3: Get campaign items + item_result = await handle_call_tool("get_campaign_items", { + "account_id": "workflow_acc_123", + "campaign_id": "workflow_camp_123" + }) + assert len(item_result) == 1 + assert "Workflow Test Item" in item_result[0].text + + @pytest.mark.asyncio + async def test_browser_auth_workflow_integration(self): + """Test browser authentication workflow integration.""" + # Get available tools + tools = await handle_list_tools() + tool_names = [tool.name for tool in tools] + + if "browser_authenticate" not in tool_names: + pytest.skip("Browser authentication not available in current config") + + # Test browser auth → token details → clear token workflow + with patch('realize.tools.auth_handlers.webbrowser.open', return_value=True), \ + patch('realize.tools.auth_handlers.web.AppRunner') as mock_runner, \ + patch('realize.tools.auth_handlers.web.TCPSite') as mock_site, \ + patch('realize.tools.auth_handlers.asyncio.wait_for') as mock_wait: + + # Mock successful authentication setup + mock_runner_instance = Mock() + mock_runner_instance.setup = AsyncMock() + mock_runner_instance.cleanup = AsyncMock() + mock_runner.return_value = mock_runner_instance + + mock_site_instance = Mock() + mock_site_instance.start = AsyncMock() + mock_site.return_value = mock_site_instance + + # Mock wait_for to immediately set the auth result and signal completion + async def mock_wait_for_success(event_wait, timeout): + import realize.tools.auth_handlers as handlers + handlers.auth_result = { + "success": True, + "access_token": "workflow_browser_token", + "expires_in": 3600 + } + return None # No timeout + + mock_wait.side_effect = mock_wait_for_success + + # Step 1: Authenticate via browser + auth_result = await handle_call_tool("browser_authenticate", {}) + assert len(auth_result) == 1 + assert "successfully authenticated" in auth_result[0].text.lower() + + # Step 2: Get token details + with patch('realize.tools.auth_handlers.auth.get_token_details') as mock_details: + mock_details.return_value = { + "token": "workflow_browser_token", + "expires_in": 3600, + "account_id": "browser_test_acc" + } + + details_result = await handle_call_tool("get_token_details", {}) + assert len(details_result) == 1 + assert "workflow_browser_token" in details_result[0].text + + # Step 3: Clear token + clear_result = await handle_call_tool("clear_auth_token", {}) + assert len(clear_result) == 1 + assert "removed" in clear_result[0].text.lower() or "cleared" in clear_result[0].text.lower() if __name__ == "__main__": diff --git a/tests/test_mcp_protocol.py b/tests/test_mcp_protocol.py index f516c37..29c2977 100644 --- a/tests/test_mcp_protocol.py +++ b/tests/test_mcp_protocol.py @@ -45,24 +45,39 @@ async def test_list_tools_returns_proper_mcp_types(self): @pytest.mark.asyncio async def test_call_tool_returns_proper_mcp_types(self): """Test that call_tool returns proper MCP TextContent types.""" - # Test with a simple auth tool - with patch('realize.tools.auth_handlers.auth.get_auth_token') as mock_auth: - mock_token = Mock() - mock_token.expires_in = 3600 - mock_auth.return_value = mock_token - - result = await handle_call_tool("get_auth_token", {}) - - # Should return a list of TextContent - assert isinstance(result, list) - assert len(result) > 0 - - for content in result: - assert isinstance(content, types.TextContent) - assert hasattr(content, 'type') - assert hasattr(content, 'text') - assert content.type == 'text' - assert isinstance(content.text, str) + # Get available tools to test with the correct auth method + tools = await handle_list_tools() + tool_names = [tool.name for tool in tools] + + # Test with available auth tool + if "get_auth_token" in tool_names: + # Test credential-based auth + with patch('realize.tools.auth_handlers.auth.get_auth_token') as mock_auth: + mock_token = Mock() + mock_token.expires_in = 3600 + mock_auth.return_value = mock_token + + result = await handle_call_tool("get_auth_token", {}) + + elif "get_token_details" in tool_names: + # Test with get_token_details (available in both auth modes) + with patch('realize.tools.auth_handlers.auth.get_token_details') as mock_details: + mock_details.return_value = {"token": "test", "expires_in": 3600} + + result = await handle_call_tool("get_token_details", {}) + else: + pytest.fail("No suitable authentication tools available for testing") + + # Should return a list of TextContent + assert isinstance(result, list) + assert len(result) > 0 + + for content in result: + assert isinstance(content, types.TextContent) + assert hasattr(content, 'type') + assert hasattr(content, 'text') + assert content.type == 'text' + assert isinstance(content.text, str) @pytest.mark.asyncio async def test_invalid_tool_name_handling(self): @@ -73,27 +88,61 @@ async def test_invalid_tool_name_handling(self): @pytest.mark.asyncio async def test_none_arguments_handling(self): """Test handling of None arguments.""" - with patch('realize.tools.auth_handlers.auth.get_auth_token') as mock_auth: - mock_token = Mock() - mock_token.expires_in = 3600 - mock_auth.return_value = mock_token + # Get available tools to test with the correct auth method + tools = await handle_list_tools() + tool_names = [tool.name for tool in tools] + + # Test with available auth tool that doesn't require arguments + if "get_auth_token" in tool_names: + # Test credential-based auth + with patch('realize.tools.auth_handlers.auth.get_auth_token') as mock_auth: + mock_token = Mock() + mock_token.expires_in = 3600 + mock_auth.return_value = mock_token + + result = await handle_call_tool("get_auth_token", None) + + elif "get_token_details" in tool_names: + # Test with get_token_details (available in both auth modes) + with patch('realize.tools.auth_handlers.auth.get_token_details') as mock_details: + mock_details.return_value = {"token": "test", "expires_in": 3600} + + result = await handle_call_tool("get_token_details", None) + else: + pytest.fail("No suitable authentication tools available for testing") - # Should work with None arguments for tools that don't require them - result = await handle_call_tool("get_auth_token", None) - assert isinstance(result, list) - assert len(result) > 0 + # Should work with None arguments for tools that don't require them + assert isinstance(result, list) + assert len(result) > 0 @pytest.mark.asyncio async def test_empty_arguments_handling(self): """Test handling of empty arguments dict.""" - with patch('realize.tools.auth_handlers.auth.get_auth_token') as mock_auth: - mock_token = Mock() - mock_token.expires_in = 3600 - mock_auth.return_value = mock_token + # Get available tools to test with the correct auth method + tools = await handle_list_tools() + tool_names = [tool.name for tool in tools] + + # Test with available auth tool + if "get_auth_token" in tool_names: + # Test credential-based auth + with patch('realize.tools.auth_handlers.auth.get_auth_token') as mock_auth: + mock_token = Mock() + mock_token.expires_in = 3600 + mock_auth.return_value = mock_token + + result = await handle_call_tool("get_auth_token", {}) + + elif "get_token_details" in tool_names: + # Test with get_token_details (available in both auth modes) + with patch('realize.tools.auth_handlers.auth.get_token_details') as mock_details: + mock_details.return_value = {"token": "test", "expires_in": 3600} + + result = await handle_call_tool("get_token_details", {}) + else: + pytest.fail("No suitable authentication tools available for testing") - result = await handle_call_tool("get_auth_token", {}) - assert isinstance(result, list) - assert len(result) > 0 + assert isinstance(result, list) + assert len(result) > 0 class TestToolDiscovery: @@ -188,15 +237,28 @@ async def test_server_handlers_registered(self): # Test that list_tools handler works tools = await handle_list_tools() assert len(tools) > 0 + tool_names = [tool.name for tool in tools] - # Test that call_tool handler works - with patch('realize.tools.auth_handlers.auth.get_auth_token') as mock_auth: - mock_token = Mock() - mock_token.expires_in = 3600 - mock_auth.return_value = mock_token - - result = await handle_call_tool("get_auth_token", {}) - assert len(result) > 0 + # Test that call_tool handler works with available auth tool + if "get_auth_token" in tool_names: + # Test credential-based auth + with patch('realize.tools.auth_handlers.auth.get_auth_token') as mock_auth: + mock_token = Mock() + mock_token.expires_in = 3600 + mock_auth.return_value = mock_token + + result = await handle_call_tool("get_auth_token", {}) + assert len(result) > 0 + + elif "get_token_details" in tool_names: + # Test with get_token_details (available in both auth modes) + with patch('realize.tools.auth_handlers.auth.get_token_details') as mock_details: + mock_details.return_value = {"token": "test", "expires_in": 3600} + + result = await handle_call_tool("get_token_details", {}) + assert len(result) > 0 + else: + pytest.fail("No suitable authentication tools available for testing") class TestErrorHandling: diff --git a/tests/test_production.py b/tests/test_production.py index 9fdf27a..860354e 100644 --- a/tests/test_production.py +++ b/tests/test_production.py @@ -19,9 +19,9 @@ def test_all_read_only_tools_registered(self): """Test that all expected read-only tools are registered.""" tools = get_all_tools() - # Check minimum required read-only tools + # Check minimum required read-only tools (excluding dynamic auth tools) required_tools = [ - 'get_auth_token', 'get_token_details', + 'get_token_details', # Universal auth tool 'search_accounts', 'get_all_campaigns', 'get_campaign', 'get_campaign_items', 'get_campaign_item', @@ -31,6 +31,10 @@ def test_all_read_only_tools_registered(self): for tool in required_tools: assert tool in tools, f"Required read-only tool {tool} not found in registry" + + # Check that at least one auth tool is available (either credential or browser based) + auth_tools = [name for name in tools.keys() if name in ['get_auth_token', 'browser_authenticate', 'clear_auth_token']] + assert len(auth_tools) > 0, "No authentication tools found" def test_no_write_operations(self): """Test that no write operations are included for safety.""" @@ -69,8 +73,16 @@ def test_tool_schemas_valid_read_only(self): # Verify description indicates read-only description = tool_config['description'].lower() - assert 'read-only' in description or 'get' in description, \ - f"Tool {tool_name} should be clearly marked as read-only" + + # Special cases - tools that are inherently read-only but don't use typical read verbs + special_read_only_tools = { + 'clear_auth_token': True, # Token management (clearing stored tokens, not deleting from server) + 'browser_authenticate': True # Authentication flow (read-only from API perspective) + } + + if tool_name not in special_read_only_tools: + assert 'read-only' in description or 'get' in description, \ + f"Tool {tool_name} should be clearly marked as read-only" # Check schema structure supports flexible JSON schema = tool_config['schema'] @@ -82,21 +94,43 @@ def test_tool_schemas_valid_read_only(self): @patch('realize.auth.httpx.AsyncClient') async def test_authentication_flow(self, mock_client): """Test authentication flow works correctly with Token model.""" - # Mock successful auth response - mock_response = Mock() - mock_response.json.return_value = { - 'access_token': 'test_token', - 'token_type': 'Bearer', - 'expires_in': 3600 - } - mock_response.raise_for_status.return_value = None - - mock_client.return_value.__aenter__.return_value.post.return_value = mock_response + from realize.auth import RealizeAuth, BrowserAuth, auth + from realize.models import Token - # Test token retrieval (only model used) - token = await auth.get_auth_token() - assert token.access_token == 'test_token' - assert token.expires_in == 3600 + if isinstance(auth, RealizeAuth): + # Test credential-based authentication + mock_response = Mock() + mock_response.json.return_value = { + 'access_token': 'test_token', + 'token_type': 'Bearer', + 'expires_in': 3600 + } + mock_response.raise_for_status.return_value = None + + mock_client.return_value.__aenter__.return_value.post.return_value = mock_response + + # Test token retrieval + token = await auth.get_auth_token() + assert token.access_token == 'test_token' + assert token.expires_in == 3600 + + elif isinstance(auth, BrowserAuth): + # Test browser-based authentication flow + # For browser auth, we need to set a token first + test_token = Token( + access_token='browser_test_token', + token_type='Bearer', + expires_in=3600 + ) + auth.token = test_token + + # Now get_auth_token should return the set token + token = await auth.get_auth_token() + assert token.access_token == 'browser_test_token' + assert token.expires_in == 3600 + + # Clear the token for cleanup + auth.token = None def test_configuration_validation(self): """Test that configuration validation works.""" @@ -108,8 +142,12 @@ def test_configuration_validation(self): @pytest.mark.asyncio @patch('realize.client.httpx.AsyncClient') - async def test_api_client_read_only_json_handling(self, mock_client): + @patch('realize.auth.auth.get_auth_header') + async def test_api_client_read_only_json_handling(self, mock_auth_header, mock_client): """Test API client returns raw JSON dictionaries for read operations.""" + # Mock authentication + mock_auth_header.return_value = {"Authorization": "Bearer test_token"} + # Mock successful API response mock_response = Mock() mock_response.json.return_value = { @@ -137,8 +175,12 @@ async def test_api_client_read_only_json_handling(self, mock_client): @pytest.mark.asyncio @patch('realize.client.httpx.AsyncClient') - async def test_api_client_error_handling(self, mock_client): + @patch('realize.auth.auth.get_auth_header') + async def test_api_client_error_handling(self, mock_auth_header, mock_client): """Test API client error handling.""" + # Mock authentication + mock_auth_header.return_value = {"Authorization": "Bearer test_token"} + # Mock HTTP error from httpx import HTTPStatusError mock_response = Mock() diff --git a/tests/test_tool_registration.py b/tests/test_tool_registration.py index 0e73d95..1499611 100644 --- a/tests/test_tool_registration.py +++ b/tests/test_tool_registration.py @@ -209,15 +209,26 @@ def test_all_descriptions_indicate_read_only(self): read_only_indicators = ['read-only', 'get', 'retrieve', 'fetch', 'search', 'view', 'list'] + # Special cases - tools that are inherently read-only but don't use typical read verbs + special_read_only_tools = { + 'clear_auth_token': True, # Token management (clearing stored tokens, not deleting from server) + 'browser_authenticate': True # Authentication flow (read-only from API perspective) + } + for tool_name, tool_config in tools.items(): description = tool_config['description'].lower() - # Should contain at least one read-only indicator - has_indicator = any(indicator in description for indicator in read_only_indicators) - assert has_indicator, \ - f"Tool {tool_name} description doesn't clearly indicate read-only: {description}" + # Check if it's a special case tool + if tool_name in special_read_only_tools: + # These tools are read-only by nature, skip the indicator check + pass + else: + # Should contain at least one read-only indicator + has_indicator = any(indicator in description for indicator in read_only_indicators) + assert has_indicator, \ + f"Tool {tool_name} description doesn't clearly indicate read-only: {description}" - # Should not contain write indicators + # Should not contain write indicators (applies to all tools) write_indicators = ['create', 'update', 'delete', 'modify', 'edit', 'write', 'post', 'put'] has_write = any(indicator in description for indicator in write_indicators) assert not has_write, \ From 3084a6a36b22fd310b727d3f0011447035ec6e57 Mon Sep 17 00:00:00 2001 From: Henry Abravanel Date: Thu, 24 Jul 2025 04:23:34 +0300 Subject: [PATCH 10/11] - Added comprehensive tests for `BrowserAuth` covering token lifecycle, header generation, and error handling. - Introduced tests for `browser_authenticate` and `clear_auth_token` handlers. - Verified browser-based authentication integration with system and tool availability logic. --- tests/test_browser_auth.py | 229 +++++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 tests/test_browser_auth.py diff --git a/tests/test_browser_auth.py b/tests/test_browser_auth.py new file mode 100644 index 0000000..2538a94 --- /dev/null +++ b/tests/test_browser_auth.py @@ -0,0 +1,229 @@ +"""Test browser-based authentication functionality.""" +import pytest +from unittest.mock import Mock, patch, AsyncMock +from realize.auth import BrowserAuth +from realize.models import Token + + +class TestBrowserAuthentication: + """Test browser-based authentication functionality.""" + + def test_browser_auth_initialization(self): + """Test that BrowserAuth initializes correctly.""" + auth = BrowserAuth() + assert auth.token is None + assert auth.base_url is not None + + def test_browser_auth_token_storage(self): + """Test that browser auth can store and retrieve tokens.""" + auth = BrowserAuth() + + test_token = Token( + access_token='test_browser_token', + token_type='Bearer', + expires_in=3600 + ) + + # Set token + auth.token = test_token + assert auth.token.access_token == 'test_browser_token' + assert auth.token.expires_in == 3600 + + @pytest.mark.asyncio + async def test_browser_auth_without_token_raises_error(self): + """Test that get_auth_token raises error when no token is set.""" + auth = BrowserAuth() + + with pytest.raises(Exception) as exc_info: + await auth.get_auth_token() + + assert "No browser auth token available" in str(exc_info.value) + assert "browser_authenticate tool" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_browser_auth_with_valid_token(self): + """Test that get_auth_token returns token when set.""" + auth = BrowserAuth() + + test_token = Token( + access_token='valid_browser_token', + token_type='Bearer', + expires_in=3600 + ) + auth.token = test_token + + retrieved_token = await auth.get_auth_token() + assert retrieved_token.access_token == 'valid_browser_token' + assert retrieved_token.expires_in == 3600 + + @pytest.mark.asyncio + async def test_browser_auth_header_generation(self): + """Test that auth header is generated correctly.""" + auth = BrowserAuth() + + test_token = Token( + access_token='header_test_token', + token_type='Bearer', + expires_in=3600 + ) + auth.token = test_token + + # Mock _is_token_expired to return False + with patch.object(auth, '_is_token_expired', return_value=False): + header = await auth.get_auth_header() + assert header == {"Authorization": "Bearer header_test_token"} + + @pytest.mark.asyncio + async def test_browser_auth_expired_token_handling(self): + """Test handling of expired tokens.""" + auth = BrowserAuth() + + # Clear any existing token first + auth.token = None + + # Mock _is_token_expired to return True (indicating no token or expired token) + with patch.object(auth, '_is_token_expired', return_value=True): + # When no token exists and expired check returns True, + # get_auth_header should fail + with pytest.raises(Exception) as exc_info: + await auth.get_auth_header() + + assert "No browser auth token available" in str(exc_info.value) + + def test_browser_auth_token_clearing(self): + """Test that tokens can be cleared.""" + auth = BrowserAuth() + + # Set a token + test_token = Token( + access_token='to_be_cleared', + token_type='Bearer', + expires_in=3600 + ) + auth.token = test_token + assert auth.token is not None + + # Clear the token + auth.token = None + assert auth.token is None + + +class TestBrowserAuthHandlers: + """Test browser authentication tool handlers.""" + + @pytest.mark.asyncio + async def test_browser_authenticate_tool_handler(self): + """Test browser authenticate tool handler.""" + from realize.tools.auth_handlers import browser_authenticate + + # Mock the browser authentication process components + with patch('realize.tools.auth_handlers.webbrowser.open', return_value=True) as mock_browser, \ + patch('realize.tools.auth_handlers.web.AppRunner') as mock_runner, \ + patch('realize.tools.auth_handlers.web.TCPSite') as mock_site, \ + patch('realize.tools.auth_handlers.asyncio.wait_for') as mock_wait: + + # Mock successful authentication flow + mock_runner_instance = Mock() + mock_runner_instance.setup = AsyncMock() + mock_runner_instance.cleanup = AsyncMock() + mock_runner.return_value = mock_runner_instance + + mock_site_instance = Mock() + mock_site_instance.start = AsyncMock() + mock_site.return_value = mock_site_instance + + # Mock successful wait (no timeout) + mock_wait.return_value = None + + # Simulate successful auth result + import realize.tools.auth_handlers as handlers + handlers.auth_result = { + "success": True, + "access_token": "test_token", + "expires_in": 3600 + } + + result = await browser_authenticate() + + # Should return list of TextContent + assert isinstance(result, list) + assert len(result) == 1 + assert hasattr(result[0], 'text') + + # Should contain success message + result_text = result[0].text.lower() + assert "successfully authenticated" in result_text or "authentication" in result_text + + @pytest.mark.asyncio + async def test_clear_auth_token_handler(self): + """Test clear auth token handler.""" + from realize.tools.auth_handlers import clear_auth_token + from realize.auth import auth + + # Set a token first + test_token = Token( + access_token='to_be_cleared', + token_type='Bearer', + expires_in=3600 + ) + auth.token = test_token + + # Clear the token + result = await clear_auth_token() + + # Should return list of TextContent + assert isinstance(result, list) + assert len(result) == 1 + + # Should confirm clearing + result_text = result[0].text.lower() + assert "removed" in result_text or "cleared" in result_text + assert auth.token is None + + @pytest.mark.asyncio + async def test_browser_authenticate_error_handling(self): + """Test browser authenticate error handling.""" + from realize.tools.auth_handlers import browser_authenticate + + # Mock browser authentication to fail during server setup + with patch('realize.tools.auth_handlers.web.AppRunner') as mock_runner: + mock_runner.side_effect = Exception("Authentication server failed") + + result = await browser_authenticate() + + # Should handle error gracefully + assert isinstance(result, list) + assert len(result) == 1 + result_text = result[0].text.lower() + assert "failed" in result_text or "error" in result_text + + +class TestBrowserAuthIntegration: + """Test browser auth integration with the overall system.""" + + def test_browser_auth_selected_when_no_credentials(self): + """Test that browser auth is selected when no credentials are configured.""" + from realize.auth import auth + from realize.config import config + + # In test environment, credentials should be placeholder values + assert config.realize_client_id == "your_client_id" + assert config.realize_client_secret == "your_client_secret" + + # So auth should be BrowserAuth + assert isinstance(auth, BrowserAuth) + + @pytest.mark.asyncio + async def test_browser_auth_tool_availability(self): + """Test that browser auth tools are available when using browser auth.""" + from realize.tools.registry import get_all_tools + + tools = get_all_tools() + + # Should have browser auth tools + assert "browser_authenticate" in tools + assert "clear_auth_token" in tools + assert "get_token_details" in tools + + # Should NOT have credential-based auth tool + assert "get_auth_token" not in tools \ No newline at end of file From 04e9cacbd0fc740c9202c55fb1a970f52d3912eb Mon Sep 17 00:00:00 2001 From: Henry Abravanel Date: Thu, 24 Jul 2025 10:30:46 +0300 Subject: [PATCH 11/11] Added `is_token_valid` tool to check authentication token status --- src/realize/realize_server.py | 4 ++ src/realize/tools/auth_handlers.py | 74 ++++++++++++++++++++++++++++++ src/realize/tools/registry.py | 11 +++++ 3 files changed, 89 insertions(+) diff --git a/src/realize/realize_server.py b/src/realize/realize_server.py index ffca67b..9e6f5c8 100644 --- a/src/realize/realize_server.py +++ b/src/realize/realize_server.py @@ -69,6 +69,10 @@ async def handle_call_tool( from realize.tools.auth_handlers import get_token_details return await get_token_details() + elif handler_path == "auth_handlers.is_token_valid": + from realize.tools.auth_handlers import is_token_valid + return await is_token_valid() + elif handler_path == "account_handlers.search_accounts": from realize.tools.account_handlers import search_accounts return await search_accounts(arguments.get("query")) diff --git a/src/realize/tools/auth_handlers.py b/src/realize/tools/auth_handlers.py index 87f402e..ff827bb 100644 --- a/src/realize/tools/auth_handlers.py +++ b/src/realize/tools/auth_handlers.py @@ -333,4 +333,78 @@ async def clear_auth_token() -> List[types.TextContent]: type="text", text=f"Failed to remove authentication token: {str(e)}" ) + ] + + +async def is_token_valid() -> List[types.TextContent]: + """Check if there is a valid authentication token in memory (read-only).""" + try: + from realize.auth import auth, BrowserAuth, RealizeAuth + + # Determine auth type + if isinstance(auth, BrowserAuth): + auth_type = "browser" + elif isinstance(auth, RealizeAuth): + auth_type = "client_credentials" + else: + auth_type = "unknown" + + # Check if token exists + if not auth.token: + return [ + types.TextContent( + type="text", + text=json.dumps({ + "valid": False, + "auth_type": auth_type, + "reason": "No token in memory" + }, indent=2) + ) + ] + + # Check if token is expired + is_expired = auth._is_token_expired() + + if is_expired: + return [ + types.TextContent( + type="text", + text=json.dumps({ + "valid": False, + "auth_type": auth_type, + "expired": True, + "reason": "Token is expired" + }, indent=2) + ) + ] + + # Calculate time until expiration + from datetime import datetime, timedelta + expiry_time = auth.token.created_at + timedelta(seconds=auth.token.expires_in) + time_until_expiry = expiry_time - datetime.now() + seconds_until_expiry = int(time_until_expiry.total_seconds()) + + return [ + types.TextContent( + type="text", + text=json.dumps({ + "valid": True, + "auth_type": auth_type, + "expires_in": seconds_until_expiry, + "expired": False + }, indent=2) + ) + ] + + except Exception as e: + logger.error(f"Failed to check token validity: {e}") + return [ + types.TextContent( + type="text", + text=json.dumps({ + "valid": False, + "error": str(e), + "reason": "Error checking token" + }, indent=2) + ) ] \ No newline at end of file diff --git a/src/realize/tools/registry.py b/src/realize/tools/registry.py index 37dde70..b6e45ed 100644 --- a/src/realize/tools/registry.py +++ b/src/realize/tools/registry.py @@ -27,6 +27,17 @@ "category": "authentication" }, + "is_token_valid": { + "description": "Quickly check if there is a valid authentication token in memory without making API calls. Returns JSON with 'valid' (boolean), 'auth_type' (client_credentials/browser), and 'expires_in' (seconds). Use this to decide whether to authenticate before making API calls.", + "schema": { + "type": "object", + "properties": {}, + "required": [] + }, + "handler": "auth_handlers.is_token_valid", + "category": "authentication" + }, + # Account Management Tools "search_accounts": { "description": "Search for accounts by numeric ID or text query to get account_id values needed for other tools (read-only). Returns account data including 'account_id' field (camelCase string) required for campaign and report operations. WORKFLOW: Use this tool FIRST to get account_id values, then use those values with other tools.",