Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions examples/workflows/workflow_evaluator_optimizer/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,22 @@
# The cycle continues until the letter meets a predefined quality standard.
app = MCPApp(name="cover_letter_writer")

@app.async_tool(name="cover_letter_writer_tool",
description="This tool implements an evaluator-optimizer workflow for generating "
"high-quality cover letters. It takes job postings, candidate details, "
"and company information as input, then iteratively generates and refines "
"cover letters until they meet excellent quality standards through "
"automated evaluation and feedback.")

@app.async_tool(
name="cover_letter_writer_tool",
description="This tool implements an evaluator-optimizer workflow for generating "
"high-quality cover letters. It takes job postings, candidate details, "
"and company information as input, then iteratively generates and refines "
"cover letters until they meet excellent quality standards through "
"automated evaluation and feedback.",
)
async def example_usage(
job_posting: str = "Software Engineer at LastMile AI. Responsibilities include developing AI systems, "
"collaborating with cross-functional teams, and enhancing scalability. Skills required: "
"Python, distributed systems, and machine learning.",
"collaborating with cross-functional teams, and enhancing scalability. Skills required: "
"Python, distributed systems, and machine learning.",
candidate_details: str = "Alex Johnson, 3 years in machine learning, contributor to open-source AI projects, "
"proficient in Python and TensorFlow. Motivated by building scalable AI systems to solve real-world problems.",
company_information: str = "Look up from the LastMile AI About page: https://lastmileai.dev/about"
"proficient in Python and TensorFlow. Motivated by building scalable AI systems to solve real-world problems.",
company_information: str = "Look up from the LastMile AI About page: https://lastmileai.dev/about",
):
async with app.run() as cover_letter_app:
context = cover_letter_app.context
Expand Down
10 changes: 9 additions & 1 deletion src/mcp_agent/cli/auth/constants.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
"""Constants for the MCP Agent auth utilities."""

# Default values
import os

# Default credentials location (legacy)
DEFAULT_CREDENTIALS_PATH = "~/.mcp-agent/credentials.json"

# Additional locations to search (XDG-compatible and documented path)
XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
ALTERNATE_CREDENTIALS_PATHS = [
os.path.join(XDG_CONFIG_HOME, "mcp-agent", "credentials.json"),
]
82 changes: 64 additions & 18 deletions src/mcp_agent/cli/auth/main.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import json
import os
import tempfile
from typing import Optional

from .constants import DEFAULT_CREDENTIALS_PATH
from .constants import DEFAULT_CREDENTIALS_PATH, ALTERNATE_CREDENTIALS_PATHS
from mcp_agent.cli.utils.ux import print_warning
from .models import UserCredentials


Expand All @@ -23,10 +25,35 @@ def save_credentials(credentials: UserCredentials) -> None:
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())
# Write atomically to avoid partial or trailing content issues
# Use a temp file in the same directory, then replace
tmp_fd, tmp_path = tempfile.mkstemp(
prefix=".credentials.json.", dir=cred_dir, text=True
)
try:
with os.fdopen(tmp_fd, "w") as f:
f.write(credentials.to_json())
f.flush()
os.fsync(f.fileno())
# Ensure restricted permissions (0600)
try:
os.chmod(tmp_path, 0o600)
except OSError:
pass
# Atomic replace
os.replace(tmp_path, credentials_path)
# Ensure final file perms in case replace inherited different mode
try:
os.chmod(credentials_path, 0o600)
except OSError:
pass
finally:
# Clean up temp if replace failed
try:
if os.path.exists(tmp_path):
os.remove(tmp_path)
except OSError:
pass


def load_credentials() -> Optional[UserCredentials]:
Expand All @@ -35,14 +62,26 @@ def load_credentials() -> Optional[UserCredentials]:
Returns:
UserCredentials object if it exists, None otherwise
"""
credentials_path = os.path.expanduser(DEFAULT_CREDENTIALS_PATH)
if os.path.exists(credentials_path):
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
# Try primary location
primary_path = os.path.expanduser(DEFAULT_CREDENTIALS_PATH)
paths_to_try = [primary_path] + [
os.path.expanduser(p) for p in ALTERNATE_CREDENTIALS_PATHS
]

for path in paths_to_try:
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as f:
return UserCredentials.from_json(f.read())
except (json.JSONDecodeError, KeyError, ValueError):
# Corrupted credentials; warn and continue to other locations
try:
print_warning(
f"Detected corrupted credentials file at {path}. Please run 'mcp-agent login' again to re-authenticate."
)
except Exception:
pass
continue
return None


Expand All @@ -52,11 +91,18 @@ def clear_credentials() -> bool:
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
removed = False
paths = [os.path.expanduser(DEFAULT_CREDENTIALS_PATH)] + [
os.path.expanduser(p) for p in ALTERNATE_CREDENTIALS_PATHS
]
for path in paths:
if os.path.exists(path):
try:
os.remove(path)
removed = True
except OSError:
pass
return removed


def load_api_key_credentials() -> Optional[str]:
Expand Down
20 changes: 15 additions & 5 deletions src/mcp_agent/cli/cloud/commands/auth/whoami/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
from rich.panel import Panel
from rich.table import Table

from mcp_agent.cli.auth import load_credentials
from mcp_agent.cli.auth import load_credentials, UserCredentials
from mcp_agent.cli.config import settings as _settings
from mcp_agent.cli.exceptions import CLIError


Expand All @@ -13,11 +14,22 @@ def whoami() -> None:

Shows the authenticated user information and organization memberships.
"""
console = Console()
credentials = load_credentials()

# If no stored credentials, allow environment variable key
if not credentials and _settings.API_KEY:
credentials = UserCredentials(api_key=_settings.API_KEY)
# Print a brief note that this is env-based auth
console.print(
Panel(
"Using MCP_API_KEY environment variable for authentication.",
title="Auth Source",
border_style="green",
)
)
if not credentials:
raise CLIError(
"Not logged in. Use 'mcp-agent login' to authenticate.", exit_code=4
"Not authenticated. Set MCP_API_KEY or run 'mcp-agent login'.", exit_code=4
)

if credentials.is_token_expired:
Expand All @@ -26,8 +38,6 @@ def whoami() -> None:
exit_code=4,
)

console = Console()

user_table = Table(show_header=False, box=None)
user_table.add_column("Field", style="bold")
user_table.add_column("Value")
Expand Down
8 changes: 7 additions & 1 deletion src/mcp_agent/cli/cloud/commands/logger/tail/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from mcp_agent.cli.exceptions import CLIError
from mcp_agent.cli.auth import load_credentials, UserCredentials
from mcp_agent.cli.config import settings as _settings
from mcp_agent.cli.cloud.commands.utils import (
setup_authenticated_client,
resolve_server,
Expand Down Expand Up @@ -103,8 +104,13 @@ def tail_logs(
"""

credentials = load_credentials()
# Prefer environment variable if present
if not credentials and _settings.API_KEY:
credentials = UserCredentials(api_key=_settings.API_KEY)
if not credentials:
print_error("Not authenticated. Run 'mcp-agent login' first.")
print_error(
"Not authenticated. Set MCP_API_KEY environment variable or run 'mcp-agent login'."
)
raise typer.Exit(4)

# Validate conflicting options
Expand Down
26 changes: 20 additions & 6 deletions src/mcp_agent/cli/cloud/commands/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import Union

from mcp_agent.cli.auth import load_api_key_credentials
from mcp_agent.cli.config import settings
from mcp_agent.cli.core.api_client import UnauthenticatedError
from mcp_agent.cli.core.constants import DEFAULT_API_BASE_URL
from mcp_agent.cli.core.utils import run_async
Expand All @@ -24,10 +25,13 @@ def setup_authenticated_client() -> MCPAppClient:
Raises:
CLIError: If authentication fails
"""
effective_api_key = load_api_key_credentials()
# Prefer environment-provided key, then fall back to stored credentials
effective_api_key = settings.API_KEY or load_api_key_credentials()

if not effective_api_key:
raise CLIError("Must be logged in to access servers. Run 'mcp-agent login'.")
raise CLIError(
"Must be authenticated. Set MCP_API_KEY or run 'mcp-agent login'."
)

return MCPAppClient(api_url=DEFAULT_API_BASE_URL, api_key=effective_api_key)

Expand All @@ -48,10 +52,10 @@ def validate_output_format(format: str) -> None:
)


def resolve_server(
async def resolve_server_async(
client: MCPAppClient, id_or_url: str
) -> Union[MCPApp, MCPAppConfiguration]:
"""Resolve server from ID or URL.
"""Resolve server from ID or URL (async).

Args:
client: Authenticated MCP App client
Expand All @@ -64,12 +68,22 @@ def resolve_server(
CLIError: If server resolution fails
"""
try:
return run_async(client.get_app_or_config(id_or_url))

return await client.get_app_or_config(id_or_url)
except Exception as e:
raise CLIError(f"Failed to resolve server '{id_or_url}': {str(e)}") from e


def resolve_server(
client: MCPAppClient, id_or_url: str
) -> Union[MCPApp, MCPAppConfiguration]:
"""Resolve server from ID or URL (sync wrapper).

Safe for synchronous CLI contexts. For async code paths, prefer
using resolve_server_async to avoid nested event loops.
"""
return run_async(resolve_server_async(client, id_or_url))


def handle_server_api_errors(func):
"""Decorator to handle common API errors for server commands.

Expand Down
8 changes: 5 additions & 3 deletions src/mcp_agent/cli/cloud/commands/workflows/cancel/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from ...utils import (
setup_authenticated_client,
handle_server_api_errors,
resolve_server,
resolve_server_async,
)


Expand All @@ -24,7 +24,7 @@ async def _cancel_workflow_async(
server_url = server_id_or_url
else:
client = setup_authenticated_client()
server = resolve_server(client, server_id_or_url)
server = await resolve_server_async(client, server_id_or_url)

if hasattr(server, "appServerInfo") and server.appServerInfo:
server_url = server.appServerInfo.serverUrl
Expand All @@ -36,7 +36,9 @@ async def _cancel_workflow_async(
if not server_url:
raise CLIError(f"No server URL found for server '{server_id_or_url}'")

effective_api_key = load_api_key_credentials()
from mcp_agent.cli.config import settings as _settings

effective_api_key = _settings.API_KEY or load_api_key_credentials()

if not effective_api_key:
raise CLIError("Must be logged in to access server. Run 'mcp-agent login'.")
Expand Down
8 changes: 5 additions & 3 deletions src/mcp_agent/cli/cloud/commands/workflows/describe/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from ...utils import (
handle_server_api_errors,
resolve_server,
resolve_server_async,
setup_authenticated_client,
)

Expand All @@ -29,7 +29,7 @@ async def _describe_workflow_async(
server_url = server_id_or_url
else:
client = setup_authenticated_client()
server = resolve_server(client, server_id_or_url)
server = await resolve_server_async(client, server_id_or_url)

if hasattr(server, "appServerInfo") and server.appServerInfo:
server_url = server.appServerInfo.serverUrl
Expand All @@ -41,7 +41,9 @@ async def _describe_workflow_async(
if not server_url:
raise CLIError(f"No server URL found for server '{server_id_or_url}'")

effective_api_key = load_api_key_credentials()
from mcp_agent.cli.config import settings as _settings

effective_api_key = _settings.API_KEY or load_api_key_credentials()

if not effective_api_key:
raise CLIError("Must be logged in to access server. Run 'mcp-agent login'.")
Expand Down
8 changes: 5 additions & 3 deletions src/mcp_agent/cli/cloud/commands/workflows/list/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from mcp_agent.cli.utils.ux import console, print_error
from ...utils import (
setup_authenticated_client,
resolve_server,
resolve_server_async,
handle_server_api_errors,
validate_output_format,
)
Expand All @@ -26,7 +26,7 @@ async def _list_workflows_async(server_id_or_url: str, format: str = "text") ->
server_url = server_id_or_url
else:
client = setup_authenticated_client()
server = resolve_server(client, server_id_or_url)
server = await resolve_server_async(client, server_id_or_url)

if hasattr(server, "appServerInfo") and server.appServerInfo:
server_url = server.appServerInfo.serverUrl
Expand All @@ -38,7 +38,9 @@ async def _list_workflows_async(server_id_or_url: str, format: str = "text") ->
if not server_url:
raise CLIError(f"No server URL found for server '{server_id_or_url}'")

effective_api_key = load_api_key_credentials()
from mcp_agent.cli.config import settings as _settings

effective_api_key = _settings.API_KEY or load_api_key_credentials()

if not effective_api_key:
raise CLIError("Must be logged in to access server. Run 'mcp-agent login'.")
Expand Down
Loading
Loading