diff --git a/src/mcp_agent/cli/auth/__init__.py b/src/mcp_agent/cli/auth/__init__.py index 0a42dfb3c..1b0b741dd 100644 --- a/src/mcp_agent/cli/auth/__init__.py +++ b/src/mcp_agent/cli/auth/__init__.py @@ -3,6 +3,18 @@ This package provides utilities for authentication (for now, api keys). """ -from .main import load_api_key_credentials, save_api_key_credentials +from .main import ( + clear_credentials, + load_api_key_credentials, + load_credentials, + save_credentials, +) +from .models import UserCredentials -__all__ = ["load_api_key_credentials", "save_api_key_credentials"] +__all__ = [ + "clear_credentials", + "load_api_key_credentials", + "load_credentials", + "save_credentials", + "UserCredentials", +] diff --git a/src/mcp_agent/cli/auth/constants.py b/src/mcp_agent/cli/auth/constants.py index aab9b2727..d90fcab1e 100644 --- a/src/mcp_agent/cli/auth/constants.py +++ b/src/mcp_agent/cli/auth/constants.py @@ -1,4 +1,4 @@ """Constants for the MCP Agent auth utilities.""" # Default values -DEFAULT_CREDENTIALS_PATH = "~/.mcp_agent/cloud/auth/credentials" +DEFAULT_CREDENTIALS_PATH = "~/.mcp-agent/credentials.json" diff --git a/src/mcp_agent/cli/auth/main.py b/src/mcp_agent/cli/auth/main.py index d7c73fed7..e169bf1d7 100644 --- a/src/mcp_agent/cli/auth/main.py +++ b/src/mcp_agent/cli/auth/main.py @@ -1,32 +1,69 @@ +import json import os from typing import Optional from .constants import DEFAULT_CREDENTIALS_PATH +from .models import UserCredentials -def save_api_key_credentials(api_key: str): - """Save an API key to the credentials file. +def save_credentials(credentials: UserCredentials) -> None: + """Save user credentials to the credentials file. Args: - api_key: API key to persist + credentials: UserCredentials object to persist Returns: None """ credentials_path = os.path.expanduser(DEFAULT_CREDENTIALS_PATH) - os.makedirs(os.path.dirname(credentials_path), exist_ok=True) - with open(credentials_path, "w", encoding="utf-8") as f: - f.write(api_key) + cred_dir = os.path.dirname(credentials_path) + os.makedirs(cred_dir, exist_ok=True) + try: + os.chmod(cred_dir, 0o700) + except OSError: + pass + # Create file with restricted permissions (0600) to prevent leakage + fd = os.open(credentials_path, os.O_WRONLY | os.O_CREAT, 0o600) + with os.fdopen(fd, "w") as f: + f.write(credentials.to_json()) -def load_api_key_credentials() -> Optional[str]: - """Load an API key from the credentials file. + +def load_credentials() -> Optional[UserCredentials]: + """Load user credentials from the credentials file. Returns: - String. API key if it exists, None otherwise + UserCredentials object if it exists, None otherwise """ credentials_path = os.path.expanduser(DEFAULT_CREDENTIALS_PATH) if os.path.exists(credentials_path): - with open(credentials_path, "r", encoding="utf-8") as f: - return f.read().strip() + try: + with open(credentials_path, "r", encoding="utf-8") as f: + return UserCredentials.from_json(f.read()) + except (json.JSONDecodeError, KeyError, ValueError): + # Handle corrupted or old format credentials + return None return None + + +def clear_credentials() -> bool: + """Clear stored credentials. + + Returns: + bool: True if credentials were cleared, False if none existed + """ + credentials_path = os.path.expanduser(DEFAULT_CREDENTIALS_PATH) + if os.path.exists(credentials_path): + os.remove(credentials_path) + return True + return False + + +def load_api_key_credentials() -> Optional[str]: + """Load an API key from the credentials file (backward compatibility). + + Returns: + String. API key if it exists, None otherwise + """ + credentials = load_credentials() + return credentials.api_key if credentials else None diff --git a/src/mcp_agent/cli/auth/models.py b/src/mcp_agent/cli/auth/models.py new file mode 100644 index 000000000..df4735663 --- /dev/null +++ b/src/mcp_agent/cli/auth/models.py @@ -0,0 +1,64 @@ +"""Authentication models for MCP Agent Cloud CLI.""" + +import json +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional + + +@dataclass +class UserCredentials: + """User authentication credentials and identity information.""" + + # Authentication + api_key: str = field(repr=False) + token_expires_at: Optional[datetime] = None + + # Identity + username: Optional[str] = None + email: Optional[str] = None + + @property + def is_token_expired(self) -> bool: + """Check if the token is expired.""" + if not self.token_expires_at: + return False + return datetime.now() > self.token_expires_at + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + result = { + "api_key": self.api_key, + "username": self.username, + "email": self.email, + } + + if self.token_expires_at: + result["token_expires_at"] = self.token_expires_at.isoformat() + + return result + + @classmethod + def from_dict(cls, data: dict) -> "UserCredentials": + """Create from dictionary loaded from JSON.""" + + token_expires_at = None + if "token_expires_at" in data: + token_expires_at = datetime.fromisoformat(data["token_expires_at"]) + + return cls( + api_key=data["api_key"], + token_expires_at=token_expires_at, + username=data.get("username"), + email=data.get("email"), + ) + + def to_json(self) -> str: + """Convert to JSON string.""" + return json.dumps(self.to_dict(), indent=2) + + @classmethod + def from_json(cls, json_str: str) -> "UserCredentials": + """Create from JSON string.""" + data = json.loads(json_str) + return cls.from_dict(data) diff --git a/src/mcp_agent/cli/cloud/commands/__init__.py b/src/mcp_agent/cli/cloud/commands/__init__.py index 9efce3fa6..d3ac8b107 100644 --- a/src/mcp_agent/cli/cloud/commands/__init__.py +++ b/src/mcp_agent/cli/cloud/commands/__init__.py @@ -6,6 +6,6 @@ from .configure.main import configure_app from .deploy.main import deploy_config -from .login import login +from .auth import login, logout, whoami -__all__ = ["configure_app", "deploy_config", "login"] +__all__ = ["configure_app", "deploy_config", "login", "logout", "whoami"] diff --git a/src/mcp_agent/cli/cloud/commands/auth/__init__.py b/src/mcp_agent/cli/cloud/commands/auth/__init__.py new file mode 100644 index 000000000..9aed82205 --- /dev/null +++ b/src/mcp_agent/cli/cloud/commands/auth/__init__.py @@ -0,0 +1,7 @@ +"""MCP Agent Cloud authentication commands.""" + +from .login import login +from .logout import logout +from .whoami import whoami + +__all__ = ["login", "logout", "whoami"] diff --git a/src/mcp_agent/cli/cloud/commands/login/__init__.py b/src/mcp_agent/cli/cloud/commands/auth/login/__init__.py similarity index 100% rename from src/mcp_agent/cli/cloud/commands/login/__init__.py rename to src/mcp_agent/cli/cloud/commands/auth/login/__init__.py diff --git a/src/mcp_agent/cli/cloud/commands/login/constants.py b/src/mcp_agent/cli/cloud/commands/auth/login/constants.py similarity index 100% rename from src/mcp_agent/cli/cloud/commands/login/constants.py rename to src/mcp_agent/cli/cloud/commands/auth/login/constants.py diff --git a/src/mcp_agent/cli/cloud/commands/auth/login/main.py b/src/mcp_agent/cli/cloud/commands/auth/login/main.py new file mode 100644 index 000000000..1baf73789 --- /dev/null +++ b/src/mcp_agent/cli/cloud/commands/auth/login/main.py @@ -0,0 +1,167 @@ +import asyncio +from typing import Optional + +import typer +from rich.prompt import Confirm, Prompt + +from mcp_agent.cli.auth import ( + UserCredentials, + load_credentials, + save_credentials, +) +from mcp_agent.cli.config import settings +from mcp_agent.cli.core.api_client import APIClient +from mcp_agent.cli.exceptions import CLIError +from mcp_agent.cli.utils.ux import ( + print_info, + print_success, + print_warning, +) + +from .constants import DEFAULT_API_AUTH_PATH + + +def _load_user_credentials(api_key: str) -> UserCredentials: + """Load credentials with user profile data fetched from API. + + Args: + api_key: The API key + + Returns: + UserCredentials object with profile data if available + """ + + async def fetch_profile() -> UserCredentials: + """Fetch user profile from the API.""" + client = APIClient(settings.API_BASE_URL, api_key) + + response = await client.post("user/get_profile", {}) + user_data = response.json() + + user_profile = user_data.get("user", {}) + + return UserCredentials( + api_key=api_key, + username=user_profile.get("name"), + email=user_profile.get("email"), + ) + + try: + return asyncio.run(fetch_profile()) + except Exception as e: + print_warning(f"Could not fetch user profile: {str(e)}") + # Fallback to minimal credentials + return UserCredentials(api_key=api_key) + + +def login( + api_key: Optional[str] = typer.Option( + None, + "--api-key", + help="Optionally set an existing API key to use for authentication, bypassing manual login.", + envvar="MCP_API_KEY", + ), + no_open: bool = typer.Option( + False, + "--no-open", + help="Don't automatically open browser for authentication.", + ), +) -> str: + """Authenticate to MCP Agent Cloud API. + + Direct to the api keys page for obtaining credentials, routing through login. + + Args: + api_key: Optionally set an existing API key to use for authentication, bypassing manual login. + no_open: Don't automatically open browser for authentication. + + Returns: + API key string. Prints success message if login is successful. + """ + + existing_credentials = load_credentials() + if existing_credentials and not existing_credentials.is_token_expired: + if not Confirm.ask("You are already logged in. Do you want to login again?"): + print_info("Using existing credentials.") + return existing_credentials.api_key + + if api_key: + print_info("Using provided API key for authentication (MCP_API_KEY).") + if not _is_valid_api_key(api_key): + raise CLIError("Invalid API key provided.") + + credentials = _load_user_credentials(api_key) + + save_credentials(credentials) + print_success("API key set.") + if credentials.username: + print_info(f"Logged in as: {credentials.username}") + return api_key + + base_url = settings.API_BASE_URL + + return _handle_browser_auth(base_url, no_open) + + +def _handle_browser_auth(base_url: str, no_open: bool) -> str: + """Handle browser-based authentication flow. + + Args: + base_url: API base URL + no_open: Whether to skip automatic browser opening + + Returns: + API key string + """ + auth_url = f"{base_url}/{DEFAULT_API_AUTH_PATH}" + + # TODO: This flow should be updated to OAuth2. Probably need to spin up local server to handle + # the oauth2 callback url. + if not no_open: + print_info("Opening MCP Agent Cloud API login in browser...") + print_info( + f"If the browser doesn't automatically open, you can manually visit: {auth_url}" + ) + typer.launch(auth_url) + else: + print_info(f"Please visit: {auth_url}") + + return _handle_manual_key_input() + + +def _handle_manual_key_input() -> str: + """Handle manual API key input. + + Returns: + API key string + """ + input_api_key = Prompt.ask("Please enter your API key :key:") + + if not input_api_key: + print_warning("No API key provided.") + raise CLIError("Failed to set valid API key") + + if not _is_valid_api_key(input_api_key): + print_warning("Invalid API key provided.") + raise CLIError("Failed to set valid API key") + + credentials = _load_user_credentials(input_api_key) + + save_credentials(credentials) + print_success("API key set.") + if credentials.username: + print_info(f"Logged in as: {credentials.username}") + + return input_api_key + + +def _is_valid_api_key(api_key: str) -> bool: + """Validate the API key. + + Args: + api_key: The API key to validate. + + Returns: + bool: True if the API key is valid, False otherwise. + """ + return api_key.startswith("lm_mcp_api_") diff --git a/src/mcp_agent/cli/cloud/commands/auth/logout/__init__.py b/src/mcp_agent/cli/cloud/commands/auth/logout/__init__.py new file mode 100644 index 000000000..5fdb98fc6 --- /dev/null +++ b/src/mcp_agent/cli/cloud/commands/auth/logout/__init__.py @@ -0,0 +1,5 @@ +"""MCP Agent Cloud logout command.""" + +from .main import logout + +__all__ = ["logout"] diff --git a/src/mcp_agent/cli/cloud/commands/auth/logout/main.py b/src/mcp_agent/cli/cloud/commands/auth/logout/main.py new file mode 100644 index 000000000..03941eea8 --- /dev/null +++ b/src/mcp_agent/cli/cloud/commands/auth/logout/main.py @@ -0,0 +1,36 @@ +"""MCP Agent Cloud logout command implementation.""" + +from rich.prompt import Confirm + +from mcp_agent.cli.auth import clear_credentials, load_credentials +from mcp_agent.cli.utils.ux import print_info, print_success + + +def logout() -> None: + """Clear credentials. + + Removes stored authentication information. + """ + credentials = load_credentials() + + if not credentials: + print_info("Not currently logged in.") + return + + # Show who is being logged out + user_info = "current user" + if credentials.username: + user_info = f"user '{credentials.username}'" + elif credentials.email: + user_info = f"user '{credentials.email}'" + + # Confirm logout action + if not Confirm.ask(f"Are you sure you want to logout {user_info}?", default=False): + print_info("Logout cancelled.") + return + + # Clear credentials + if clear_credentials(): + print_success("Successfully logged out.") + else: + print_info("No credentials were found to clear.") diff --git a/src/mcp_agent/cli/cloud/commands/auth/whoami/__init__.py b/src/mcp_agent/cli/cloud/commands/auth/whoami/__init__.py new file mode 100644 index 000000000..506ca1b00 --- /dev/null +++ b/src/mcp_agent/cli/cloud/commands/auth/whoami/__init__.py @@ -0,0 +1,5 @@ +"""MCP Agent Cloud whoami command.""" + +from .main import whoami + +__all__ = ["whoami"] diff --git a/src/mcp_agent/cli/cloud/commands/auth/whoami/main.py b/src/mcp_agent/cli/cloud/commands/auth/whoami/main.py new file mode 100644 index 000000000..7965fdef8 --- /dev/null +++ b/src/mcp_agent/cli/cloud/commands/auth/whoami/main.py @@ -0,0 +1,54 @@ +"""MCP Agent Cloud whoami command implementation.""" + + +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from mcp_agent.cli.auth import load_credentials +from mcp_agent.cli.exceptions import CLIError + + +def whoami() -> None: + """Print current identity and org(s). + + Shows the authenticated user information and organization memberships. + """ + credentials = load_credentials() + + if not credentials: + raise CLIError( + "Not logged in. Use 'mcp-agent login' to authenticate.", exit_code=4 + ) + + if credentials.is_token_expired: + raise CLIError( + "Authentication token has expired. Use 'mcp-agent login' to re-authenticate.", + exit_code=4, + ) + + console = Console() + + # Create user info table + user_table = Table(show_header=False, box=None) + user_table.add_column("Field", style="bold") + user_table.add_column("Value") + + # Add user information + if credentials.username: + user_table.add_row("Username", credentials.username) + if credentials.email: + user_table.add_row("Email", credentials.email) + + # Add token expiry if available + if credentials.token_expires_at: + user_table.add_row( + "Token Expires", + credentials.token_expires_at.strftime("%Y-%m-%d %H:%M:%S UTC"), + ) + else: + user_table.add_row("Token Expires", "Never") + + # Create user panel + user_panel = Panel(user_table, title="User Information", title_align="left") + console.print(user_panel) diff --git a/src/mcp_agent/cli/cloud/commands/login/main.py b/src/mcp_agent/cli/cloud/commands/login/main.py deleted file mode 100644 index 2b6e0326e..000000000 --- a/src/mcp_agent/cli/cloud/commands/login/main.py +++ /dev/null @@ -1,90 +0,0 @@ -from typing import Optional - -import typer -from rich.prompt import Prompt - -from mcp_agent.cli.auth import ( - save_api_key_credentials, -) -from mcp_agent.cli.config import settings -from mcp_agent.cli.exceptions import CLIError -from mcp_agent.cli.utils.ux import ( - print_info, - print_success, - print_warning, -) - -from .constants import DEFAULT_API_AUTH_PATH - - -def login( - api_key: Optional[str] = typer.Option( - None, - "--api-key", - help="Optionally set an existing API key to use for authentication, bypassing manual login.", - envvar="MCP_API_KEY", - ), - api_url: Optional[str] = typer.Option( - None, - "--api-url", - help="API base URL. Overrides MCP_API_BASE_URL environment variable and persisted credentials.", - ), -) -> str: - """Authenticate to MCP Agent Cloud API. - - Direct to the api keys page for obtaining credentials, routing through login. - - Args: - api_key: Optionally set an existing API key to use for authentication, bypassing manual login. - api_url: Override the default base API url. - - - Returns: - None. Prints success message if login is successful. - """ - - if api_key: - print_info("Using provided API key for authentication.") - if not _is_valid_api_key(api_key): - raise CLIError("Invalid API key provided.") - save_api_key_credentials(api_key) - print_success("API key set.") - return api_key - - base_url = api_url or settings.API_BASE_URL - auth_url = f"{base_url}/{DEFAULT_API_AUTH_PATH}" - - # TODO: This flow should be updated to Oauth2. Probably need to spin up local server to handle - # the oauth2 callback url. - print_info("Directing to MCP Agent Cloud API login...") - typer.launch(auth_url) - - attempts = 3 - while attempts > 0: - attempts -= 1 - input_api_key = Prompt.ask("Please enter your API key :key:", password=True) - - if not input_api_key: - print_warning("No API key provided.") - continue - - if _is_valid_api_key(input_api_key): - save_api_key_credentials(input_api_key) - print_success("API key set.") - return input_api_key - - print_warning("Invalid API key provided.") - - raise CLIError("Failed to set valid API key") - - -def _is_valid_api_key(api_key: str) -> bool: - """Validate the API key. - - Args: - api_key: The API key to validate. - - Returns: - bool: True if the API key is valid, False otherwise. - """ - return api_key.startswith("lm_mcp_api_") diff --git a/src/mcp_agent/cli/cloud/commands/open/__init__.py b/src/mcp_agent/cli/cloud/commands/open/__init__.py new file mode 100644 index 000000000..014e1def8 --- /dev/null +++ b/src/mcp_agent/cli/cloud/commands/open/__init__.py @@ -0,0 +1,5 @@ +"""Cloud open command exports.""" + +from .main import open_portal + +__all__ = ["open_portal"] diff --git a/src/mcp_agent/cli/cloud/commands/open/main.py b/src/mcp_agent/cli/cloud/commands/open/main.py new file mode 100644 index 000000000..1c6c618e3 --- /dev/null +++ b/src/mcp_agent/cli/cloud/commands/open/main.py @@ -0,0 +1,48 @@ +"""Open MCP Agent Cloud portal in browser.""" + +import webbrowser +from typing import Optional + +import typer + +from mcp_agent.cli.core.constants import DEFAULT_BASE_URL +from mcp_agent.cli.utils.ux import print_info, print_warning + + +def open_portal( + server: Optional[str] = typer.Option( + None, + "--server", + help="Server ID or URL to open deployment details page", + ), +) -> None: + """Open the MCP Agent Cloud portal in browser. + + Opens the portal home page by default, or the deployment details page + if a server ID or URL is provided. + + Args: + server: Optional server ID or URL to open deployment details for + """ + # Use the base URL directly for portal access + base_url = DEFAULT_BASE_URL.rstrip("/") + + if server: + if server.startswith("http"): + # If it's a URL, try to extract server ID + url = f"{base_url}/deployments/{server.split('/')[-1]}" + print_info(f"Opening deployment details for server: {server}") + else: + # If it's an ID, construct the deployment page URL + url = f"{base_url}/deployments/{server}" + print_info(f"Opening deployment details for server ID: {server}") + else: + url = f"{base_url}/dashboard" + print_info("Opening MCP Agent Cloud portal") + + try: + webbrowser.open(url) + print_info(f"Portal opened at: {url}") + except Exception as e: + print_warning(f"Could not open browser automatically: {str(e)}") + print_info(f"Please open this URL manually: {url}") diff --git a/src/mcp_agent/cli/cloud/main.py b/src/mcp_agent/cli/cloud/main.py index a883c7c46..28fb76788 100644 --- a/src/mcp_agent/cli/cloud/main.py +++ b/src/mcp_agent/cli/cloud/main.py @@ -13,13 +13,14 @@ from rich.panel import Panel from typer.core import TyperGroup -from mcp_agent.cli.cloud.commands import configure_app, deploy_config, login +from mcp_agent.cli.cloud.commands import configure_app, deploy_config, login, logout, whoami from mcp_agent.cli.cloud.commands.app import ( delete_app, get_app_status, list_app_workflows, ) from mcp_agent.cli.cloud.commands.apps import list_apps +from mcp_agent.cli.cloud.commands.open import open_portal from mcp_agent.cli.cloud.commands.workflow import get_workflow_status from mcp_agent.cli.exceptions import CLIError from mcp_agent.cli.utils.ux import print_error @@ -105,17 +106,6 @@ def invoke(self, ctx): )(deploy_config) -# Login command -app.command( - name="login", - help=""" -Authenticate to MCP Agent Cloud API.\n\n - -Direct to the api keys page for obtaining credentials, routing through login. -""".strip(), -)(login) - - # Sub-typer for `mcp-agent apps` commands app_cmd_apps = typer.Typer( help="Management commands for multiple MCP Apps", @@ -145,6 +135,50 @@ def invoke(self, ctx): app_cmd_workflow.command(name="status")(get_workflow_status) app.add_typer(app_cmd_workflow, name="workflow", help="Manage MCP Workflows") +# Sub-typer for `mcp-agent cloud` commands +app_cmd_cloud = typer.Typer( + help="Cloud operations and management", + no_args_is_help=True, + cls=HelpfulTyperGroup, +) +# Sub-typer for `mcp-agent cloud auth` commands +app_cmd_cloud_auth = typer.Typer( + help="Cloud authentication commands", + no_args_is_help=True, + cls=HelpfulTyperGroup, +) +# Register auth commands under cloud auth +app_cmd_cloud_auth.command( + name="login", + help=""" +Authenticate to MCP Agent Cloud API.\n\n +Direct to the api keys page for obtaining credentials, routing through login. +""".strip(), +)(login) +app_cmd_cloud_auth.command(name="whoami", help="Print current identity and org(s).")( + whoami +) +app_cmd_cloud_auth.command(name="logout", help="Clear credentials.")(logout) +# Add auth sub-typer to cloud +app_cmd_cloud.add_typer(app_cmd_cloud_auth, name="auth", help="Authentication commands") + +# Add open command to cloud +app_cmd_cloud.command(name="open", help="Open the MCP Agent Cloud portal in browser")( + open_portal +) + +app.add_typer(app_cmd_cloud, name="cloud", help="Cloud operations and management") +# Top-level auth commands that map to cloud auth commands +app.command( + name="login", + help=""" +Authenticate to MCP Agent Cloud API.\n\n +Direct to the api keys page for obtaining credentials, routing through login. +""".strip(), +)(login) +app.command(name="whoami", help="Print current identity and org(s).")(whoami) +app.command(name="logout", help="Clear credentials.")(logout) + @app.callback(invoke_without_command=True) def callback( diff --git a/src/mcp_agent/cli/core/constants.py b/src/mcp_agent/cli/core/constants.py index 56182e365..38e44e7e8 100644 --- a/src/mcp_agent/cli/core/constants.py +++ b/src/mcp_agent/cli/core/constants.py @@ -23,8 +23,9 @@ ENV_API_KEY = "MCP_API_KEY" ENV_VERBOSE = "MCP_VERBOSE" -# API defaults -DEFAULT_API_BASE_URL = "https://mcp-agent.com/api" +# Base URL defaults +DEFAULT_BASE_URL = "https://mcp-agent.com" # Base URL for web portal and API +DEFAULT_API_BASE_URL = f"{DEFAULT_BASE_URL}/api" # API endpoint derived from base URL # Secret types (string constants) SECRET_TYPE_DEVELOPER = "dev"