Skip to content

Commit 0fc97d9

Browse files
saqadriandrew-lastmile
authored andcommitted
'update' CLI command, and also a --noauth flag (lastmile-ai#554)
1 parent 2c66ba5 commit 0fc97d9

File tree

11 files changed

+530
-16
lines changed

11 files changed

+530
-16
lines changed
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""MCP Agent Cloud apps command."""
22

33
from .list import list_apps
4+
from .update import update_app
45

5-
__all__ = ["list_apps"]
6+
__all__ = ["list_apps", "update_app"]
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Update MCP apps command module exports."""
2+
3+
from .main import update_app
4+
5+
__all__ = ["update_app"]
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
from typing import Optional
2+
3+
import typer
4+
5+
from mcp_agent.cli.auth import load_api_key_credentials
6+
from mcp_agent.cli.config import settings
7+
from mcp_agent.cli.core.api_client import UnauthenticatedError
8+
from mcp_agent.cli.core.constants import (
9+
DEFAULT_API_BASE_URL,
10+
ENV_API_BASE_URL,
11+
ENV_API_KEY,
12+
)
13+
from mcp_agent.cli.core.utils import run_async
14+
from mcp_agent.cli.exceptions import CLIError
15+
from mcp_agent.cli.mcp_app.api_client import MCPApp, MCPAppClient, MCPAppConfiguration
16+
from mcp_agent.cli.utils.ux import print_info, print_success
17+
from ...utils import resolve_server
18+
19+
20+
def update_app(
21+
app_id_or_name: str = typer.Argument(
22+
...,
23+
help="ID, server URL, configuration ID, or name of the app to update.",
24+
show_default=False,
25+
),
26+
name: Optional[str] = typer.Option(
27+
None,
28+
"--name",
29+
"-n",
30+
help="Set a new name for the app.",
31+
),
32+
description: Optional[str] = typer.Option(
33+
None,
34+
"--description",
35+
"-d",
36+
help="Set a new description for the app. Use an empty string to clear it.",
37+
),
38+
unauthenticated_access: Optional[bool] = typer.Option(
39+
None,
40+
"--no-auth/--auth",
41+
help=(
42+
"Allow unauthenticated access to the app server (--no-auth) or require authentication (--auth). "
43+
"If omitted, the current setting is preserved."
44+
),
45+
),
46+
api_url: Optional[str] = typer.Option(
47+
settings.API_BASE_URL,
48+
"--api-url",
49+
help="API base URL. Defaults to MCP_API_BASE_URL environment variable.",
50+
envvar=ENV_API_BASE_URL,
51+
),
52+
api_key: Optional[str] = typer.Option(
53+
settings.API_KEY,
54+
"--api-key",
55+
help="API key for authentication. Defaults to MCP_API_KEY environment variable.",
56+
envvar=ENV_API_KEY,
57+
),
58+
) -> None:
59+
"""Update metadata or authentication settings for a deployed MCP App."""
60+
if name is None and description is None and unauthenticated_access is None:
61+
raise CLIError(
62+
"Specify at least one of --name, --description, or --no-auth/--auth to update.",
63+
retriable=False,
64+
)
65+
66+
effective_api_key = api_key or settings.API_KEY or load_api_key_credentials()
67+
68+
if not effective_api_key:
69+
raise CLIError(
70+
"Must be logged in to update an app. Run 'mcp-agent login', set MCP_API_KEY environment variable or specify --api-key option.",
71+
retriable=False,
72+
)
73+
74+
client = MCPAppClient(
75+
api_url=api_url or DEFAULT_API_BASE_URL, api_key=effective_api_key
76+
)
77+
78+
try:
79+
resolved = resolve_server(client, app_id_or_name)
80+
81+
if isinstance(resolved, MCPAppConfiguration):
82+
if not resolved.app:
83+
raise CLIError(
84+
"Could not resolve the underlying app for the configuration provided."
85+
)
86+
target_app: MCPApp = resolved.app
87+
else:
88+
target_app = resolved
89+
90+
updated_app = run_async(
91+
client.update_app(
92+
app_id=target_app.appId,
93+
name=name,
94+
description=description,
95+
unauthenticated_access=unauthenticated_access,
96+
)
97+
)
98+
99+
short_id = f"{updated_app.appId[:8]}…"
100+
print_success(
101+
f"Updated app '{updated_app.name or target_app.name}' (ID: `{short_id}`)"
102+
)
103+
104+
if updated_app.description is not None:
105+
desc_text = updated_app.description or "(cleared)"
106+
print_info(f"Description: {desc_text}")
107+
108+
app_server_info = updated_app.appServerInfo
109+
if app_server_info and app_server_info.serverUrl:
110+
print_info(f"Server URL: {app_server_info.serverUrl}")
111+
if app_server_info.unauthenticatedAccess is not None:
112+
auth_msg = (
113+
"Unauthenticated access allowed"
114+
if app_server_info.unauthenticatedAccess
115+
else "Authentication required"
116+
)
117+
print_info(f"Authentication: {auth_msg}")
118+
119+
except UnauthenticatedError as e:
120+
raise CLIError(
121+
"Invalid API key. Run 'mcp-agent login' or set MCP_API_KEY environment variable with new API key."
122+
) from e
123+
except CLIError:
124+
raise
125+
except Exception as e:
126+
raise CLIError(f"Error updating app: {str(e)}") from e

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

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ def deploy_config(
8383
"--non-interactive",
8484
help="Use existing secrets and update existing app where applicable, without prompting.",
8585
),
86+
unauthenticated_access: Optional[bool] = typer.Option(
87+
None,
88+
"--no-auth/--auth",
89+
help="Allow unauthenticated access to the deployed server. Defaults to preserving the existing setting.",
90+
),
8691
# TODO(@rholinshead): Re-add dry-run and perform pre-validation of the app
8792
# dry_run: bool = typer.Option(
8893
# False,
@@ -173,14 +178,14 @@ def deploy_config(
173178

174179
if app_name is None:
175180
if default_app_name:
176-
print_info(
177-
f"Using app name from config.yaml: '{default_app_name}'"
178-
)
181+
print_info(f"Using app name from config.yaml: '{default_app_name}'")
179182
app_name = default_app_name
180183
else:
181184
app_name = "default"
182185
print_info("Using app name: 'default'")
183186

187+
description_provided_by_cli = app_description is not None
188+
184189
if app_description is None:
185190
if default_app_description:
186191
app_description = default_app_description
@@ -205,7 +210,7 @@ def deploy_config(
205210
" • Or use the --api-key flag with your key",
206211
retriable=False,
207212
)
208-
213+
209214
if settings.VERBOSE:
210215
print_info(f"Using API at {effective_api_url}")
211216

@@ -222,7 +227,9 @@ def deploy_config(
222227
print_info(f"App '{app_name}' not found — creating a new one...")
223228
app = run_async(
224229
mcp_app_client.create_app(
225-
name=app_name, description=app_description
230+
name=app_name,
231+
description=app_description,
232+
unauthenticated_access=unauthenticated_access,
226233
)
227234
)
228235
app_id = app.appId
@@ -231,9 +238,7 @@ def deploy_config(
231238
print_info(f"New app id: `{app_id}`")
232239
else:
233240
short_id = f"{app_id[:8]}…"
234-
print_success(
235-
f"Found existing app '{app_name}' (ID: `{short_id}`)"
236-
)
241+
print_success(f"Found existing app '{app_name}' (ID: `{short_id}`)")
237242
if not non_interactive:
238243
use_existing = typer.confirm(
239244
f"Deploy an update to '{app_name}' (ID: `{short_id}`)?",
@@ -251,6 +256,21 @@ def deploy_config(
251256
print_info(
252257
"--non-interactive specified, will deploy an update to the existing app."
253258
)
259+
update_payload: dict[str, Optional[str | bool]] = {}
260+
if description_provided_by_cli:
261+
update_payload["description"] = app_description
262+
if unauthenticated_access is not None:
263+
update_payload["unauthenticated_access"] = unauthenticated_access
264+
265+
if update_payload:
266+
if settings.VERBOSE:
267+
print_info("Updating app settings before deployment...")
268+
run_async(
269+
mcp_app_client.update_app(
270+
app_id=app_id,
271+
**update_payload,
272+
)
273+
)
254274
except UnauthenticatedError as e:
255275
raise CLIError(
256276
"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(
361381
)
362382
print_info(f"App URL: {app.appServerInfo.serverUrl}")
363383
print_info(f"App Status: {status}")
384+
if app.appServerInfo.unauthenticatedAccess is not None:
385+
auth_text = (
386+
"Not required (unauthenticated access allowed)"
387+
if app.appServerInfo.unauthenticatedAccess
388+
else "Required"
389+
)
390+
print_info(f"Authentication: {auth_text}")
364391
return app_id
365392

366393
except Exception as e:

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,9 @@ def ignore_patterns(path_str, names):
296296
)
297297
meta_vars.update({"MCP_DEPLOY_WORKSPACE_HASH": bundle_hash})
298298
if settings.VERBOSE:
299-
print_info(f"Deploying from non-git workspace (hash {bundle_hash[:12]}…)")
299+
print_info(
300+
f"Deploying from non-git workspace (hash {bundle_hash[:12]}…)"
301+
)
300302

301303
# Write a breadcrumb file into the project so it ships with the bundle.
302304
# Use a Python file for guaranteed inclusion without renaming.

src/mcp_agent/cli/cloud/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
logout,
1717
whoami,
1818
)
19+
from mcp_agent.cli.cloud.commands.apps import update_app as update_app_command
1920
from mcp_agent.cli.cloud.commands.app import (
2021
delete_app,
2122
get_app_status,
@@ -88,6 +89,7 @@
8889
app_cmd_app.command(name="delete")(delete_app)
8990
app_cmd_app.command(name="status")(get_app_status)
9091
app_cmd_app.command(name="workflows")(list_app_workflows)
92+
app_cmd_app.command(name="update")(update_app_command)
9193
app.add_typer(app_cmd_app, name="apps", help="Manage an MCP App")
9294

9395
# Sub-typer for `mcp-agent workflows` commands

src/mcp_agent/cli/mcp_app/api_client.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class AppServerInfo(BaseModel):
1616
"APP_SERVER_STATUS_ONLINE",
1717
"APP_SERVER_STATUS_OFFLINE",
1818
] # Enums: 0=UNSPECIFIED, 1=ONLINE, 2=OFFLINE
19+
unauthenticatedAccess: Optional[bool] = None
1920

2021

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

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

134-
async def create_app(self, name: str, description: Optional[str] = None) -> MCPApp:
136+
async def create_app(
137+
self,
138+
name: str,
139+
description: Optional[str] = None,
140+
unauthenticated_access: Optional[bool] = None,
141+
) -> MCPApp:
135142
"""Create a new MCP App via the API.
136143
137144
Args:
138145
name: The name of the MCP App
139146
description: Optional description for the app
147+
unauthenticated_access: Whether the app should allow unauthenticated access
140148
141149
Returns:
142150
MCPApp: The created MCP App
@@ -155,6 +163,9 @@ async def create_app(self, name: str, description: Optional[str] = None) -> MCPA
155163
if description:
156164
payload["description"] = description
157165

166+
if unauthenticated_access is not None:
167+
payload["unauthenticatedAccess"] = unauthenticated_access
168+
158169
response = await self.post("/mcp_app/create_app", payload)
159170

160171
res = response.json()
@@ -245,6 +256,60 @@ async def get_app_configuration(
245256

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

259+
async def update_app(
260+
self,
261+
app_id: str,
262+
name: Optional[str] = None,
263+
description: Optional[str] = None,
264+
unauthenticated_access: Optional[bool] = None,
265+
) -> MCPApp:
266+
"""Update an existing MCP App via the API.
267+
268+
Args:
269+
app_id: The UUID of the app to update
270+
name: Optional new name for the app
271+
description: Optional new description for the app
272+
unauthenticated_access: Optional flag to toggle unauthenticated access
273+
274+
Returns:
275+
MCPApp: The updated MCP App
276+
277+
Raises:
278+
ValueError: If the app_id is invalid or no fields are provided
279+
httpx.HTTPStatusError: If the API returns an error
280+
httpx.HTTPError: If the request fails
281+
"""
282+
if not app_id or not is_valid_app_id_format(app_id):
283+
raise ValueError(f"Invalid app ID format: {app_id}")
284+
285+
if name is None and description is None and unauthenticated_access is None:
286+
raise ValueError(
287+
"At least one of name, description, or unauthenticated_access must be provided."
288+
)
289+
290+
payload: Dict[str, Any] = {"appId": app_id}
291+
292+
if name is not None:
293+
if not isinstance(name, str) or not name.strip():
294+
raise ValueError("App name must be a non-empty string when provided")
295+
payload["name"] = name
296+
297+
if description is not None:
298+
if not isinstance(description, str):
299+
raise ValueError("App description must be a string when provided")
300+
payload["description"] = description
301+
302+
if unauthenticated_access is not None:
303+
payload["unauthenticatedAccess"] = unauthenticated_access
304+
305+
response = await self.put("/mcp_app/update_app", payload)
306+
307+
res = response.json()
308+
if not res or "app" not in res:
309+
raise ValueError("API response did not contain the updated app data")
310+
311+
return MCPApp(**res["app"])
312+
248313
async def get_app_or_config(
249314
self, app_id_or_url: str
250315
) -> Union[MCPApp, MCPAppConfiguration]:

0 commit comments

Comments
 (0)