From a985f77628e915f687209906746cbfde1d79ef0e Mon Sep 17 00:00:00 2001 From: John Corbett <547858+jtcorbett@users.noreply.github.com> Date: Wed, 3 Sep 2025 23:10:55 +0000 Subject: [PATCH 01/10] add auth commands --- .../cli/cloud/commands/auth/__init__.py | 7 + .../cli/cloud/commands/auth/login/__init__.py | 5 + .../cloud/commands/auth/login/constants.py | 5 + .../cli/cloud/commands/auth/login/main.py | 167 ++++++++++++++++++ .../cloud/commands/auth/logout/__init__.py | 5 + .../cli/cloud/commands/auth/logout/main.py | 37 ++++ .../cloud/commands/auth/whoami/__init__.py | 5 + .../cli/cloud/commands/auth/whoami/main.py | 57 ++++++ 8 files changed, 288 insertions(+) create mode 100644 src/mcp_agent/cli/cloud/commands/auth/__init__.py create mode 100644 src/mcp_agent/cli/cloud/commands/auth/login/__init__.py create mode 100644 src/mcp_agent/cli/cloud/commands/auth/login/constants.py create mode 100644 src/mcp_agent/cli/cloud/commands/auth/login/main.py create mode 100644 src/mcp_agent/cli/cloud/commands/auth/logout/__init__.py create mode 100644 src/mcp_agent/cli/cloud/commands/auth/logout/main.py create mode 100644 src/mcp_agent/cli/cloud/commands/auth/whoami/__init__.py create mode 100644 src/mcp_agent/cli/cloud/commands/auth/whoami/main.py 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/auth/login/__init__.py b/src/mcp_agent/cli/cloud/commands/auth/login/__init__.py new file mode 100644 index 000000000..676023167 --- /dev/null +++ b/src/mcp_agent/cli/cloud/commands/auth/login/__init__.py @@ -0,0 +1,5 @@ +"""MCP Agent Cloud login command.""" + +from .main import login + +__all__ = ["login"] diff --git a/src/mcp_agent/cli/cloud/commands/auth/login/constants.py b/src/mcp_agent/cli/cloud/commands/auth/login/constants.py new file mode 100644 index 000000000..a741e5909 --- /dev/null +++ b/src/mcp_agent/cli/cloud/commands/auth/login/constants.py @@ -0,0 +1,5 @@ +"""Constants for the MCP Agent CLI login command.""" + +# Default values +# TODO: Change to oauth2 +DEFAULT_API_AUTH_PATH = "auth/signin?callbackUrl=%2Fapikeys%3Fcreate%3DMCP_AGENT_CLI" 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..d84c6f912 --- /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_cloud.auth import ( + UserCredentials, + load_credentials, + save_credentials, +) +from mcp_agent_cloud.config import settings +from mcp_agent_cloud.core.api_client import APIClient +from mcp_agent_cloud.exceptions import CLIError +from mcp_agent_cloud.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..89cd5ce05 --- /dev/null +++ b/src/mcp_agent/cli/cloud/commands/auth/logout/main.py @@ -0,0 +1,37 @@ +"""MCP Agent Cloud logout command implementation.""" + +import typer +from rich.prompt import Confirm + +from mcp_agent_cloud.auth import clear_credentials, load_credentials +from mcp_agent_cloud.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}?"): + 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..601a63c12 --- /dev/null +++ b/src/mcp_agent/cli/cloud/commands/auth/whoami/main.py @@ -0,0 +1,57 @@ +"""MCP Agent Cloud whoami command implementation.""" + +from typing import Optional + +import typer +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from mcp_agent_cloud.auth import load_credentials +from mcp_agent_cloud.exceptions import CLIError +from mcp_agent_cloud.ux import print_error, print_info + + +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) From 202871019e4fa36f20774127779bfab69dbccd80 Mon Sep 17 00:00:00 2001 From: John Corbett <547858+jtcorbett@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:56:20 +0000 Subject: [PATCH 02/10] update main.py to include typer info --- src/mcp_agent/cli/cloud/commands/__init__.py | 4 +- .../cli/cloud/commands/login/__init__.py | 5 -- .../cli/cloud/commands/login/constants.py | 5 -- .../cli/cloud/commands/login/main.py | 90 ------------------- src/mcp_agent/cli/cloud/main.py | 52 ++++++++--- 5 files changed, 42 insertions(+), 114 deletions(-) delete mode 100644 src/mcp_agent/cli/cloud/commands/login/__init__.py delete mode 100644 src/mcp_agent/cli/cloud/commands/login/constants.py delete mode 100644 src/mcp_agent/cli/cloud/commands/login/main.py 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/login/__init__.py b/src/mcp_agent/cli/cloud/commands/login/__init__.py deleted file mode 100644 index 676023167..000000000 --- a/src/mcp_agent/cli/cloud/commands/login/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""MCP Agent Cloud login command.""" - -from .main import login - -__all__ = ["login"] diff --git a/src/mcp_agent/cli/cloud/commands/login/constants.py b/src/mcp_agent/cli/cloud/commands/login/constants.py deleted file mode 100644 index a741e5909..000000000 --- a/src/mcp_agent/cli/cloud/commands/login/constants.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Constants for the MCP Agent CLI login command.""" - -# Default values -# TODO: Change to oauth2 -DEFAULT_API_AUTH_PATH = "auth/signin?callbackUrl=%2Fapikeys%3Fcreate%3DMCP_AGENT_CLI" 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/main.py b/src/mcp_agent/cli/cloud/main.py index a883c7c46..d4a21a0c2 100644 --- a/src/mcp_agent/cli/cloud/main.py +++ b/src/mcp_agent/cli/cloud/main.py @@ -13,7 +13,7 @@ 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, @@ -105,17 +105,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 +134,45 @@ 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") +# Register cloud commands (only containing auth for now) +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( From a914f6666f21a49611c1525e9751040ec8e9166f Mon Sep 17 00:00:00 2001 From: John Corbett <547858+jtcorbett@users.noreply.github.com> Date: Thu, 4 Sep 2025 15:11:39 +0000 Subject: [PATCH 03/10] add models and update imports --- src/mcp_agent/cli/auth/__init__.py | 16 ++++- src/mcp_agent/cli/auth/constants.py | 2 +- src/mcp_agent/cli/auth/main.py | 50 ++++++++++++--- src/mcp_agent/cli/auth/models.py | 64 +++++++++++++++++++ .../cli/cloud/commands/auth/login/main.py | 10 +-- .../cli/cloud/commands/auth/logout/main.py | 4 +- .../cli/cloud/commands/auth/whoami/main.py | 6 +- 7 files changed, 130 insertions(+), 22 deletions(-) create mode 100644 src/mcp_agent/cli/auth/models.py 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..6dbf845ad 100644 --- a/src/mcp_agent/cli/auth/main.py +++ b/src/mcp_agent/cli/auth/main.py @@ -1,32 +1,64 @@ +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) + + # Create file with restricted permissions (0600) to prevent leakage with open(credentials_path, "w", encoding="utf-8") as f: - f.write(api_key) + f.write(credentials.to_json()) + os.chmod(credentials_path, 0o600) -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..1445031fe --- /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 +from datetime import datetime +from typing import List, Optional + + +@dataclass +class UserCredentials: + """User authentication credentials and identity information.""" + + # Authentication + api_key: str + 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/auth/login/main.py b/src/mcp_agent/cli/cloud/commands/auth/login/main.py index d84c6f912..1baf73789 100644 --- a/src/mcp_agent/cli/cloud/commands/auth/login/main.py +++ b/src/mcp_agent/cli/cloud/commands/auth/login/main.py @@ -4,15 +4,15 @@ import typer from rich.prompt import Confirm, Prompt -from mcp_agent_cloud.auth import ( +from mcp_agent.cli.auth import ( UserCredentials, load_credentials, save_credentials, ) -from mcp_agent_cloud.config import settings -from mcp_agent_cloud.core.api_client import APIClient -from mcp_agent_cloud.exceptions import CLIError -from mcp_agent_cloud.ux import ( +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, diff --git a/src/mcp_agent/cli/cloud/commands/auth/logout/main.py b/src/mcp_agent/cli/cloud/commands/auth/logout/main.py index 89cd5ce05..de7be6cea 100644 --- a/src/mcp_agent/cli/cloud/commands/auth/logout/main.py +++ b/src/mcp_agent/cli/cloud/commands/auth/logout/main.py @@ -3,8 +3,8 @@ import typer from rich.prompt import Confirm -from mcp_agent_cloud.auth import clear_credentials, load_credentials -from mcp_agent_cloud.ux import print_info, print_success +from mcp_agent.cli.auth import clear_credentials, load_credentials +from mcp_agent.cli.utils.ux import print_info, print_success def logout() -> None: diff --git a/src/mcp_agent/cli/cloud/commands/auth/whoami/main.py b/src/mcp_agent/cli/cloud/commands/auth/whoami/main.py index 601a63c12..ea3bd99b3 100644 --- a/src/mcp_agent/cli/cloud/commands/auth/whoami/main.py +++ b/src/mcp_agent/cli/cloud/commands/auth/whoami/main.py @@ -7,9 +7,9 @@ from rich.panel import Panel from rich.table import Table -from mcp_agent_cloud.auth import load_credentials -from mcp_agent_cloud.exceptions import CLIError -from mcp_agent_cloud.ux import print_error, print_info +from mcp_agent.cli.auth import load_credentials +from mcp_agent.cli.exceptions import CLIError +from mcp_agent.cli.utils.ux import print_error, print_info def whoami() -> None: From 603e44b8ca98a4dbcd75c7b1fd2b5afdc8e0764e Mon Sep 17 00:00:00 2001 From: John Corbett <547858+jtcorbett@users.noreply.github.com> Date: Thu, 4 Sep 2025 15:13:32 +0000 Subject: [PATCH 04/10] remove unused imports --- src/mcp_agent/cli/auth/models.py | 2 +- src/mcp_agent/cli/cloud/commands/auth/logout/main.py | 1 - src/mcp_agent/cli/cloud/commands/auth/whoami/main.py | 3 --- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/mcp_agent/cli/auth/models.py b/src/mcp_agent/cli/auth/models.py index 1445031fe..016a11f5f 100644 --- a/src/mcp_agent/cli/auth/models.py +++ b/src/mcp_agent/cli/auth/models.py @@ -3,7 +3,7 @@ import json from dataclasses import dataclass from datetime import datetime -from typing import List, Optional +from typing import Optional @dataclass diff --git a/src/mcp_agent/cli/cloud/commands/auth/logout/main.py b/src/mcp_agent/cli/cloud/commands/auth/logout/main.py index de7be6cea..a3913f9ff 100644 --- a/src/mcp_agent/cli/cloud/commands/auth/logout/main.py +++ b/src/mcp_agent/cli/cloud/commands/auth/logout/main.py @@ -1,6 +1,5 @@ """MCP Agent Cloud logout command implementation.""" -import typer from rich.prompt import Confirm from mcp_agent.cli.auth import clear_credentials, load_credentials diff --git a/src/mcp_agent/cli/cloud/commands/auth/whoami/main.py b/src/mcp_agent/cli/cloud/commands/auth/whoami/main.py index ea3bd99b3..7965fdef8 100644 --- a/src/mcp_agent/cli/cloud/commands/auth/whoami/main.py +++ b/src/mcp_agent/cli/cloud/commands/auth/whoami/main.py @@ -1,15 +1,12 @@ """MCP Agent Cloud whoami command implementation.""" -from typing import Optional -import typer 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 -from mcp_agent.cli.utils.ux import print_error, print_info def whoami() -> None: From 467c24360ebe714376c0dbd4fd315315b82f992a Mon Sep 17 00:00:00 2001 From: John Corbett <547858+jtcorbett@users.noreply.github.com> Date: Thu, 4 Sep 2025 11:21:48 -0400 Subject: [PATCH 05/10] Apply suggestions from code review --- src/mcp_agent/cli/auth/main.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/mcp_agent/cli/auth/main.py b/src/mcp_agent/cli/auth/main.py index 6dbf845ad..2ade14066 100644 --- a/src/mcp_agent/cli/auth/main.py +++ b/src/mcp_agent/cli/auth/main.py @@ -20,8 +20,9 @@ def save_credentials(credentials: UserCredentials) -> None: # Create file with restricted permissions (0600) to prevent leakage with open(credentials_path, "w", encoding="utf-8") as f: - f.write(credentials.to_json()) - os.chmod(credentials_path, 0o600) + 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_credentials() -> Optional[UserCredentials]: From 71daae2950a8e9be1416eb81c3dc9f30a8518366 Mon Sep 17 00:00:00 2001 From: John Corbett <547858+jtcorbett@users.noreply.github.com> Date: Thu, 4 Sep 2025 15:22:09 +0000 Subject: [PATCH 06/10] default login to false --- src/mcp_agent/cli/cloud/commands/auth/logout/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp_agent/cli/cloud/commands/auth/logout/main.py b/src/mcp_agent/cli/cloud/commands/auth/logout/main.py index a3913f9ff..03941eea8 100644 --- a/src/mcp_agent/cli/cloud/commands/auth/logout/main.py +++ b/src/mcp_agent/cli/cloud/commands/auth/logout/main.py @@ -25,7 +25,7 @@ def logout() -> None: user_info = f"user '{credentials.email}'" # Confirm logout action - if not Confirm.ask(f"Are you sure you want to logout {user_info}?"): + if not Confirm.ask(f"Are you sure you want to logout {user_info}?", default=False): print_info("Logout cancelled.") return From 47c9c99a77f4d6ba72a17581ef8ea08dc262625b Mon Sep 17 00:00:00 2001 From: John Corbett <547858+jtcorbett@users.noreply.github.com> Date: Thu, 4 Sep 2025 11:43:55 -0400 Subject: [PATCH 07/10] Update main.py --- src/mcp_agent/cli/auth/main.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/mcp_agent/cli/auth/main.py b/src/mcp_agent/cli/auth/main.py index 2ade14066..dd95d5257 100644 --- a/src/mcp_agent/cli/auth/main.py +++ b/src/mcp_agent/cli/auth/main.py @@ -19,10 +19,9 @@ def save_credentials(credentials: UserCredentials) -> None: os.makedirs(os.path.dirname(credentials_path), exist_ok=True) # Create file with restricted permissions (0600) to prevent leakage - with open(credentials_path, "w", encoding="utf-8") as f: - fd = os.open(credentials_path, os.O_WRONLY | os.O_CREAT, 0o600) - with os.fdopen(fd, "w") as f: - f.write(credentials.to_json()) + 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_credentials() -> Optional[UserCredentials]: From 0a9e2dbf42eeed00d27fbce52b06f0a7dcd25e19 Mon Sep 17 00:00:00 2001 From: John Corbett <547858+jtcorbett@users.noreply.github.com> Date: Thu, 4 Sep 2025 15:47:06 +0000 Subject: [PATCH 08/10] permissions on credentials dir --- src/mcp_agent/cli/auth/main.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/mcp_agent/cli/auth/main.py b/src/mcp_agent/cli/auth/main.py index dd95d5257..e169bf1d7 100644 --- a/src/mcp_agent/cli/auth/main.py +++ b/src/mcp_agent/cli/auth/main.py @@ -16,7 +16,12 @@ def save_credentials(credentials: UserCredentials) -> None: None """ credentials_path = os.path.expanduser(DEFAULT_CREDENTIALS_PATH) - os.makedirs(os.path.dirname(credentials_path), exist_ok=True) + 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) From 521eca9a0fa74e5f9c3f5154154dd6158a557056 Mon Sep 17 00:00:00 2001 From: John Corbett <547858+jtcorbett@users.noreply.github.com> Date: Thu, 4 Sep 2025 15:52:06 +0000 Subject: [PATCH 09/10] remove api key from repr --- src/mcp_agent/cli/auth/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mcp_agent/cli/auth/models.py b/src/mcp_agent/cli/auth/models.py index 016a11f5f..df4735663 100644 --- a/src/mcp_agent/cli/auth/models.py +++ b/src/mcp_agent/cli/auth/models.py @@ -1,7 +1,7 @@ """Authentication models for MCP Agent Cloud CLI.""" import json -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from typing import Optional @@ -11,7 +11,7 @@ class UserCredentials: """User authentication credentials and identity information.""" # Authentication - api_key: str + api_key: str = field(repr=False) token_expires_at: Optional[datetime] = None # Identity From 40f9ee63c150fbdf34f609197d46c85ff51d0057 Mon Sep 17 00:00:00 2001 From: John Corbett <547858+jtcorbett@users.noreply.github.com> Date: Thu, 4 Sep 2025 15:41:00 +0000 Subject: [PATCH 10/10] add cloud open --- .../cli/cloud/commands/open/__init__.py | 5 ++ src/mcp_agent/cli/cloud/commands/open/main.py | 48 +++++++++++++++++++ src/mcp_agent/cli/cloud/main.py | 8 +++- src/mcp_agent/cli/core/constants.py | 5 +- 4 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 src/mcp_agent/cli/cloud/commands/open/__init__.py create mode 100644 src/mcp_agent/cli/cloud/commands/open/main.py 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 d4a21a0c2..28fb76788 100644 --- a/src/mcp_agent/cli/cloud/main.py +++ b/src/mcp_agent/cli/cloud/main.py @@ -20,6 +20,7 @@ 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 @@ -160,7 +161,12 @@ def invoke(self, ctx): 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") -# Register cloud commands (only containing auth for now) + +# 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( 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"