Skip to content

Commit 157867e

Browse files
authored
Add cloud auth (#410)
# Summary Enhanced authentication system for MCP Agent Cloud CLI with improved credential management, new auth commands, and a more structured credentials storage format. # Changelist Restructured authentication module to support richer user credentials Added new UserCredentials model to store API keys, user info, and token expiry Changed credentials storage path to ~/.mcp-agent/credentials.json Added new auth commands: whoami: Display current user identity information logout: Clear stored credentials Reorganized command structure with new cloud auth command group Maintained backward compatibility with existing API key functions Added unit tests for new authentication functionality # Test Plan ``` # Test login functionality mcp-agent login # Check current identity mcp-agent whoami # Test logout mcp-agent logout # Verify nested command structure works mcp-agent cloud auth login mcp-agent cloud auth whoami mcp-agent cloud auth logout ``` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - New Features - Added whoami command to display current user details. - Added logout command with confirmation to clear stored credentials. - Improvements - Enhanced login: browser or manual API-key flows, --api-key and --no-open options, profile loading, reuse prompt for existing credentials. - Credentials persisted in a JSON-backed format with token expiry awareness and a new default storage location. - Refactor - CLI reorganized into a cloud auth subgroup while retaining root-level aliases for auth commands. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent e763520 commit 157867e

File tree

15 files changed

+443
-118
lines changed

15 files changed

+443
-118
lines changed

src/mcp_agent/cli/auth/__init__.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@
33
This package provides utilities for authentication (for now, api keys).
44
"""
55

6-
from .main import load_api_key_credentials, save_api_key_credentials
6+
from .main import (
7+
clear_credentials,
8+
load_api_key_credentials,
9+
load_credentials,
10+
save_credentials,
11+
)
12+
from .models import UserCredentials
713

8-
__all__ = ["load_api_key_credentials", "save_api_key_credentials"]
14+
__all__ = [
15+
"clear_credentials",
16+
"load_api_key_credentials",
17+
"load_credentials",
18+
"save_credentials",
19+
"UserCredentials",
20+
]
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
"""Constants for the MCP Agent auth utilities."""
22

33
# Default values
4-
DEFAULT_CREDENTIALS_PATH = "~/.mcp_agent/cloud/auth/credentials"
4+
DEFAULT_CREDENTIALS_PATH = "~/.mcp-agent/credentials.json"

src/mcp_agent/cli/auth/main.py

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,69 @@
1+
import json
12
import os
23
from typing import Optional
34

45
from .constants import DEFAULT_CREDENTIALS_PATH
6+
from .models import UserCredentials
57

68

7-
def save_api_key_credentials(api_key: str):
8-
"""Save an API key to the credentials file.
9+
def save_credentials(credentials: UserCredentials) -> None:
10+
"""Save user credentials to the credentials file.
911
1012
Args:
11-
api_key: API key to persist
13+
credentials: UserCredentials object to persist
1214
1315
Returns:
1416
None
1517
"""
1618
credentials_path = os.path.expanduser(DEFAULT_CREDENTIALS_PATH)
17-
os.makedirs(os.path.dirname(credentials_path), exist_ok=True)
18-
with open(credentials_path, "w", encoding="utf-8") as f:
19-
f.write(api_key)
19+
cred_dir = os.path.dirname(credentials_path)
20+
os.makedirs(cred_dir, exist_ok=True)
21+
try:
22+
os.chmod(cred_dir, 0o700)
23+
except OSError:
24+
pass
2025

26+
# Create file with restricted permissions (0600) to prevent leakage
27+
fd = os.open(credentials_path, os.O_WRONLY | os.O_CREAT, 0o600)
28+
with os.fdopen(fd, "w") as f:
29+
f.write(credentials.to_json())
2130

22-
def load_api_key_credentials() -> Optional[str]:
23-
"""Load an API key from the credentials file.
31+
32+
def load_credentials() -> Optional[UserCredentials]:
33+
"""Load user credentials from the credentials file.
2434
2535
Returns:
26-
String. API key if it exists, None otherwise
36+
UserCredentials object if it exists, None otherwise
2737
"""
2838
credentials_path = os.path.expanduser(DEFAULT_CREDENTIALS_PATH)
2939
if os.path.exists(credentials_path):
30-
with open(credentials_path, "r", encoding="utf-8") as f:
31-
return f.read().strip()
40+
try:
41+
with open(credentials_path, "r", encoding="utf-8") as f:
42+
return UserCredentials.from_json(f.read())
43+
except (json.JSONDecodeError, KeyError, ValueError):
44+
# Handle corrupted or old format credentials
45+
return None
3246
return None
47+
48+
49+
def clear_credentials() -> bool:
50+
"""Clear stored credentials.
51+
52+
Returns:
53+
bool: True if credentials were cleared, False if none existed
54+
"""
55+
credentials_path = os.path.expanduser(DEFAULT_CREDENTIALS_PATH)
56+
if os.path.exists(credentials_path):
57+
os.remove(credentials_path)
58+
return True
59+
return False
60+
61+
62+
def load_api_key_credentials() -> Optional[str]:
63+
"""Load an API key from the credentials file (backward compatibility).
64+
65+
Returns:
66+
String. API key if it exists, None otherwise
67+
"""
68+
credentials = load_credentials()
69+
return credentials.api_key if credentials else None

src/mcp_agent/cli/auth/models.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Authentication models for MCP Agent Cloud CLI."""
2+
3+
import json
4+
from dataclasses import dataclass, field
5+
from datetime import datetime
6+
from typing import Optional
7+
8+
9+
@dataclass
10+
class UserCredentials:
11+
"""User authentication credentials and identity information."""
12+
13+
# Authentication
14+
api_key: str = field(repr=False)
15+
token_expires_at: Optional[datetime] = None
16+
17+
# Identity
18+
username: Optional[str] = None
19+
email: Optional[str] = None
20+
21+
@property
22+
def is_token_expired(self) -> bool:
23+
"""Check if the token is expired."""
24+
if not self.token_expires_at:
25+
return False
26+
return datetime.now() > self.token_expires_at
27+
28+
def to_dict(self) -> dict:
29+
"""Convert to dictionary for JSON serialization."""
30+
result = {
31+
"api_key": self.api_key,
32+
"username": self.username,
33+
"email": self.email,
34+
}
35+
36+
if self.token_expires_at:
37+
result["token_expires_at"] = self.token_expires_at.isoformat()
38+
39+
return result
40+
41+
@classmethod
42+
def from_dict(cls, data: dict) -> "UserCredentials":
43+
"""Create from dictionary loaded from JSON."""
44+
45+
token_expires_at = None
46+
if "token_expires_at" in data:
47+
token_expires_at = datetime.fromisoformat(data["token_expires_at"])
48+
49+
return cls(
50+
api_key=data["api_key"],
51+
token_expires_at=token_expires_at,
52+
username=data.get("username"),
53+
email=data.get("email"),
54+
)
55+
56+
def to_json(self) -> str:
57+
"""Convert to JSON string."""
58+
return json.dumps(self.to_dict(), indent=2)
59+
60+
@classmethod
61+
def from_json(cls, json_str: str) -> "UserCredentials":
62+
"""Create from JSON string."""
63+
data = json.loads(json_str)
64+
return cls.from_dict(data)

src/mcp_agent/cli/cloud/commands/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@
66

77
from .configure.main import configure_app
88
from .deploy.main import deploy_config
9-
from .login import login
9+
from .auth import login, logout, whoami
1010

11-
__all__ = ["configure_app", "deploy_config", "login"]
11+
__all__ = ["configure_app", "deploy_config", "login", "logout", "whoami"]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""MCP Agent Cloud authentication commands."""
2+
3+
from .login import login
4+
from .logout import logout
5+
from .whoami import whoami
6+
7+
__all__ = ["login", "logout", "whoami"]
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import asyncio
2+
from typing import Optional
3+
4+
import typer
5+
from rich.prompt import Confirm, Prompt
6+
7+
from mcp_agent.cli.auth import (
8+
UserCredentials,
9+
load_credentials,
10+
save_credentials,
11+
)
12+
from mcp_agent.cli.config import settings
13+
from mcp_agent.cli.core.api_client import APIClient
14+
from mcp_agent.cli.exceptions import CLIError
15+
from mcp_agent.cli.utils.ux import (
16+
print_info,
17+
print_success,
18+
print_warning,
19+
)
20+
21+
from .constants import DEFAULT_API_AUTH_PATH
22+
23+
24+
def _load_user_credentials(api_key: str) -> UserCredentials:
25+
"""Load credentials with user profile data fetched from API.
26+
27+
Args:
28+
api_key: The API key
29+
30+
Returns:
31+
UserCredentials object with profile data if available
32+
"""
33+
34+
async def fetch_profile() -> UserCredentials:
35+
"""Fetch user profile from the API."""
36+
client = APIClient(settings.API_BASE_URL, api_key)
37+
38+
response = await client.post("user/get_profile", {})
39+
user_data = response.json()
40+
41+
user_profile = user_data.get("user", {})
42+
43+
return UserCredentials(
44+
api_key=api_key,
45+
username=user_profile.get("name"),
46+
email=user_profile.get("email"),
47+
)
48+
49+
try:
50+
return asyncio.run(fetch_profile())
51+
except Exception as e:
52+
print_warning(f"Could not fetch user profile: {str(e)}")
53+
# Fallback to minimal credentials
54+
return UserCredentials(api_key=api_key)
55+
56+
57+
def login(
58+
api_key: Optional[str] = typer.Option(
59+
None,
60+
"--api-key",
61+
help="Optionally set an existing API key to use for authentication, bypassing manual login.",
62+
envvar="MCP_API_KEY",
63+
),
64+
no_open: bool = typer.Option(
65+
False,
66+
"--no-open",
67+
help="Don't automatically open browser for authentication.",
68+
),
69+
) -> str:
70+
"""Authenticate to MCP Agent Cloud API.
71+
72+
Direct to the api keys page for obtaining credentials, routing through login.
73+
74+
Args:
75+
api_key: Optionally set an existing API key to use for authentication, bypassing manual login.
76+
no_open: Don't automatically open browser for authentication.
77+
78+
Returns:
79+
API key string. Prints success message if login is successful.
80+
"""
81+
82+
existing_credentials = load_credentials()
83+
if existing_credentials and not existing_credentials.is_token_expired:
84+
if not Confirm.ask("You are already logged in. Do you want to login again?"):
85+
print_info("Using existing credentials.")
86+
return existing_credentials.api_key
87+
88+
if api_key:
89+
print_info("Using provided API key for authentication (MCP_API_KEY).")
90+
if not _is_valid_api_key(api_key):
91+
raise CLIError("Invalid API key provided.")
92+
93+
credentials = _load_user_credentials(api_key)
94+
95+
save_credentials(credentials)
96+
print_success("API key set.")
97+
if credentials.username:
98+
print_info(f"Logged in as: {credentials.username}")
99+
return api_key
100+
101+
base_url = settings.API_BASE_URL
102+
103+
return _handle_browser_auth(base_url, no_open)
104+
105+
106+
def _handle_browser_auth(base_url: str, no_open: bool) -> str:
107+
"""Handle browser-based authentication flow.
108+
109+
Args:
110+
base_url: API base URL
111+
no_open: Whether to skip automatic browser opening
112+
113+
Returns:
114+
API key string
115+
"""
116+
auth_url = f"{base_url}/{DEFAULT_API_AUTH_PATH}"
117+
118+
# TODO: This flow should be updated to OAuth2. Probably need to spin up local server to handle
119+
# the oauth2 callback url.
120+
if not no_open:
121+
print_info("Opening MCP Agent Cloud API login in browser...")
122+
print_info(
123+
f"If the browser doesn't automatically open, you can manually visit: {auth_url}"
124+
)
125+
typer.launch(auth_url)
126+
else:
127+
print_info(f"Please visit: {auth_url}")
128+
129+
return _handle_manual_key_input()
130+
131+
132+
def _handle_manual_key_input() -> str:
133+
"""Handle manual API key input.
134+
135+
Returns:
136+
API key string
137+
"""
138+
input_api_key = Prompt.ask("Please enter your API key :key:")
139+
140+
if not input_api_key:
141+
print_warning("No API key provided.")
142+
raise CLIError("Failed to set valid API key")
143+
144+
if not _is_valid_api_key(input_api_key):
145+
print_warning("Invalid API key provided.")
146+
raise CLIError("Failed to set valid API key")
147+
148+
credentials = _load_user_credentials(input_api_key)
149+
150+
save_credentials(credentials)
151+
print_success("API key set.")
152+
if credentials.username:
153+
print_info(f"Logged in as: {credentials.username}")
154+
155+
return input_api_key
156+
157+
158+
def _is_valid_api_key(api_key: str) -> bool:
159+
"""Validate the API key.
160+
161+
Args:
162+
api_key: The API key to validate.
163+
164+
Returns:
165+
bool: True if the API key is valid, False otherwise.
166+
"""
167+
return api_key.startswith("lm_mcp_api_")
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""MCP Agent Cloud logout command."""
2+
3+
from .main import logout
4+
5+
__all__ = ["logout"]

0 commit comments

Comments
 (0)