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
3 changes: 2 additions & 1 deletion src/mcp_agent/cli/cloud/commands/apps/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""MCP Agent Cloud apps command."""

from .list import list_apps
from .update import update_app

__all__ = ["list_apps"]
__all__ = ["list_apps", "update_app"]
5 changes: 5 additions & 0 deletions src/mcp_agent/cli/cloud/commands/apps/update/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Update MCP apps command module exports."""

from .main import update_app

__all__ = ["update_app"]
126 changes: 126 additions & 0 deletions src/mcp_agent/cli/cloud/commands/apps/update/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
from typing import Optional

import typer

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,
ENV_API_BASE_URL,
ENV_API_KEY,
)
from mcp_agent.cli.core.utils import run_async
from mcp_agent.cli.exceptions import CLIError
from mcp_agent.cli.mcp_app.api_client import MCPApp, MCPAppClient, MCPAppConfiguration
from mcp_agent.cli.utils.ux import print_info, print_success
from ...utils import resolve_server


def update_app(
app_id_or_name: str = typer.Argument(
...,
help="ID, server URL, configuration ID, or name of the app to update.",
show_default=False,
),
name: Optional[str] = typer.Option(
None,
"--name",
"-n",
help="Set a new name for the app.",
),
description: Optional[str] = typer.Option(
None,
"--description",
"-d",
help="Set a new description for the app. Use an empty string to clear it.",
),
unauthenticated_access: Optional[bool] = typer.Option(
None,
"--no-auth/--auth",
help=(
"Allow unauthenticated access to the app server (--no-auth) or require authentication (--auth). "
"If omitted, the current setting is preserved."
),
),
api_url: Optional[str] = typer.Option(
settings.API_BASE_URL,
"--api-url",
help="API base URL. Defaults to MCP_API_BASE_URL environment variable.",
envvar=ENV_API_BASE_URL,
),
api_key: Optional[str] = typer.Option(
settings.API_KEY,
"--api-key",
help="API key for authentication. Defaults to MCP_API_KEY environment variable.",
envvar=ENV_API_KEY,
),
) -> None:
"""Update metadata or authentication settings for a deployed MCP App."""
if name is None and description is None and unauthenticated_access is None:
raise CLIError(
"Specify at least one of --name, --description, or --no-auth/--auth to update.",
retriable=False,
)

effective_api_key = api_key or settings.API_KEY or load_api_key_credentials()

if not effective_api_key:
raise CLIError(
"Must be logged in to update an app. Run 'mcp-agent login', set MCP_API_KEY environment variable or specify --api-key option.",
retriable=False,
)

client = MCPAppClient(
api_url=api_url or DEFAULT_API_BASE_URL, api_key=effective_api_key
)

try:
resolved = resolve_server(client, app_id_or_name)

if isinstance(resolved, MCPAppConfiguration):
if not resolved.app:
raise CLIError(
"Could not resolve the underlying app for the configuration provided."
)
target_app: MCPApp = resolved.app
else:
target_app = resolved

updated_app = run_async(
client.update_app(
app_id=target_app.appId,
name=name,
description=description,
unauthenticated_access=unauthenticated_access,
)
)

short_id = f"{updated_app.appId[:8]}…"
print_success(
f"Updated app '{updated_app.name or target_app.name}' (ID: `{short_id}`)"
)

if updated_app.description is not None:
desc_text = updated_app.description or "(cleared)"
print_info(f"Description: {desc_text}")

app_server_info = updated_app.appServerInfo
if app_server_info and app_server_info.serverUrl:
print_info(f"Server URL: {app_server_info.serverUrl}")
if app_server_info.unauthenticatedAccess is not None:
auth_msg = (
"Unauthenticated access allowed"
if app_server_info.unauthenticatedAccess
else "Authentication required"
)
print_info(f"Authentication: {auth_msg}")

except UnauthenticatedError as e:
raise CLIError(
"Invalid API key. Run 'mcp-agent login' or set MCP_API_KEY environment variable with new API key."
) from e
except CLIError:
raise
except Exception as e:
raise CLIError(f"Error updating app: {str(e)}") from e
43 changes: 35 additions & 8 deletions src/mcp_agent/cli/cloud/commands/deploy/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ def deploy_config(
"--non-interactive",
help="Use existing secrets and update existing app where applicable, without prompting.",
),
unauthenticated_access: Optional[bool] = typer.Option(
None,
"--no-auth/--auth",
help="Allow unauthenticated access to the deployed server. Defaults to preserving the existing setting.",
),
# TODO(@rholinshead): Re-add dry-run and perform pre-validation of the app
# dry_run: bool = typer.Option(
# False,
Expand Down Expand Up @@ -173,14 +178,14 @@ def deploy_config(

if app_name is None:
if default_app_name:
print_info(
f"Using app name from config.yaml: '{default_app_name}'"
)
print_info(f"Using app name from config.yaml: '{default_app_name}'")
app_name = default_app_name
else:
app_name = "default"
print_info("Using app name: 'default'")

description_provided_by_cli = app_description is not None

if app_description is None:
if default_app_description:
app_description = default_app_description
Expand All @@ -205,7 +210,7 @@ def deploy_config(
" • Or use the --api-key flag with your key",
retriable=False,
)

if settings.VERBOSE:
print_info(f"Using API at {effective_api_url}")

Expand All @@ -222,7 +227,9 @@ def deploy_config(
print_info(f"App '{app_name}' not found — creating a new one...")
app = run_async(
mcp_app_client.create_app(
name=app_name, description=app_description
name=app_name,
description=app_description,
unauthenticated_access=unauthenticated_access,
)
)
app_id = app.appId
Expand All @@ -231,9 +238,7 @@ def deploy_config(
print_info(f"New app id: `{app_id}`")
else:
short_id = f"{app_id[:8]}…"
print_success(
f"Found existing app '{app_name}' (ID: `{short_id}`)"
)
print_success(f"Found existing app '{app_name}' (ID: `{short_id}`)")
if not non_interactive:
use_existing = typer.confirm(
f"Deploy an update to '{app_name}' (ID: `{short_id}`)?",
Expand All @@ -251,6 +256,21 @@ def deploy_config(
print_info(
"--non-interactive specified, will deploy an update to the existing app."
)
update_payload: dict[str, Optional[str | bool]] = {}
if description_provided_by_cli:
update_payload["description"] = app_description
if unauthenticated_access is not None:
update_payload["unauthenticated_access"] = unauthenticated_access

if update_payload:
if settings.VERBOSE:
print_info("Updating app settings before deployment...")
run_async(
mcp_app_client.update_app(
app_id=app_id,
**update_payload,
)
)
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.",
Expand Down Expand Up @@ -361,6 +381,13 @@ def deploy_config(
)
print_info(f"App URL: {app.appServerInfo.serverUrl}")
print_info(f"App Status: {status}")
if app.appServerInfo.unauthenticatedAccess is not None:
auth_text = (
"Not required (unauthenticated access allowed)"
if app.appServerInfo.unauthenticatedAccess
else "Required"
)
print_info(f"Authentication: {auth_text}")
return app_id

except Exception as e:
Expand Down
4 changes: 3 additions & 1 deletion src/mcp_agent/cli/cloud/commands/deploy/wrangler_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,9 @@ def ignore_patterns(path_str, names):
)
meta_vars.update({"MCP_DEPLOY_WORKSPACE_HASH": bundle_hash})
if settings.VERBOSE:
print_info(f"Deploying from non-git workspace (hash {bundle_hash[:12]}…)")
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.
Expand Down
2 changes: 2 additions & 0 deletions src/mcp_agent/cli/cloud/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
logout,
whoami,
)
from mcp_agent.cli.cloud.commands.apps import update_app as update_app_command
from mcp_agent.cli.cloud.commands.app import (
delete_app,
get_app_status,
Expand Down Expand Up @@ -88,6 +89,7 @@
app_cmd_app.command(name="delete")(delete_app)
app_cmd_app.command(name="status")(get_app_status)
app_cmd_app.command(name="workflows")(list_app_workflows)
app_cmd_app.command(name="update")(update_app_command)
app.add_typer(app_cmd_app, name="apps", help="Manage an MCP App")

# Sub-typer for `mcp-agent workflows` commands
Expand Down
67 changes: 66 additions & 1 deletion src/mcp_agent/cli/mcp_app/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class AppServerInfo(BaseModel):
"APP_SERVER_STATUS_ONLINE",
"APP_SERVER_STATUS_OFFLINE",
] # Enums: 0=UNSPECIFIED, 1=ONLINE, 2=OFFLINE
unauthenticatedAccess: Optional[bool] = None


# A developer-deployed MCP App which others can configure and use.
Expand All @@ -26,6 +27,7 @@ class MCPApp(BaseModel):
description: Optional[str] = None
createdAt: datetime
updatedAt: datetime
unauthenticatedAccess: Optional[bool] = None
appServerInfo: Optional[AppServerInfo] = None
deploymentMetadata: Optional[Dict[str, Any]] = None

Expand Down Expand Up @@ -131,12 +133,18 @@ def log_entries_list(self) -> List[LogEntry]:
class MCPAppClient(APIClient):
"""Client for interacting with the MCP App API service over HTTP."""

async def create_app(self, name: str, description: Optional[str] = None) -> MCPApp:
async def create_app(
self,
name: str,
description: Optional[str] = None,
unauthenticated_access: Optional[bool] = None,
) -> MCPApp:
"""Create a new MCP App via the API.
Args:
name: The name of the MCP App
description: Optional description for the app
unauthenticated_access: Whether the app should allow unauthenticated access
Returns:
MCPApp: The created MCP App
Expand All @@ -155,6 +163,9 @@ async def create_app(self, name: str, description: Optional[str] = None) -> MCPA
if description:
payload["description"] = description

if unauthenticated_access is not None:
payload["unauthenticatedAccess"] = unauthenticated_access

response = await self.post("/mcp_app/create_app", payload)

res = response.json()
Expand Down Expand Up @@ -245,6 +256,60 @@ async def get_app_configuration(

return MCPAppConfiguration(**res["appConfiguration"])

async def update_app(
self,
app_id: str,
name: Optional[str] = None,
description: Optional[str] = None,
unauthenticated_access: Optional[bool] = None,
) -> MCPApp:
"""Update an existing MCP App via the API.
Args:
app_id: The UUID of the app to update
name: Optional new name for the app
description: Optional new description for the app
unauthenticated_access: Optional flag to toggle unauthenticated access
Returns:
MCPApp: The updated MCP App
Raises:
ValueError: If the app_id is invalid or no fields are provided
httpx.HTTPStatusError: If the API returns an error
httpx.HTTPError: If the request fails
"""
if not app_id or not is_valid_app_id_format(app_id):
raise ValueError(f"Invalid app ID format: {app_id}")

if name is None and description is None and unauthenticated_access is None:
raise ValueError(
"At least one of name, description, or unauthenticated_access must be provided."
)

payload: Dict[str, Any] = {"appId": app_id}

if name is not None:
if not isinstance(name, str) or not name.strip():
raise ValueError("App name must be a non-empty string when provided")
payload["name"] = name

if description is not None:
if not isinstance(description, str):
raise ValueError("App description must be a string when provided")
payload["description"] = description

if unauthenticated_access is not None:
payload["unauthenticatedAccess"] = unauthenticated_access

response = await self.put("/mcp_app/update_app", payload)

res = response.json()
if not res or "app" not in res:
raise ValueError("API response did not contain the updated app data")

return MCPApp(**res["app"])

async def get_app_or_config(
self, app_id_or_url: str
) -> Union[MCPApp, MCPAppConfiguration]:
Expand Down
Loading
Loading