diff --git a/src/mcp_agent/cli/cloud/commands/apps/__init__.py b/src/mcp_agent/cli/cloud/commands/apps/__init__.py index a96c27d03..cc2407599 100644 --- a/src/mcp_agent/cli/cloud/commands/apps/__init__.py +++ b/src/mcp_agent/cli/cloud/commands/apps/__init__.py @@ -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"] diff --git a/src/mcp_agent/cli/cloud/commands/apps/update/__init__.py b/src/mcp_agent/cli/cloud/commands/apps/update/__init__.py new file mode 100644 index 000000000..b27a5cb50 --- /dev/null +++ b/src/mcp_agent/cli/cloud/commands/apps/update/__init__.py @@ -0,0 +1,5 @@ +"""Update MCP apps command module exports.""" + +from .main import update_app + +__all__ = ["update_app"] diff --git a/src/mcp_agent/cli/cloud/commands/apps/update/main.py b/src/mcp_agent/cli/cloud/commands/apps/update/main.py new file mode 100644 index 000000000..01d4a4590 --- /dev/null +++ b/src/mcp_agent/cli/cloud/commands/apps/update/main.py @@ -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 diff --git a/src/mcp_agent/cli/cloud/commands/deploy/main.py b/src/mcp_agent/cli/cloud/commands/deploy/main.py index 5634e1618..2b3839744 100644 --- a/src/mcp_agent/cli/cloud/commands/deploy/main.py +++ b/src/mcp_agent/cli/cloud/commands/deploy/main.py @@ -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, @@ -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 @@ -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}") @@ -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 @@ -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}`)?", @@ -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.", @@ -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: diff --git a/src/mcp_agent/cli/cloud/commands/deploy/wrangler_wrapper.py b/src/mcp_agent/cli/cloud/commands/deploy/wrangler_wrapper.py index 60df32da6..50eee106c 100644 --- a/src/mcp_agent/cli/cloud/commands/deploy/wrangler_wrapper.py +++ b/src/mcp_agent/cli/cloud/commands/deploy/wrangler_wrapper.py @@ -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. diff --git a/src/mcp_agent/cli/cloud/main.py b/src/mcp_agent/cli/cloud/main.py index b23247952..557ff354b 100644 --- a/src/mcp_agent/cli/cloud/main.py +++ b/src/mcp_agent/cli/cloud/main.py @@ -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, @@ -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 diff --git a/src/mcp_agent/cli/mcp_app/api_client.py b/src/mcp_agent/cli/mcp_app/api_client.py index cd6a8ab79..d25b09643 100644 --- a/src/mcp_agent/cli/mcp_app/api_client.py +++ b/src/mcp_agent/cli/mcp_app/api_client.py @@ -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. @@ -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 @@ -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 @@ -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() @@ -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]: diff --git a/src/mcp_agent/cli/mcp_app/mock_client.py b/src/mcp_agent/cli/mcp_app/mock_client.py index b14331234..b8691f91f 100644 --- a/src/mcp_agent/cli/mcp_app/mock_client.py +++ b/src/mcp_agent/cli/mcp_app/mock_client.py @@ -31,7 +31,7 @@ def __init__(self, api_url: str = "http://mock-api", api_key: str = "mock-key"): """ self.api_url = api_url self.api_key = api_key - self._createdApps: Dict[str, Dict[str, MCPApp]] = {} + self._createdApps: Dict[str, MCPApp] = {} async def get_app_id_by_name(self, name: str) -> Optional[str]: """Get a mock app ID by name. Deterministic for MOCK_APP_NAME name. @@ -70,7 +70,10 @@ async def get_app( uuid_str = str(raw_uuid) resolved_app_id = f"app_{uuid_str}" - return MCPApp( + if resolved_app_id in self._createdApps: + return self._createdApps[resolved_app_id] + + app = MCPApp( appId=resolved_app_id, name="Test App", creatorId="u_12345678-1234-1234-1234-123456789012", @@ -82,13 +85,21 @@ async def get_app( 2025, 6, 16, 0, 0, 0, tzinfo=datetime.timezone.utc ), ) + self._createdApps[resolved_app_id] = app + return app - 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 mock MCP App. Args: name: The name of the MCP App description: Optional description for the app + unauthenticated_access: Optional flag indicating unauthenticated access Returns: MCPApp: The created mock MCP App @@ -110,11 +121,12 @@ async def create_app(self, name: str, description: Optional[str] = None) -> MCPA # Add the prefix to identify this as an app entity prefixed_uuid = f"app_{uuid_str}" - return MCPApp( + created_app = MCPApp( appId=prefixed_uuid, name=name, creatorId="u_12345678-1234-1234-1234-123456789012", description=description, + unauthenticatedAccess=unauthenticated_access, createdAt=datetime.datetime( 2025, 6, 16, 0, 0, 0, tzinfo=datetime.timezone.utc ), @@ -122,6 +134,39 @@ async def create_app(self, name: str, description: Optional[str] = None) -> MCPA 2025, 6, 16, 0, 0, 0, tzinfo=datetime.timezone.utc ), ) + self._createdApps[prefixed_uuid] = created_app + return created_app + + 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 mock MCP App.""" + if not app_id or not app_id.startswith("app_"): + raise ValueError("Invalid app ID format") + + app = self._createdApps.get(app_id) + if not app: + app = await self.get_app(app_id=app_id) + + updated_fields = app.dict() + if name is not None: + updated_fields["name"] = name + if description is not None: + updated_fields["description"] = description + if unauthenticated_access is not None: + updated_fields["unauthenticatedAccess"] = unauthenticated_access + + updated_fields["updatedAt"] = datetime.datetime( + 2025, 6, 17, 0, 0, 0, tzinfo=datetime.timezone.utc + ) + + updated_app = MCPApp(**updated_fields) + self._createdApps[app_id] = updated_app + return updated_app async def configure_app( self, diff --git a/tests/cli/commands/test_apps_update.py b/tests/cli/commands/test_apps_update.py new file mode 100644 index 000000000..6fa0fe74c --- /dev/null +++ b/tests/cli/commands/test_apps_update.py @@ -0,0 +1,128 @@ +"""Tests for the `mcp-agent apps update` command.""" + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, patch + +import pytest +from typer.testing import CliRunner + +from mcp_agent.cli.cloud.main import app +from mcp_agent.cli.mcp_app.api_client import AppServerInfo, MCPApp, MCPAppConfiguration + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def _make_app(unauthenticated: bool = False) -> MCPApp: + now = datetime(2025, 1, 1, tzinfo=timezone.utc) + return MCPApp( + appId="app_12345678-1234-1234-1234-1234567890ab", + name="Sample App", + creatorId="u_12345678-1234-1234-1234-1234567890ab", + description="Initial", + createdAt=now, + updatedAt=now, + appServerInfo=AppServerInfo( + serverUrl="https://example.com", + status="APP_SERVER_STATUS_ONLINE", + unauthenticatedAccess=unauthenticated, + ), + ) + + +def test_apps_update_requires_fields(runner: CliRunner): + result = runner.invoke( + app, + [ + "apps", + "update", + "app_12345678-1234-1234-1234-1234567890ab", + "--api-key", + "token", + ], + ) + + assert result.exit_code != 0 + assert "Specify at least one" in result.stdout + + +def test_apps_update_sets_auth_flag(runner: CliRunner): + existing_app = _make_app() + updated_app = _make_app(unauthenticated=True) + + mock_client = AsyncMock() + mock_client.update_app.return_value = updated_app + + with ( + patch( + "mcp_agent.cli.cloud.commands.apps.update.main.MCPAppClient", + return_value=mock_client, + ), + patch( + "mcp_agent.cli.cloud.commands.apps.update.main.resolve_server", + return_value=existing_app, + ), + ): + result = runner.invoke( + app, + [ + "apps", + "update", + existing_app.appId, + "--no-auth", + "--api-key", + "token", + "--api-url", + "http://api", + ], + ) + + assert result.exit_code == 0, result.stdout + update_kwargs = mock_client.update_app.await_args.kwargs + assert update_kwargs["unauthenticated_access"] is True + assert "Unauthenticated access allowed" in result.stdout + + +def test_apps_update_accepts_configuration_identifier(runner: CliRunner): + base_app = _make_app() + config = MCPAppConfiguration( + appConfigurationId="apcnf_12345678-1234-1234-1234-1234567890ab", + app=base_app, + creatorId="u_12345678-1234-1234-1234-1234567890ab", + ) + updated_app = _make_app() + updated_app.description = "Updated description" + + mock_client = AsyncMock() + mock_client.update_app.return_value = updated_app + + with ( + patch( + "mcp_agent.cli.cloud.commands.apps.update.main.MCPAppClient", + return_value=mock_client, + ), + patch( + "mcp_agent.cli.cloud.commands.apps.update.main.resolve_server", + return_value=config, + ), + ): + result = runner.invoke( + app, + [ + "apps", + "update", + config.appConfigurationId, + "--description", + "Updated description", + "--api-key", + "token", + ], + ) + + assert result.exit_code == 0, result.stdout + update_kwargs = mock_client.update_app.await_args.kwargs + assert update_kwargs["description"] == "Updated description" + assert update_kwargs["app_id"] == base_app.appId + assert "Description: Updated description" in result.stdout diff --git a/tests/cli/commands/test_deploy_command.py b/tests/cli/commands/test_deploy_command.py index 4b3ee6044..b131fb529 100644 --- a/tests/cli/commands/test_deploy_command.py +++ b/tests/cli/commands/test_deploy_command.py @@ -74,6 +74,7 @@ def test_deploy_command_help(runner): assert "--api-url" in clean_text assert "--api-key" in clean_text assert "--non-interactive" in clean_text + assert "--no-auth" in clean_text assert "--ignore-file" in clean_text assert "mcpacignore" in clean_text @@ -144,6 +145,118 @@ async def mock_process_secrets(*args, **kwargs): assert "Transformed secrets file written to" in result.stdout +def test_deploy_no_auth_flag_sets_unauthenticated_access(runner, temp_config_dir): + """Ensure the --no-auth flag is forwarded to app creation.""" + output_path = temp_config_dir / MCP_DEPLOYED_SECRETS_FILENAME + + async def mock_process_secrets(*args, **kwargs): + with open(kwargs.get("output_path", output_path), "w", encoding="utf-8") as f: + f.write("# Transformed file\ntest: value\n") + return { + "deployment_secrets": [], + "user_secrets": [], + "reused_secrets": [], + "skipped_secrets": [], + } + + mock_client = AsyncMock() + mock_client.get_app_id_by_name.return_value = None + + mock_app = MagicMock() + mock_app.appId = MOCK_APP_ID + mock_client.create_app.return_value = mock_app + + with ( + patch( + "mcp_agent.cli.secrets.processor.process_config_secrets", + side_effect=mock_process_secrets, + ), + patch( + "mcp_agent.cli.cloud.commands.deploy.main.MCPAppClient", + return_value=mock_client, + ), + patch( + "mcp_agent.cli.cloud.commands.deploy.main.wrangler_deploy", + return_value=MOCK_APP_ID, + ), + ): + result = runner.invoke( + app, + [ + "deploy", + MOCK_APP_NAME, + "--config-dir", + temp_config_dir, + "--api-url", + "http://test-api.com", + "--api-key", + "test-api-key", + "--no-auth", + "--non-interactive", + ], + ) + + assert result.exit_code == 0, result.stdout + create_kwargs = mock_client.create_app.await_args.kwargs + assert create_kwargs.get("unauthenticated_access") is True + + +def test_deploy_existing_app_updates_auth_setting(runner, temp_config_dir): + """Existing apps should be updated when auth flags are provided.""" + output_path = temp_config_dir / MCP_DEPLOYED_SECRETS_FILENAME + + async def mock_process_secrets(*args, **kwargs): + with open(kwargs.get("output_path", output_path), "w", encoding="utf-8") as f: + f.write("# Transformed file\ntest: value\n") + return { + "deployment_secrets": [], + "user_secrets": [], + "reused_secrets": [], + "skipped_secrets": [], + } + + mock_client = AsyncMock() + mock_client.get_app_id_by_name.return_value = MOCK_APP_ID + + mock_updated_app = MagicMock() + mock_updated_app.appServerInfo = None + mock_client.update_app.return_value = mock_updated_app + + with ( + patch( + "mcp_agent.cli.secrets.processor.process_config_secrets", + side_effect=mock_process_secrets, + ), + patch( + "mcp_agent.cli.cloud.commands.deploy.main.MCPAppClient", + return_value=mock_client, + ), + patch( + "mcp_agent.cli.cloud.commands.deploy.main.wrangler_deploy", + return_value=MOCK_APP_ID, + ), + ): + result = runner.invoke( + app, + [ + "deploy", + MOCK_APP_NAME, + "--config-dir", + temp_config_dir, + "--api-url", + "http://test-api.com", + "--api-key", + "test-api-key", + "--auth", + "--non-interactive", + ], + ) + + assert result.exit_code == 0, result.stdout + update_kwargs = mock_client.update_app.await_args.kwargs + assert update_kwargs.get("unauthenticated_access") is False + + def test_deploy_defaults_to_configured_app_name(runner, temp_config_dir): """Command should fall back to the config-defined name when none is provided.""" diff --git a/uv.lock b/uv.lock index 767f1fafd..49e713df7 100644 --- a/uv.lock +++ b/uv.lock @@ -2029,7 +2029,7 @@ wheels = [ [[package]] name = "mcp-agent" -version = "0.1.32" +version = "0.1.33" source = { editable = "." } dependencies = [ { name = "aiohttp" },