Skip to content

Commit 94216ea

Browse files
saqadrirholinshead
andauthored
Add git tag/commit breadcrumbs to deploy flow (#472)
* Add git tag/commit breadcrumbs to deploy flow * Fix: Update Deployment Secrets Processing UX (#467) * WIP starting point * Reorder app creation and prompt to redeploy * Update secrets processing * Track skipped secrets * Improve messaging with path * Fix validation test * Transform tests * Clean up tests * uncomment deploymentMetadata in payload for time being * make get_git_metadata best-effort * formatter * add deployment_metadata back * add app name support, and version check --------- Co-authored-by: Ryan Holinshead <[email protected]>
1 parent 588ffb7 commit 94216ea

File tree

19 files changed

+663
-100
lines changed

19 files changed

+663
-100
lines changed

src/mcp_agent/cli/cloud/commands/app/status/main.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def get_app_status(
3737
None,
3838
"--id",
3939
"-i",
40-
help="ID or server URL of the app or app configuration to get details for.",
40+
help="ID, server URL, or name of the app to get details for.",
4141
),
4242
api_url: Optional[str] = typer.Option(
4343
settings.API_BASE_URL,
@@ -58,7 +58,7 @@ def get_app_status(
5858
if not effective_api_key:
5959
raise CLIError(
6060
"Must be logged in to get app status. Run 'mcp-agent login', set MCP_API_KEY environment variable or specify --api-key option.",
61-
retriable=False
61+
retriable=False,
6262
)
6363

6464
client = MCPAppClient(
@@ -94,7 +94,7 @@ def get_app_status(
9494
except UnauthenticatedError as e:
9595
raise CLIError(
9696
"Invalid API key. Run 'mcp-agent login' or set MCP_API_KEY environment variable with new API key.",
97-
retriable=False
97+
retriable=False,
9898
) from e
9999
except Exception as e:
100100
# Re-raise with more context - top-level CLI handler will show clean message

src/mcp_agent/cli/cloud/commands/auth/whoami/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ def whoami() -> None:
2929
)
3030
if not credentials:
3131
raise CLIError(
32-
"Not authenticated. Set MCP_API_KEY or run 'mcp-agent login'.", exit_code=4, retriable=False
32+
"Not authenticated. Set MCP_API_KEY or run 'mcp-agent login'.",
33+
exit_code=4,
34+
retriable=False,
3335
)
3436

3537
if credentials.is_token_expired:

src/mcp_agent/cli/cloud/commands/deploy/main.py

Lines changed: 85 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
from pathlib import Path
8+
from datetime import datetime, timezone
89
from typing import Optional
910

1011
import typer
@@ -35,6 +36,11 @@
3536
print_info,
3637
print_success,
3738
)
39+
from mcp_agent.cli.utils.git_utils import (
40+
get_git_metadata,
41+
create_git_tag,
42+
sanitize_git_ref_component,
43+
)
3844

3945
from .wrangler_wrapper import wrangler_deploy
4046

@@ -85,6 +91,12 @@ def deploy_config(
8591
help="API key for authentication. Defaults to MCP_API_KEY environment variable.",
8692
envvar=ENV_API_KEY,
8793
),
94+
git_tag: bool = typer.Option(
95+
False,
96+
"--git-tag/--no-git-tag",
97+
help="Create a local git tag for this deploy (if in a git repo)",
98+
envvar="MCP_DEPLOY_GIT_TAG",
99+
),
88100
retry_count: int = typer.Option(
89101
3,
90102
"--retry-count",
@@ -129,12 +141,12 @@ def deploy_config(
129141
if not effective_api_url:
130142
raise CLIError(
131143
"MCP_API_BASE_URL environment variable or --api-url option must be set.",
132-
retriable=False
144+
retriable=False,
133145
)
134146
if not effective_api_key:
135147
raise CLIError(
136148
"Must be logged in to deploy. Run 'mcp-agent login', set MCP_API_KEY environment variable or specify --api-key option.",
137-
retriable=False
149+
retriable=False,
138150
)
139151
print_info(f"Using API at {effective_api_url}")
140152

@@ -178,7 +190,7 @@ def deploy_config(
178190
except UnauthenticatedError as e:
179191
raise CLIError(
180192
"Invalid API key for deployment. Run 'mcp-agent login' or set MCP_API_KEY environment variable with new API key.",
181-
retriable=False
193+
retriable=False,
182194
) from e
183195
except Exception as e:
184196
raise CLIError(f"Error checking or creating app: {str(e)}") from e
@@ -249,13 +261,36 @@ def deploy_config(
249261
)
250262
)
251263

252-
app = run_async(_deploy_with_retry(
253-
app_id=app_id,
254-
api_key=effective_api_key,
255-
project_dir=config_dir,
256-
mcp_app_client=mcp_app_client,
257-
retry_count=retry_count,
258-
))
264+
# Optionally create a local git tag as a breadcrumb of this deployment
265+
if git_tag:
266+
git_meta = get_git_metadata(config_dir)
267+
if git_meta:
268+
# Sanitize app name for git tag safety
269+
safe_name = sanitize_git_ref_component(app_name)
270+
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
271+
tag_name = f"mcp-deploy/{safe_name}/{ts}-{git_meta.short_sha}"
272+
msg = (
273+
f"MCP Agent deploy for app '{app_name}' (id {app_id})\n"
274+
f"Commit: {git_meta.commit_sha}\n"
275+
f"Branch: {git_meta.branch or ''}\n"
276+
f"Dirty: {git_meta.dirty}"
277+
)
278+
if create_git_tag(config_dir, tag_name, msg):
279+
print_success(f"Created local git tag: {tag_name}")
280+
else:
281+
print_info("Skipping git tag (not a repo or tag failed)")
282+
else:
283+
print_info("Skipping git tag (not a git repository)")
284+
285+
app = run_async(
286+
_deploy_with_retry(
287+
app_id=app_id,
288+
api_key=effective_api_key,
289+
project_dir=config_dir,
290+
mcp_app_client=mcp_app_client,
291+
retry_count=retry_count,
292+
)
293+
)
259294

260295
print_info(f"App ID: {app_id}")
261296
if app.appServerInfo:
@@ -318,13 +353,45 @@ async def _perform_api_deployment():
318353
SpinnerColumn(spinner_name="arrow3"),
319354
TextColumn("[progress.description]{task.description}"),
320355
) as progress:
321-
deploy_task = progress.add_task(f"Deploying MCP App bundle{attempt_suffix}...", total=None)
356+
deploy_task = progress.add_task(
357+
f"Deploying MCP App bundle{attempt_suffix}...", total=None
358+
)
322359
try:
323-
app = await mcp_app_client.deploy_app(app_id=app_id)
324-
progress.update(deploy_task, description=f"✅ MCP App deployed successfully{attempt_suffix}!")
360+
# Optionally include minimal metadata (git only to avoid heavy scans)
361+
metadata = None
362+
gm = get_git_metadata(project_dir)
363+
if gm:
364+
metadata = {
365+
"source": "git",
366+
"commit": gm.commit_sha,
367+
"short": gm.short_sha,
368+
"branch": gm.branch,
369+
"dirty": gm.dirty,
370+
"tag": gm.tag,
371+
"message": gm.commit_message,
372+
}
373+
374+
try:
375+
app = await mcp_app_client.deploy_app(
376+
app_id=app_id, deployment_metadata=metadata
377+
)
378+
except Exception as e:
379+
# Fallback: if API rejects deploymentMetadata, retry once without it
380+
try:
381+
app = await mcp_app_client.deploy_app(
382+
app_id=app_id, deployment_metadata=None
383+
)
384+
except Exception:
385+
raise e
386+
progress.update(
387+
deploy_task,
388+
description=f"✅ MCP App deployed successfully{attempt_suffix}!",
389+
)
325390
return app
326391
except Exception:
327-
progress.update(deploy_task, description=f"❌ Deployment failed{attempt_suffix}")
392+
progress.update(
393+
deploy_task, description=f"❌ Deployment failed{attempt_suffix}"
394+
)
328395
raise
329396

330397
if retry_count > 1:
@@ -341,7 +408,9 @@ async def _perform_api_deployment():
341408
except RetryError as e:
342409
attempts_text = "attempts" if retry_count > 1 else "attempt"
343410
print_error(f"Deployment failed after {retry_count} {attempts_text}")
344-
raise CLIError(f"Deployment failed after {retry_count} {attempts_text}. Last error: {e.original_error}") from e.original_error
411+
raise CLIError(
412+
f"Deployment failed after {retry_count} {attempts_text}. Last error: {e.original_error}"
413+
) from e.original_error
345414

346415

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

364433
secrets_file: Optional[Path] = None

src/mcp_agent/cli/cloud/commands/deploy/wrangler_wrapper.py

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@
55
import tempfile
66
import textwrap
77
from pathlib import Path
8+
import json
89

910
from rich.progress import Progress, SpinnerColumn, TextColumn
1011

1112
from mcp_agent.cli.config import settings
12-
from mcp_agent.cli.core.constants import (
13-
MCP_SECRETS_FILENAME,
13+
from mcp_agent.cli.core.constants import MCP_SECRETS_FILENAME
14+
from mcp_agent.cli.utils.ux import console, print_error, print_warning, print_info
15+
from mcp_agent.cli.utils.git_utils import (
16+
get_git_metadata,
17+
compute_directory_fingerprint,
18+
utc_iso_now,
1419
)
15-
from mcp_agent.cli.utils.ux import console, print_error, print_warning
1620

1721
from .constants import (
1822
CLOUDFLARE_ACCOUNT_ID,
@@ -197,13 +201,96 @@ def ignore_patterns(_path, names):
197201
# Rename in place
198202
file_path.rename(py_path)
199203

200-
# Create temporary wrangler.toml
204+
# Collect deployment metadata (git if available, else workspace hash)
205+
git_meta = get_git_metadata(project_dir)
206+
deploy_source = "git" if git_meta else "workspace"
207+
meta_vars = {
208+
"MCP_DEPLOY_SOURCE": deploy_source,
209+
"MCP_DEPLOY_TIME_UTC": utc_iso_now(),
210+
}
211+
if git_meta:
212+
meta_vars.update(
213+
{
214+
"MCP_DEPLOY_GIT_COMMIT": git_meta.commit_sha,
215+
"MCP_DEPLOY_GIT_SHORT": git_meta.short_sha,
216+
"MCP_DEPLOY_GIT_BRANCH": git_meta.branch or "",
217+
"MCP_DEPLOY_GIT_DIRTY": "true" if git_meta.dirty else "false",
218+
}
219+
)
220+
# Friendly console hint
221+
dirty_mark = "*" if git_meta.dirty else ""
222+
print_info(
223+
f"Deploying from git commit {git_meta.short_sha}{dirty_mark} on branch {git_meta.branch or '?'}"
224+
)
225+
else:
226+
# Compute a cheap fingerprint (metadata-based) of the prepared project
227+
bundle_hash = compute_directory_fingerprint(
228+
temp_project_dir,
229+
ignore_names={
230+
".git",
231+
"logs",
232+
"__pycache__",
233+
"node_modules",
234+
"venv",
235+
MCP_SECRETS_FILENAME,
236+
},
237+
)
238+
meta_vars.update({"MCP_DEPLOY_WORKSPACE_HASH": bundle_hash})
239+
print_info(f"Deploying from non-git workspace (hash {bundle_hash[:12]}…)")
240+
241+
# Write a breadcrumb file into the project so it ships with the bundle.
242+
# Use a Python file for guaranteed inclusion without renaming.
243+
breadcrumb = {
244+
"version": 1,
245+
"app_id": app_id,
246+
"deploy_time_utc": meta_vars["MCP_DEPLOY_TIME_UTC"],
247+
"source": meta_vars["MCP_DEPLOY_SOURCE"],
248+
}
249+
if git_meta:
250+
breadcrumb.update(
251+
{
252+
"git": {
253+
"commit": git_meta.commit_sha,
254+
"short": git_meta.short_sha,
255+
"branch": git_meta.branch,
256+
"dirty": git_meta.dirty,
257+
"tag": git_meta.tag,
258+
"message": git_meta.commit_message,
259+
}
260+
}
261+
)
262+
else:
263+
breadcrumb.update(
264+
{"workspace_fingerprint": meta_vars["MCP_DEPLOY_WORKSPACE_HASH"]}
265+
)
266+
267+
breadcrumb_py = textwrap.dedent(
268+
"""
269+
# Auto-generated by mcp-agent deploy. Do not edit.
270+
# Contains deployment metadata for traceability.
271+
import json as _json
272+
BREADCRUMB = %s
273+
BREADCRUMB_JSON = _json.dumps(BREADCRUMB, separators=(",", ":"))
274+
__all__ = ["BREADCRUMB", "BREADCRUMB_JSON"]
275+
"""
276+
).strip() % (json.dumps(breadcrumb, indent=2))
277+
278+
(temp_project_dir / "mcp_deploy_breadcrumb.py").write_text(breadcrumb_py)
279+
280+
# Create temporary wrangler.toml with [vars] carrying deploy metadata
281+
# Use TOML strings and keep values simple/escaped; also include a compact JSON blob
282+
meta_json = json.dumps(meta_vars, separators=(",", ":"))
283+
vars_lines = ["[vars]"] + [f'{k} = "{v}"' for k, v in meta_vars.items()]
284+
vars_lines.append(f'MCP_DEPLOY_META = """{meta_json}"""')
285+
201286
wrangler_toml_content = textwrap.dedent(
202287
f"""
203288
name = "{app_id}"
204289
main = "{main_py}"
205290
compatibility_flags = ["python_workers"]
206291
compatibility_date = "2025-06-26"
292+
293+
{os.linesep.join(vars_lines)}
207294
"""
208295
).strip()
209296

src/mcp_agent/cli/cloud/commands/servers/delete/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
@handle_server_api_errors
1818
def delete_server(
1919
id_or_url: str = typer.Argument(
20-
..., help="Server ID or app configuration ID to delete"
20+
..., help="App ID, server URL, or app name to delete"
2121
),
2222
force: bool = typer.Option(
2323
False, "--force", "-f", help="Force deletion without confirmation prompt"

src/mcp_agent/cli/cloud/commands/servers/describe/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
@handle_server_api_errors
2121
def describe_server(
2222
id_or_url: str = typer.Argument(
23-
..., help="Server ID or app configuration ID to describe"
23+
..., help="App ID, server URL, or app name to describe"
2424
),
2525
format: Optional[str] = typer.Option(
2626
"text", "--format", help="Output format (text|json|yaml)"

0 commit comments

Comments
 (0)