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
6 changes: 3 additions & 3 deletions src/mcp_agent/cli/cloud/commands/app/status/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def get_app_status(
None,
"--id",
"-i",
help="ID or server URL of the app or app configuration to get details for.",
help="ID, server URL, or name of the app to get details for.",
),
api_url: Optional[str] = typer.Option(
settings.API_BASE_URL,
Expand All @@ -58,7 +58,7 @@ def get_app_status(
if not effective_api_key:
raise CLIError(
"Must be logged in to get app status. Run 'mcp-agent login', set MCP_API_KEY environment variable or specify --api-key option.",
retriable=False
retriable=False,
)

client = MCPAppClient(
Expand Down Expand Up @@ -94,7 +94,7 @@ def get_app_status(
except UnauthenticatedError as e:
raise CLIError(
"Invalid API key. Run 'mcp-agent login' or set MCP_API_KEY environment variable with new API key.",
retriable=False
retriable=False,
) from e
except Exception as e:
# Re-raise with more context - top-level CLI handler will show clean message
Expand Down
4 changes: 3 additions & 1 deletion src/mcp_agent/cli/cloud/commands/auth/whoami/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ def whoami() -> None:
)
if not credentials:
raise CLIError(
"Not authenticated. Set MCP_API_KEY or run 'mcp-agent login'.", exit_code=4, retriable=False
"Not authenticated. Set MCP_API_KEY or run 'mcp-agent login'.",
exit_code=4,
retriable=False,
)

if credentials.is_token_expired:
Expand Down
101 changes: 85 additions & 16 deletions src/mcp_agent/cli/cloud/commands/deploy/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

from pathlib import Path
from datetime import datetime, timezone
from typing import Optional

import typer
Expand Down Expand Up @@ -35,6 +36,11 @@
print_info,
print_success,
)
from mcp_agent.cli.utils.git_utils import (
get_git_metadata,
create_git_tag,
sanitize_git_ref_component,
)

from .wrangler_wrapper import wrangler_deploy

Expand Down Expand Up @@ -85,6 +91,12 @@ def deploy_config(
help="API key for authentication. Defaults to MCP_API_KEY environment variable.",
envvar=ENV_API_KEY,
),
git_tag: bool = typer.Option(
False,
"--git-tag/--no-git-tag",
help="Create a local git tag for this deploy (if in a git repo)",
envvar="MCP_DEPLOY_GIT_TAG",
),
retry_count: int = typer.Option(
3,
"--retry-count",
Expand Down Expand Up @@ -129,12 +141,12 @@ def deploy_config(
if not effective_api_url:
raise CLIError(
"MCP_API_BASE_URL environment variable or --api-url option must be set.",
retriable=False
retriable=False,
)
if not effective_api_key:
raise CLIError(
"Must be logged in to deploy. Run 'mcp-agent login', set MCP_API_KEY environment variable or specify --api-key option.",
retriable=False
retriable=False,
)
print_info(f"Using API at {effective_api_url}")

Expand Down Expand Up @@ -178,7 +190,7 @@ def deploy_config(
except UnauthenticatedError as e:
raise CLIError(
"Invalid API key for deployment. Run 'mcp-agent login' or set MCP_API_KEY environment variable with new API key.",
retriable=False
retriable=False,
) from e
except Exception as e:
raise CLIError(f"Error checking or creating app: {str(e)}") from e
Expand Down Expand Up @@ -249,13 +261,36 @@ def deploy_config(
)
)

app = run_async(_deploy_with_retry(
app_id=app_id,
api_key=effective_api_key,
project_dir=config_dir,
mcp_app_client=mcp_app_client,
retry_count=retry_count,
))
# Optionally create a local git tag as a breadcrumb of this deployment
if git_tag:
git_meta = get_git_metadata(config_dir)
if git_meta:
# Sanitize app name for git tag safety
safe_name = sanitize_git_ref_component(app_name)
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
tag_name = f"mcp-deploy/{safe_name}/{ts}-{git_meta.short_sha}"
msg = (
f"MCP Agent deploy for app '{app_name}' (id {app_id})\n"
f"Commit: {git_meta.commit_sha}\n"
f"Branch: {git_meta.branch or ''}\n"
f"Dirty: {git_meta.dirty}"
)
if create_git_tag(config_dir, tag_name, msg):
print_success(f"Created local git tag: {tag_name}")
else:
print_info("Skipping git tag (not a repo or tag failed)")
else:
print_info("Skipping git tag (not a git repository)")

app = run_async(
_deploy_with_retry(
app_id=app_id,
api_key=effective_api_key,
project_dir=config_dir,
mcp_app_client=mcp_app_client,
retry_count=retry_count,
)
)

print_info(f"App ID: {app_id}")
if app.appServerInfo:
Expand Down Expand Up @@ -318,13 +353,45 @@ async def _perform_api_deployment():
SpinnerColumn(spinner_name="arrow3"),
TextColumn("[progress.description]{task.description}"),
) as progress:
deploy_task = progress.add_task(f"Deploying MCP App bundle{attempt_suffix}...", total=None)
deploy_task = progress.add_task(
f"Deploying MCP App bundle{attempt_suffix}...", total=None
)
try:
app = await mcp_app_client.deploy_app(app_id=app_id)
progress.update(deploy_task, description=f"✅ MCP App deployed successfully{attempt_suffix}!")
# Optionally include minimal metadata (git only to avoid heavy scans)
metadata = None
gm = get_git_metadata(project_dir)
if gm:
metadata = {
"source": "git",
"commit": gm.commit_sha,
"short": gm.short_sha,
"branch": gm.branch,
"dirty": gm.dirty,
"tag": gm.tag,
"message": gm.commit_message,
}

try:
app = await mcp_app_client.deploy_app(
app_id=app_id, deployment_metadata=metadata
)
except Exception as e:
# Fallback: if API rejects deploymentMetadata, retry once without it
try:
app = await mcp_app_client.deploy_app(
app_id=app_id, deployment_metadata=None
)
except Exception:
raise e
progress.update(
deploy_task,
description=f"✅ MCP App deployed successfully{attempt_suffix}!",
)
return app
except Exception:
progress.update(deploy_task, description=f"❌ Deployment failed{attempt_suffix}")
progress.update(
deploy_task, description=f"❌ Deployment failed{attempt_suffix}"
)
raise

if retry_count > 1:
Expand All @@ -341,7 +408,9 @@ async def _perform_api_deployment():
except RetryError as e:
attempts_text = "attempts" if retry_count > 1 else "attempt"
print_error(f"Deployment failed after {retry_count} {attempts_text}")
raise CLIError(f"Deployment failed after {retry_count} {attempts_text}. Last error: {e.original_error}") from e.original_error
raise CLIError(
f"Deployment failed after {retry_count} {attempts_text}. Last error: {e.original_error}"
) from e.original_error


def get_config_files(config_dir: Path) -> tuple[Path, Optional[Path], Optional[Path]]:
Expand All @@ -358,7 +427,7 @@ def get_config_files(config_dir: Path) -> tuple[Path, Optional[Path], Optional[P
if not config_file.exists():
raise CLIError(
f"Configuration file '{MCP_CONFIG_FILENAME}' not found in {config_dir}",
retriable=False
retriable=False,
)

secrets_file: Optional[Path] = None
Expand Down
95 changes: 91 additions & 4 deletions src/mcp_agent/cli/cloud/commands/deploy/wrangler_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@
import tempfile
import textwrap
from pathlib import Path
import json

from rich.progress import Progress, SpinnerColumn, TextColumn

from mcp_agent.cli.config import settings
from mcp_agent.cli.core.constants import (
MCP_SECRETS_FILENAME,
from mcp_agent.cli.core.constants import MCP_SECRETS_FILENAME
from mcp_agent.cli.utils.ux import console, print_error, print_warning, print_info
from mcp_agent.cli.utils.git_utils import (
get_git_metadata,
compute_directory_fingerprint,
utc_iso_now,
)
from mcp_agent.cli.utils.ux import console, print_error, print_warning

from .constants import (
CLOUDFLARE_ACCOUNT_ID,
Expand Down Expand Up @@ -197,13 +201,96 @@ def ignore_patterns(_path, names):
# Rename in place
file_path.rename(py_path)

# Create temporary wrangler.toml
# Collect deployment metadata (git if available, else workspace hash)
git_meta = get_git_metadata(project_dir)
deploy_source = "git" if git_meta else "workspace"
meta_vars = {
"MCP_DEPLOY_SOURCE": deploy_source,
"MCP_DEPLOY_TIME_UTC": utc_iso_now(),
}
if git_meta:
meta_vars.update(
{
"MCP_DEPLOY_GIT_COMMIT": git_meta.commit_sha,
"MCP_DEPLOY_GIT_SHORT": git_meta.short_sha,
"MCP_DEPLOY_GIT_BRANCH": git_meta.branch or "",
"MCP_DEPLOY_GIT_DIRTY": "true" if git_meta.dirty else "false",
}
)
# Friendly console hint
dirty_mark = "*" if git_meta.dirty else ""
print_info(
f"Deploying from git commit {git_meta.short_sha}{dirty_mark} on branch {git_meta.branch or '?'}"
)
else:
# Compute a cheap fingerprint (metadata-based) of the prepared project
bundle_hash = compute_directory_fingerprint(
temp_project_dir,
ignore_names={
".git",
"logs",
"__pycache__",
"node_modules",
"venv",
MCP_SECRETS_FILENAME,
},
)
meta_vars.update({"MCP_DEPLOY_WORKSPACE_HASH": bundle_hash})
print_info(f"Deploying from non-git workspace (hash {bundle_hash[:12]}…)")

# Write a breadcrumb file into the project so it ships with the bundle.
# Use a Python file for guaranteed inclusion without renaming.
breadcrumb = {
"version": 1,
"app_id": app_id,
"deploy_time_utc": meta_vars["MCP_DEPLOY_TIME_UTC"],
"source": meta_vars["MCP_DEPLOY_SOURCE"],
}
if git_meta:
breadcrumb.update(
{
"git": {
"commit": git_meta.commit_sha,
"short": git_meta.short_sha,
"branch": git_meta.branch,
"dirty": git_meta.dirty,
"tag": git_meta.tag,
"message": git_meta.commit_message,
}
}
)
else:
breadcrumb.update(
{"workspace_fingerprint": meta_vars["MCP_DEPLOY_WORKSPACE_HASH"]}
)

breadcrumb_py = textwrap.dedent(
"""
# Auto-generated by mcp-agent deploy. Do not edit.
# Contains deployment metadata for traceability.
import json as _json
BREADCRUMB = %s
BREADCRUMB_JSON = _json.dumps(BREADCRUMB, separators=(",", ":"))
__all__ = ["BREADCRUMB", "BREADCRUMB_JSON"]
"""
).strip() % (json.dumps(breadcrumb, indent=2))

(temp_project_dir / "mcp_deploy_breadcrumb.py").write_text(breadcrumb_py)

# Create temporary wrangler.toml with [vars] carrying deploy metadata
# Use TOML strings and keep values simple/escaped; also include a compact JSON blob
meta_json = json.dumps(meta_vars, separators=(",", ":"))
vars_lines = ["[vars]"] + [f'{k} = "{v}"' for k, v in meta_vars.items()]
vars_lines.append(f'MCP_DEPLOY_META = """{meta_json}"""')

wrangler_toml_content = textwrap.dedent(
f"""
name = "{app_id}"
main = "{main_py}"
compatibility_flags = ["python_workers"]
compatibility_date = "2025-06-26"

{os.linesep.join(vars_lines)}
"""
).strip()

Expand Down
2 changes: 1 addition & 1 deletion src/mcp_agent/cli/cloud/commands/servers/delete/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
@handle_server_api_errors
def delete_server(
id_or_url: str = typer.Argument(
..., help="Server ID or app configuration ID to delete"
..., help="App ID, server URL, or app name to delete"
),
force: bool = typer.Option(
False, "--force", "-f", help="Force deletion without confirmation prompt"
Expand Down
2 changes: 1 addition & 1 deletion src/mcp_agent/cli/cloud/commands/servers/describe/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
@handle_server_api_errors
def describe_server(
id_or_url: str = typer.Argument(
..., help="Server ID or app configuration ID to describe"
..., help="App ID, server URL, or app name to describe"
),
format: Optional[str] = typer.Option(
"text", "--format", help="Output format (text|json|yaml)"
Expand Down
Loading
Loading