From 4ca824cf8f7b5d5807321c386329d80c2666dbfe Mon Sep 17 00:00:00 2001
From: Eric Gustin <34000337+EricGustin@users.noreply.github.com>
Date: Mon, 3 Nov 2025 11:19:04 -0800
Subject: [PATCH] Improve `arcade deploy` CLI Command (#634)
Also fleshed out `arcade server` commands and MCPApp.name validation.
Example of output of `arcade deploy`:
---
libs/arcade-cli/arcade_cli/deploy.py | 346 ++++++++++++---
libs/arcade-cli/arcade_cli/main.py | 8 +-
libs/arcade-cli/arcade_cli/new.py | 4 +-
libs/arcade-cli/arcade_cli/server.py | 411 ++++++++++++++++++
libs/arcade-cli/arcade_cli/worker.py | 347 ---------------
.../arcade_mcp_server/mcp_app.py | 57 ++-
libs/arcade-mcp-server/pyproject.toml | 2 +-
libs/tests/arcade_mcp_server/test_mcp_app.py | 176 ++++++--
pyproject.toml | 6 +-
9 files changed, 903 insertions(+), 454 deletions(-)
create mode 100644 libs/arcade-cli/arcade_cli/server.py
delete mode 100644 libs/arcade-cli/arcade_cli/worker.py
diff --git a/libs/arcade-cli/arcade_cli/deploy.py b/libs/arcade-cli/arcade_cli/deploy.py
index 996b2a67..a95c259c 100644
--- a/libs/arcade-cli/arcade_cli/deploy.py
+++ b/libs/arcade-cli/arcade_cli/deploy.py
@@ -1,3 +1,4 @@
+import asyncio
import base64
import io
import os
@@ -6,13 +7,20 @@ import subprocess
import sys
import tarfile
import time
+from collections import deque
from pathlib import Path
from typing import cast
import httpx
from dotenv import load_dotenv
from pydantic import BaseModel, Field
-from rich.console import Console
+from rich.columns import Columns
+from rich.console import Console, Group
+from rich.live import Live
+from rich.prompt import Confirm
+from rich.spinner import Spinner
+from rich.text import Text
+from typing_extensions import Literal
from arcade_cli.secret import load_env_file
from arcade_cli.utils import compute_base_url, validate_and_get_config
@@ -63,7 +71,7 @@ class DeploymentToolkits(BaseModel):
packages: list[str] = Field(default_factory=list)
-class DeploymentRequest(BaseModel):
+class CreateDeploymentRequest(BaseModel):
"""Deployment request payload for /v1/deployments endpoint."""
name: str
@@ -71,7 +79,227 @@ class DeploymentRequest(BaseModel):
toolkits: DeploymentToolkits
-# Functions
+class UpdateDeploymentRequest(BaseModel):
+ """Deployment request payload for /v1/deployments/{deployment_name} endpoint."""
+
+ description: str
+ toolkits: DeploymentToolkits
+
+
+# Deployment Status Functions
+
+
+def get_deployment_status(engine_url: str, api_key: str, server_name: str) -> str:
+ """
+ Get the status of a deployment.
+
+ Args:
+ engine_url: The base URL of the Arcade Engine
+ server_name: The name of the server to get the status of
+
+ Returns:
+ The status of the deployment.
+ Possible values are: "pending", "updating", "unknown", "running", "failed".
+ """
+ client = httpx.Client(
+ base_url=engine_url,
+ headers={"Authorization": f"Bearer {api_key}"},
+ timeout=360,
+ )
+ response = client.get(f"/v1/deployments/{server_name}/status")
+ response.raise_for_status()
+ status = cast(str, response.json().get("status", "unknown"))
+ return status
+
+
+async def _poll_deployment_status(
+ engine_url: str,
+ api_key: str,
+ server_name: str,
+ state: dict,
+ debug: bool = False,
+) -> None:
+ """Poll deployment status until it's running or error."""
+ while state["status"] in ["pending", "unknown", "updating"]:
+ try:
+ status = get_deployment_status(engine_url, api_key, server_name)
+ state["status"] = status
+ if status in ["running", "failed"]:
+ break
+ except Exception as e:
+ if debug:
+ console.print(f"Error polling status: {e}", style="dim red")
+ await asyncio.sleep(5)
+
+
+async def _stream_deployment_logs_to_deque(
+ engine_url: str,
+ api_key: str,
+ server_name: str,
+ log_deque: deque,
+ state: dict,
+ debug: bool = False,
+) -> None:
+ """Stream deployment logs into a deque with retry logic."""
+ stream_url = f"{engine_url}/v1/deployments/{server_name}/logs/stream"
+ headers = {"Authorization": f"Bearer {api_key}"}
+
+ while state["status"] in ["pending", "unknown", "updating"]:
+ try:
+ async with (
+ httpx.AsyncClient(timeout=None) as client, # noqa: S113 - expected indefinite log stream
+ client.stream("GET", stream_url, headers=headers) as response,
+ ):
+ response.raise_for_status()
+ async for line in response.aiter_lines():
+ if line.strip():
+ log_deque.append(line)
+ # End state check
+ if state["status"] not in ["pending", "unknown", "updating"]:
+ break
+ except httpx.HTTPStatusError as e:
+ if debug:
+ console.print(f"Failed to stream logs: {e.response.status_code}", style="dim red")
+ await asyncio.sleep(3)
+ except Exception as e:
+ if debug:
+ console.print(f"Error streaming logs: {e}", style="dim red")
+ await asyncio.sleep(3)
+
+
+async def _monitor_deployment_with_logs(
+ engine_url: str,
+ api_key: str,
+ server_name: str,
+ debug: bool = False,
+ is_update: bool = False,
+) -> tuple[Literal["running", "failed"], list[str]]:
+ """
+ Monitor deployment with live status and streaming logs display.
+
+ Args:
+ engine_url: The base URL of the Arcade Engine
+ api_key: The API key for authentication
+ server_name: The name of the server to monitor
+ debug: Whether to show debug information
+ is_update: If True, wait for status to be 'updating' before streaming logs or 'failed' before exiting
+
+ Returns:
+ Tuple of (final status, list of all logs collected)
+ """
+ state = {"status": "pending"}
+ log_deque: deque[str] = deque(maxlen=1000)
+
+ # Friendly messages that rotate while waiting for logs
+ waiting_messages = [
+ "Waiting for logs...",
+ "Still getting logs ready...",
+ "Build environment warming up...",
+ "Preparing deployment resources...",
+ ]
+
+ status_task = asyncio.create_task(
+ _poll_deployment_status(engine_url, api_key, server_name, state, debug)
+ )
+
+ # Don't stream logs until the deployment is 'updating' or 'failed' otherwise we will get logs from the previous deployment
+ if is_update:
+ while state["status"] not in ["updating", "failed"]:
+ await asyncio.sleep(1)
+
+ # Start log streaming task
+ logs_task = asyncio.create_task(
+ _stream_deployment_logs_to_deque(engine_url, api_key, server_name, log_deque, state, debug)
+ )
+
+ # Live display with spinner and logs
+ spinner = Spinner("dots", style="green")
+ log_spinner = Spinner("dots", style="dim")
+
+ start_time = time.time()
+
+ with Live(console=console, refresh_per_second=4) as live:
+ while state["status"] in ["pending", "unknown", "updating"]:
+ elapsed = int(time.time() - start_time)
+
+ # Show different messages based on status
+ if state["status"] == "updating":
+ status_text = Text(
+ "Updating deployment (this may take a few minutes)...", style="bold green"
+ )
+ else:
+ status_text = Text(
+ "Deployment in progress (this may take a few minutes)...", style="bold green"
+ )
+ status_line = Columns([spinner, status_text], padding=(0, 1))
+
+ logs_header = Text("\nRecent logs:", style="dim")
+
+ if log_deque:
+ # Get the last logs and ensure we only show 6 lines total
+ recent_logs = list(log_deque)[-6:]
+ log_lines_text = Text()
+ for log_line in recent_logs:
+ log_lines_text.append(f" {log_line}\n", style="dim")
+ # Pad with empty lines if we have fewer than 6 logs
+ for _ in range(6 - len(recent_logs)):
+ log_lines_text.append("\n")
+
+ footer = Text(
+ "\nYou can safely exit with Ctrl+C at any time. The deployment will continue normally.",
+ style="green",
+ )
+ display = Group(Text("\n"), status_line, logs_header, log_lines_text, footer)
+ else:
+ # Rotate message every 7 seconds while waiting for logs
+ message_index = (elapsed // 7) % len(waiting_messages)
+ current_message = waiting_messages[message_index]
+ waiting_line = Columns(
+ [log_spinner, Text(current_message, style="dim italic")], padding=(0, 1)
+ )
+ padding = Text("\n" * 5)
+ footer = Text(
+ "\nYou can safely exit with Ctrl+C at any time. The deployment will continue normally.",
+ style="green",
+ )
+ display = Group(
+ Text("\n"), status_line, logs_header, Text(" "), waiting_line, padding, footer
+ )
+
+ live.update(display)
+ await asyncio.sleep(0.25)
+
+ status_task.cancel()
+ logs_task.cancel()
+ await asyncio.gather(status_task, logs_task, return_exceptions=True)
+
+ all_logs = list(log_deque)
+
+ return cast(Literal["running", "failed"], state["status"]), all_logs
+
+
+# Create Deployment Functions
+
+
+def server_already_exists(engine_url: str, api_key: str, server_name: str) -> bool:
+ """Check if a server already exists in the Arcade Engine."""
+ client = httpx.Client(base_url=engine_url, headers={"Authorization": f"Bearer {api_key}"})
+ response = client.get(f"/v1/workers/{server_name}")
+ if response.status_code == 404:
+ return False
+
+ response.raise_for_status()
+
+ return response.json().get("managed")
+
+
+def update_deployment(
+ engine_url: str, api_key: str, server_name: str, update_deployment_request: dict
+) -> None:
+ """Update a deployment in the Arcade Engine."""
+ client = httpx.Client(base_url=engine_url, headers={"Authorization": f"Bearer {api_key}"})
+ response = client.put(f"/v1/deployments/{server_name}", json=update_deployment_request)
+ response.raise_for_status()
def create_package_archive(package_dir: Path) -> str:
@@ -106,20 +334,16 @@ def create_package_archive(package_dir: Path) -> str:
name = tarinfo.name
parts = Path(name).parts
- if any(part.startswith(".") for part in parts):
- return None
- if "__pycache__" in parts:
- return None
-
- if any(part.endswith(".egg-info") for part in parts):
- return None
-
- if "dist" in parts or "build" in parts:
- return None
-
- if name.endswith(".lock"):
- return None
+ for part in parts:
+ if (
+ part.startswith(".")
+ or part == "__pycache__"
+ or part.endswith(".egg-info")
+ or part in ["dist", "build"]
+ or part.endswith(".lock")
+ ):
+ return None
return tarinfo
@@ -152,7 +376,7 @@ def start_server_process(entrypoint: str, debug: bool = False) -> tuple[subproce
"""
port = random.randint(8000, 9000) # noqa: S311
- # override app.run() settings
+ # override MCPApp.run() settings
env = {
**os.environ,
"ARCADE_SERVER_HOST": "localhost",
@@ -496,7 +720,8 @@ def deploy_server_logic(
console.print("\nValidating user is logged in...", style="dim")
config = validate_and_get_config()
engine_url = compute_base_url(force_tls, force_no_tls, host, port)
- console.print(f"✓ {config.user.email} is logged in", style="green")
+ user_email = config.user.email if config.user else "User"
+ console.print(f"✓ {user_email} is logged in", style="green")
# Step 2: Validate pyproject.toml exists in current directory
console.print("\nValidating pyproject.toml exists in current directory...", style="dim")
@@ -517,19 +742,20 @@ def deploy_server_logic(
load_dotenv(env_path, override=False)
console.print(f"✓ Loaded environment from {env_path}", style="green")
else:
- console.print(f"⚠️ No .env file found at {env_path}", style="yellow")
+ console.print(f"[!] No .env file found at {env_path}", style="yellow")
# Step 4: Verify server and extract metadata (or skip if --skip-validate)
required_secrets_from_validation: set[str] = set()
if skip_validate:
- console.print("\n⚠️ Skipping server validation (--skip-validate set)", style="yellow")
+ console.print("\n[!] Skipping server validation (--skip-validate set)", style="yellow")
# Use the provided server_name and server_version
# These are guaranteed to be set due to validation in main.py
if server_name is None:
raise ValueError("server_name must be provided when skip_validate is True")
if server_version is None:
raise ValueError("server_version must be provided when skip_validate is True")
+
console.print(f"✓ Using server name: {server_name}", style="green")
console.print(f"✓ Using server version: {server_version}", style="green")
else:
@@ -551,7 +777,7 @@ def deploy_server_logic(
secrets_to_upsert: set[str] = set()
if secrets == "skip":
- console.print("\n⚠️ Skipping secret upload (--secrets skip)", style="yellow")
+ console.print("\n[!] Skipping secret upload (--secrets skip)", style="yellow")
elif secrets == "all":
console.print("\nUploading ALL secrets from .env file...", style="dim")
secrets_to_upsert = set(load_env_file(str(env_path)).keys())
@@ -559,7 +785,7 @@ def deploy_server_logic(
console.print(f"✓ Found {len(secrets_to_upsert)} secret(s) in .env file", style="green")
upsert_secrets_to_engine(engine_url, config.api.key, secrets_to_upsert, debug)
else:
- console.print("⚠️ No secrets found in .env file", style="yellow")
+ console.print("[!] No secrets found in .env file", style="yellow")
elif secrets == "auto":
# Only upload required secrets discovered during validation
if required_secrets_from_validation:
@@ -582,50 +808,48 @@ def deploy_server_logic(
except Exception as e:
raise ValueError(f"Failed to create package archive: {e}") from e
- # Step 7: Build deployment request payload
- deployment_request = DeploymentRequest(
- name=server_name,
- description="MCP Server deployed via CLI",
- toolkits=DeploymentToolkits(
- bundles=[
- ToolkitBundle(
- name=server_name,
- version=server_version,
- bytes=archive_base64,
- type="mcp",
- entrypoint=entrypoint,
- )
- ],
- ),
- )
-
- # Step 8: Send deployment request to engine
- console.print("\nDeploying to Arcade Engine...", style="dim")
+ # Step 7: Send deployment request to engine
+ is_update = False
try:
- response = deploy_server_to_engine(
- engine_url, config.api.key, deployment_request.model_dump(), debug
+ toolkit_bundle = ToolkitBundle(
+ name=server_name,
+ version=server_version,
+ bytes=archive_base64,
+ type="mcp",
+ entrypoint=entrypoint,
)
+ deployment_toolkits = DeploymentToolkits(bundles=[toolkit_bundle])
+
+ if server_already_exists(engine_url, config.api.key, server_name):
+ is_update = True
+ update_request = UpdateDeploymentRequest(
+ description="MCP Server deployed via CLI",
+ toolkits=deployment_toolkits,
+ )
+ update_deployment(engine_url, config.api.key, server_name, update_request.model_dump())
+ else:
+ create_request = CreateDeploymentRequest(
+ name=server_name,
+ description="MCP Server deployed via CLI",
+ toolkits=deployment_toolkits,
+ )
+ deploy_server_to_engine(engine_url, config.api.key, create_request.model_dump(), debug)
except Exception as e:
raise ValueError(f"Deployment failed: {e}") from e
- console.print(
- f"✓ Server '{server_name}' v{server_version} deployed successfully", style="bold green"
+ # Step 8: Monitor deployment with live status and logs
+ final_status, all_logs = asyncio.run(
+ _monitor_deployment_with_logs(engine_url, config.api.key, server_name, debug, is_update)
)
- deployment_id = response.get("id", "N/A")
- deployment_uri = response.get("http", {}).get("uri", "N/A")
- deployment_secret = response.get("http", {}).get("secret", "N/A").get("value", "N/A")
+ if final_status == "running":
+ console.print("\n✓ Deployment successful! Server is running.", style="bold green")
+ elif final_status == "failed":
+ console.print("\n✗ Deployment failed. Check logs for details.", style="bold red")
- console.print("\n[bold]Deployment Details:[/bold]")
- console.print(f" • Server ID: [cyan]{deployment_id}[/cyan]")
- console.print(f" • Server URI: [cyan]{deployment_uri}[/cyan]")
- console.print(f" • Server Secret: [cyan]{deployment_secret}[/cyan]")
- console.print("\n[yellow]⚠ Note:[/yellow] Your server is now starting up...", style="bold")
- console.print(
- "\n This process may take a few minutes. Your server will be available at the URI above once ready."
- )
-
- console.print(
- "\nView and manage your servers: [link]https://api.arcade.dev/dashboard/[/link]",
- style="dim",
- )
+ # Offer to view full deployment logs
+ if all_logs and Confirm.ask("\nView full deployment logs?", default=False): # type: ignore[arg-type]
+ with console.pager(styles=True):
+ console.print("[bold]Full Deployment Logs[/bold]\n", style="cyan")
+ for i, log_line in enumerate(all_logs, 1):
+ console.print(f"{i:4d} | {log_line}", style="dim")
diff --git a/libs/arcade-cli/arcade_cli/main.py b/libs/arcade-cli/arcade_cli/main.py
index fedd9361..3a1e773d 100644
--- a/libs/arcade-cli/arcade_cli/main.py
+++ b/libs/arcade-cli/arcade_cli/main.py
@@ -16,8 +16,6 @@ from rich.console import Console
from rich.text import Text
from tqdm import tqdm
-import arcade_cli.secret as secret
-import arcade_cli.worker as worker
from arcade_cli.authn import LocalAuthCallbackServer, check_existing_login
from arcade_cli.constants import (
PROD_CLOUD_HOST,
@@ -26,6 +24,8 @@ from arcade_cli.constants import (
from arcade_cli.display import (
display_eval_results,
)
+from arcade_cli.secret import app as secret_app
+from arcade_cli.server import app as server_app
from arcade_cli.show import show_logic
from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup
from arcade_cli.utils import (
@@ -57,14 +57,14 @@ cli = TrackedTyper(
cli.add_typer(
- worker.app,
+ server_app,
name="server",
help="Manage deployments of tool servers (logs, list, etc)",
rich_help_panel="Manage",
)
cli.add_typer(
- secret.app,
+ secret_app,
name="secret",
help="Manage tool secrets in the cloud (set, unset, list)",
rich_help_panel="Manage",
diff --git a/libs/arcade-cli/arcade_cli/new.py b/libs/arcade-cli/arcade_cli/new.py
index 4ec32e1e..7c40396c 100644
--- a/libs/arcade-cli/arcade_cli/new.py
+++ b/libs/arcade-cli/arcade_cli/new.py
@@ -19,14 +19,14 @@ try:
ARCADE_MCP_MAX_VERSION = str(int(ARCADE_MCP_MIN_VERSION.split(".")[0]) + 1) + ".0.0"
except Exception as e:
console.print(f"[red]Failed to get arcade-mcp version: {e}[/red]")
- ARCADE_MCP_MIN_VERSION = "1.4.0" # Default version if unable to fetch
+ ARCADE_MCP_MIN_VERSION = "1.5.0" # Default version if unable to fetch
ARCADE_MCP_MAX_VERSION = "2.0.0"
ARCADE_TDK_MIN_VERSION = "3.0.0"
ARCADE_TDK_MAX_VERSION = "4.0.0"
ARCADE_SERVE_MIN_VERSION = "3.0.0"
ARCADE_SERVE_MAX_VERSION = "4.0.0"
-ARCADE_MCP_SERVER_MIN_VERSION = "1.5.0"
+ARCADE_MCP_SERVER_MIN_VERSION = "1.7.0"
ARCADE_MCP_SERVER_MAX_VERSION = "2.0.0"
diff --git a/libs/arcade-cli/arcade_cli/server.py b/libs/arcade-cli/arcade_cli/server.py
new file mode 100644
index 00000000..bdf2cfab
--- /dev/null
+++ b/libs/arcade-cli/arcade_cli/server.py
@@ -0,0 +1,411 @@
+import asyncio
+import json
+import re
+from datetime import datetime, timedelta, timezone
+from typing import Optional
+
+import httpx
+import typer
+from arcadepy import Arcade, NotFoundError
+from arcadepy.types import WorkerHealthResponse, WorkerResponse
+from dateutil import parser
+from rich.console import Console
+from rich.table import Table
+
+from arcade_cli.constants import (
+ PROD_ENGINE_HOST,
+)
+from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup
+from arcade_cli.utils import (
+ compute_base_url,
+ handle_cli_error,
+ validate_and_get_config,
+)
+
+console = Console()
+
+
+def _format_timestamp_to_local(timestamp_str: str) -> str:
+ """
+ Convert a UTC timestamp string to local timezone format.
+
+ Args:
+ timestamp_str: UTC timestamp in format "2025-10-22T21:08:23.508906574Z"
+
+ Returns:
+ Formatted timestamp string in local timezone
+ """
+ try:
+ # Parse the UTC timestamp and convert to local timezone
+ utc_dt = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
+ local_dt = utc_dt.astimezone()
+ return local_dt.strftime("%Y-%m-%d %H:%M:%S %Z")
+ except (ValueError, TypeError):
+ # If parsing fails, return the original timestamp
+ return timestamp_str
+
+
+def _parse_time_string(time_str: str) -> datetime:
+ """
+ Parse a time string that can be either relative (e.g., "1h", "42m", "2d")
+ or absolute (e.g., "2025-10-03T12:24:36Z").
+
+ Args:
+ time_str: Time string in relative or absolute format
+
+ Returns:
+ datetime object in UTC timezone
+
+ Raises:
+ ValueError: If the time string cannot be parsed
+ """
+ if not time_str:
+ raise ValueError("Time string cannot be empty")
+
+ # Handle relative time formats (e.g., "1h", "42m", "2d", "0m")
+ relative_pattern = r"^(\d+)([smhd])$"
+ match = re.match(relative_pattern, time_str.lower())
+
+ if match:
+ value = int(match.group(1))
+ unit = match.group(2)
+
+ now = datetime.now(timezone.utc)
+
+ if unit == "s":
+ delta = timedelta(seconds=value)
+ elif unit == "m":
+ delta = timedelta(minutes=value)
+ elif unit == "h":
+ delta = timedelta(hours=value)
+ elif unit == "d":
+ delta = timedelta(days=value)
+ else:
+ raise ValueError(f"Unsupported time unit: {unit}")
+
+ return now - delta
+
+ # Handle absolute time formats using dateutil parser
+ try:
+ parsed_dt = parser.parse(time_str)
+
+ # Ensure timezone awareness
+ if parsed_dt.tzinfo is None:
+ # Assume UTC if no timezone info
+ parsed_dt = parsed_dt.replace(tzinfo=timezone.utc)
+ else:
+ # Convert to UTC
+ parsed_dt = parsed_dt.astimezone(timezone.utc)
+ except (ValueError, TypeError) as e:
+ raise ValueError(f"Unable to parse time string '{time_str}': {e}")
+
+ return parsed_dt
+
+
+app = TrackedTyper(
+ cls=TrackedTyperGroup,
+ add_completion=False,
+ no_args_is_help=True,
+ pretty_exceptions_enable=False,
+ pretty_exceptions_show_locals=False,
+ pretty_exceptions_short=True,
+)
+
+state = {
+ "engine_url": compute_base_url(
+ host=PROD_ENGINE_HOST, port=None, force_tls=False, force_no_tls=False
+ )
+}
+
+
+@app.callback()
+def main(
+ host: str = typer.Option(
+ PROD_ENGINE_HOST,
+ "--host",
+ "-h",
+ help="The Arcade Engine host.",
+ ),
+ port: int = typer.Option(
+ None,
+ "--port",
+ "-p",
+ help="The port of the Arcade Engine host.",
+ ),
+ force_tls: bool = typer.Option(
+ False,
+ "--tls",
+ help="Whether to force TLS for the connection to the Arcade Engine.",
+ ),
+ force_no_tls: bool = typer.Option(
+ False,
+ "--no-tls",
+ help="Whether to disable TLS for the connection to the Arcade Engine.",
+ ),
+) -> None:
+ """
+ Manage users in the system.
+ """
+ engine_url = compute_base_url(force_tls, force_no_tls, host, port)
+ state["engine_url"] = engine_url
+
+
+@app.command("list", help="List all servers")
+def list_servers(
+ debug: bool = typer.Option(
+ False,
+ "--debug",
+ "-d",
+ help="Show debug information",
+ ),
+) -> None:
+ config = validate_and_get_config()
+ base_url = state["engine_url"]
+ client = Arcade(api_key=config.api.key, base_url=base_url)
+ try:
+ servers = client.workers.list(limit=100)
+ _print_servers_table(servers.items)
+ except Exception as e:
+ handle_cli_error("Failed to list servers", e, debug=debug)
+
+
+@app.command("get", help="Get a server's details")
+def get_server(
+ server_name: str,
+ debug: bool = typer.Option(
+ False,
+ "--debug",
+ "-d",
+ help="Show debug information",
+ ),
+) -> None:
+ config = validate_and_get_config()
+ base_url = state["engine_url"]
+ client = Arcade(api_key=config.api.key, base_url=base_url)
+ try:
+ server = client.workers.get(server_name)
+ server_health = client.workers.health(server_name)
+ _print_server_details(server, server_health)
+ except Exception as e:
+ handle_cli_error(f"Failed to get server '{server_name}'", e, debug=debug)
+
+
+@app.command("enable", help="Enable a server")
+def enable_server(
+ server_name: str,
+ debug: bool = typer.Option(
+ False,
+ "--debug",
+ "-d",
+ help="Show debug information",
+ ),
+) -> None:
+ config = validate_and_get_config()
+ engine_url = state["engine_url"]
+ arcade = Arcade(api_key=config.api.key, base_url=engine_url)
+ try:
+ arcade.workers.update(server_name, enabled=True)
+ except Exception as e:
+ handle_cli_error(f"Failed to enable worker '{server_name}'", e, debug=debug)
+
+
+@app.command("disable", help="Disable a server")
+def disable_server(
+ server_name: str,
+ debug: bool = typer.Option(
+ False,
+ "--debug",
+ "-d",
+ help="Show debug information",
+ ),
+) -> None:
+ config = validate_and_get_config()
+ engine_url = state["engine_url"]
+ arcade = Arcade(api_key=config.api.key, base_url=engine_url)
+ try:
+ arcade.workers.update(server_name, enabled=False)
+ except Exception as e:
+ handle_cli_error(f"Failed to disable worker '{server_name}'", e, debug=debug)
+
+
+@app.command("delete", help="Delete a server that is managed by Arcade")
+def delete_server(
+ server_name: str,
+ debug: bool = typer.Option(
+ False,
+ "--debug",
+ "-d",
+ help="Show debug information",
+ ),
+) -> None:
+ config = validate_and_get_config()
+ engine_url = state["engine_url"]
+
+ try:
+ arcade = Arcade(api_key=config.api.key, base_url=engine_url)
+ arcade.workers.delete(server_name)
+ console.print(f"✓ Server '{server_name}' deleted successfully", style="green")
+ except NotFoundError as e:
+ handle_cli_error(
+ f"Server '{server_name}' doesn't exist or cannot be deleted", e, debug=debug
+ )
+ except Exception as e:
+ handle_cli_error(
+ f"Server '{server_name}' doesn't exist or cannot be deleted", e, debug=debug
+ )
+
+
+@app.command("logs", help="Get logs for a server that is managed by Arcade")
+def get_server_logs(
+ server_name: str,
+ follow: bool = typer.Option(
+ False,
+ "--follow",
+ "-f",
+ is_flag=True,
+ help="Follow (stream) the log output in real-time",
+ rich_help_panel="Streaming Options",
+ ),
+ since: Optional[str] = typer.Option(
+ None,
+ "--since",
+ "-s",
+ help="Show logs since timestamp (e.g., 2025-10-03T12:24:36Z) or relative (e.g., 42m for 42 minutes ago). Defaults to 1h (1 hour ago) for non-streaming, 0s (now) for streaming.",
+ rich_help_panel="Time Range Options",
+ ),
+ until: Optional[str] = typer.Option(
+ None,
+ "--until",
+ "-u",
+ help="Show logs until timestamp (e.g., 2025-10-03T12:24:36Z) or relative (e.g., 42m for 42 minutes ago). Defaults to 0s (now).",
+ rich_help_panel="Time Range Options",
+ ),
+ debug: bool = typer.Option(
+ False,
+ "--debug",
+ "-d",
+ help="Show debug information",
+ ),
+) -> None:
+ config = validate_and_get_config()
+ headers = {"Authorization": f"Bearer {config.api.key}", "Content-Type": "application/json"}
+
+ # Set defaults based on whether we're following or not
+ if since is None:
+ since = "0s" if follow else "1h"
+ if until is None:
+ until = "0s"
+
+ try:
+ # Parse time strings to UTC datetime objects
+ since_dt = _parse_time_string(since)
+ until_dt = _parse_time_string(until)
+
+ # Validate that since is before until
+ if since_dt >= until_dt:
+ raise ValueError(f"'since' time ({since}) must be before 'until' time ({until})") # noqa: TRY301
+ except ValueError as e:
+ handle_cli_error(f"Invalid time format: {e}", debug=debug)
+
+ if follow:
+ # Use the streaming endpoint
+ engine_url = state["engine_url"] + f"/v1/deployments/{server_name}/logs/stream"
+ asyncio.run(_stream_deployment_logs(engine_url, headers, since_dt, until_dt, debug=debug))
+ else:
+ # Use the non-streaming endpoint
+ engine_url = state["engine_url"] + f"/v1/deployments/{server_name}/logs"
+ _display_deployment_logs(engine_url, headers, since_dt, until_dt, debug=debug)
+
+
+def _display_deployment_logs(
+ engine_url: str, headers: dict, since: datetime, until: datetime, debug: bool
+) -> None:
+ try:
+ with httpx.Client() as client:
+ params = {"start_time_utc": since.isoformat(), "end_time_utc": until.isoformat()}
+ response = client.get(engine_url, headers=headers, params=params)
+ response.raise_for_status()
+ logs = response.json()
+ for log in logs:
+ formatted_timestamp = _format_timestamp_to_local(log["timestamp"])
+ print(f"[{formatted_timestamp}] {log['line']}")
+ except httpx.HTTPStatusError as e:
+ handle_cli_error(
+ f"Failed to fetch logs: {e.response.status_code} {e.response.text}", debug=debug
+ )
+ except Exception as e:
+ handle_cli_error(f"Error fetching logs: {e}", debug=debug)
+
+
+async def _stream_deployment_logs(
+ engine_url: str, headers: dict, since: datetime, until: datetime, debug: bool
+) -> None:
+ try:
+ async with (
+ httpx.AsyncClient(timeout=None) as client, # noqa: S113 - expected indefinite log stream
+ client.stream(
+ "GET",
+ engine_url,
+ headers=headers,
+ params={"start_time_utc": since.isoformat(), "end_time_utc": until.isoformat()},
+ ) as response,
+ ):
+ response.raise_for_status()
+ async for line in response.aiter_lines():
+ if not line:
+ continue
+
+ # Handle SSE format: "data: {json}"
+ if line.startswith("data: "):
+ try:
+ data = json.loads(line[6:])
+ timestamp_str = data.get("Timestamp", "")
+ log_line = data.get("Line", "")
+ formatted_timestamp = _format_timestamp_to_local(timestamp_str)
+ print(f"[{formatted_timestamp}] {log_line}")
+ except (json.JSONDecodeError, KeyError, IndexError):
+ print(line)
+ else:
+ print(line)
+ except httpx.HTTPStatusError as e:
+ handle_cli_error(f"Failed to stream logs: {e.response.status_code}", debug=debug)
+ except Exception as e:
+ handle_cli_error(f"Error streaming logs: {e}", debug=debug)
+
+
+def _print_servers_table(servers: list[WorkerResponse]) -> None:
+ if not servers:
+ console.print("No servers found", style="bold red")
+ return
+
+ table = Table(title="Servers")
+ table.add_column("Name")
+ table.add_column("Enabled")
+ table.add_column("Host")
+ table.add_column("Managed by Arcade")
+
+ for server in servers:
+ if server.id is None:
+ continue
+ uri = server.http.uri if server.http and server.http.uri else "N/A"
+ table.add_row(
+ server.id,
+ str(server.enabled),
+ uri,
+ str(server.managed),
+ )
+ console.print(table)
+
+
+def _print_server_details(server: WorkerResponse, server_health: WorkerHealthResponse) -> None:
+ table = Table(title="Server Details")
+ table.add_column("Name")
+ table.add_column("Enabled")
+ table.add_column("Is Healthy")
+ table.add_column("Host")
+ table.add_column("Managed by Arcade")
+ uri = server.http.uri if server.http and server.http.uri else "N/A"
+ table.add_row(
+ server.id, str(server.enabled), str(server_health.healthy), uri, str(server.managed)
+ )
+ console.print(table)
diff --git a/libs/arcade-cli/arcade_cli/worker.py b/libs/arcade-cli/arcade_cli/worker.py
deleted file mode 100644
index 4cbe4b36..00000000
--- a/libs/arcade-cli/arcade_cli/worker.py
+++ /dev/null
@@ -1,347 +0,0 @@
-import httpx
-import typer
-from arcadepy import Arcade, NotFoundError
-from rich.console import Console
-from rich.table import Table
-
-from arcade_cli.constants import (
- PROD_CLOUD_HOST,
- PROD_ENGINE_HOST,
-)
-from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup
-from arcade_cli.utils import (
- CLIError,
- compute_base_url,
- handle_cli_error,
- validate_and_get_config,
-)
-
-console = Console()
-
-
-app = TrackedTyper(
- cls=TrackedTyperGroup,
- add_completion=False,
- no_args_is_help=True,
- pretty_exceptions_enable=False,
- pretty_exceptions_show_locals=False,
- pretty_exceptions_short=True,
-)
-
-state = {
- "engine_url": compute_base_url(
- host=PROD_ENGINE_HOST, port=None, force_tls=False, force_no_tls=False
- )
-}
-
-
-@app.callback()
-def main(
- host: str = typer.Option(
- PROD_ENGINE_HOST,
- "--host",
- "-h",
- help="The Arcade Engine host.",
- ),
- port: int = typer.Option(
- None,
- "--port",
- "-p",
- help="The port of the Arcade Engine host.",
- ),
- force_tls: bool = typer.Option(
- False,
- "--tls",
- help="Whether to force TLS for the connection to the Arcade Engine.",
- ),
- force_no_tls: bool = typer.Option(
- False,
- "--no-tls",
- help="Whether to disable TLS for the connection to the Arcade Engine.",
- ),
-) -> None:
- """
- Manage users in the system.
- """
- engine_url = compute_base_url(force_tls, force_no_tls, host, port)
- state["engine_url"] = engine_url
-
-
-@app.command("list", help="List all workers")
-def list_workers(
- cloud_host: str = typer.Option(
- PROD_CLOUD_HOST,
- "--cloud-host",
- "-c",
- help="The Arcade Engine host.",
- hidden=True,
- ),
- cloud_port: int = typer.Option(
- None,
- "--cloud-port",
- "-cp",
- help="The port of the Arcade Engine host.",
- hidden=True,
- ),
- force_tls: bool = typer.Option(
- False,
- "--tls",
- help="Whether to force TLS for the connection to the Arcade Engine.",
- hidden=True,
- ),
- force_no_tls: bool = typer.Option(
- False,
- "--no-tls",
- help="Whether to disable TLS for the connection to the Arcade Engine.",
- hidden=True,
- ),
-) -> None:
- config = validate_and_get_config()
- engine_url = state["engine_url"]
- client = Arcade(api_key=config.api.key, base_url=engine_url)
- deployments = []
- try:
- cloud_url = compute_base_url(force_tls, force_no_tls, cloud_host, cloud_port)
- cloud_client = httpx.Client(base_url=cloud_url)
- response = cloud_client.get(
- "/api/v1/workers", headers={"Authorization": f"Bearer {config.api.key}"}
- )
- response.raise_for_status()
- deployments = response.json()["data"]["workers"]
- except Exception as e:
- handle_cli_error(f"Failed to list deployed servers: {e}")
-
- print_worker_table(client, deployments)
-
-
-def print_worker_table(client: Arcade, deployments: list[dict]) -> None:
- workers = client.workers.list()
- if not workers.items:
- console.print("No workers found", style="bold red")
- return
-
- # Create and print a table of worker information
- table = Table(title="Workers")
- table.add_column("ID")
- table.add_column("Cloud Deployed")
- table.add_column("Engine Registered")
- table.add_column("Enabled")
- table.add_column("Host")
- table.add_column("Toolkits")
-
- # Track workers that are registered in the engine
- engine_workers = []
- for worker in workers.items:
- if worker.id is None:
- continue
- engine_workers.append(worker.id)
- # Check if the worker is deployed in the cloud
- is_deployed = is_cloud_deployment(worker.id, deployments)
- # Get the toolkits for the worker
-
- tools = get_toolkits(client, worker.id)
- uri = worker.http.uri if worker.http and worker.http.uri else ""
- table.add_row(
- worker.id,
- str(is_deployed),
- str(True),
- str(worker.enabled),
- compare_endpoints(worker.id, uri, deployments),
- "Could not fetch toolkits" if tools == "" else tools,
- )
- for deployment in deployments:
- if deployment["name"] not in engine_workers:
- table.add_row(deployment["name"], "True", "False", "False", deployment["endpoint"], "")
- console.print(table)
-
-
-# Check if the worker is in the list of cloud deployments
-def is_cloud_deployment(name: str, deployments: list[dict]) -> bool:
- return any(deployment["name"] == name for deployment in deployments)
-
-
-# Compare the endpoint of the worker in the engine to the endpoint in the cloud
-# Return a highlighted diff if the endpoint in the engine is different from the endpoint in the cloud
-def compare_endpoints(worker_id: str, engine_endpoint: str, deployments: list[dict]) -> str:
- if is_cloud_deployment(worker_id, deployments):
- for deployment in deployments:
- deployment_endpoint = deployment["endpoint"]
- if deployment["name"] == worker_id:
- if deployment_endpoint == engine_endpoint:
- return engine_endpoint
- else:
- return f"[red]Endpoint Mismatch[/red]\n[yellow]Registered Endpoint: {engine_endpoint}[/yellow]\n[green]Actual Endpoint: {deployment_endpoint}[/green]"
- return engine_endpoint
-
-
-@app.command("enable", help="Enable a worker")
-def enable_worker(
- worker_id: str,
-) -> None:
- config = validate_and_get_config()
- engine_url = state["engine_url"]
- arcade = Arcade(api_key=config.api.key, base_url=engine_url)
- try:
- arcade.workers.update(worker_id, enabled=True)
- except Exception as e:
- handle_cli_error(f"Failed to enable worker '{worker_id}': {e}")
-
-
-@app.command("disable", help="Disable a worker")
-def disable_worker(
- worker_id: str,
-) -> None:
- config = validate_and_get_config()
- engine_url = state["engine_url"]
- arcade = Arcade(api_key=config.api.key, base_url=engine_url)
- try:
- arcade.workers.update(worker_id, enabled=False)
- except Exception as e:
- handle_cli_error(f"Failed to disable worker '{worker_id}': {e}")
-
-
-@app.command("rm", help="Remove a worker")
-def rm_worker(
- worker_id: str,
- engine_only: bool = typer.Option(
- False,
- "--deregister",
- "-d",
- help="Deregister the worker from the engine",
- ),
- cloud_host: str = typer.Option(
- PROD_CLOUD_HOST,
- "--cloud-host",
- "-c",
- help="The Arcade Engine host.",
- hidden=True,
- ),
- cloud_port: int = typer.Option(
- None,
- "--cloud-port",
- "-cp",
- help="The port of the Arcade Engine host.",
- hidden=True,
- ),
- force_tls: bool = typer.Option(
- False,
- "--tls",
- help="Whether to force TLS for the connection to the Arcade Engine.",
- hidden=True,
- ),
- force_no_tls: bool = typer.Option(
- False,
- "--no-tls",
- help="Whether to disable TLS for the connection to the Arcade Engine.",
- hidden=True,
- ),
-) -> None:
- config = validate_and_get_config()
- engine_url = state["engine_url"]
- cloud_url = compute_base_url(force_tls, force_no_tls, cloud_host, cloud_port)
-
- # First attempt to delete from the cloud
- if not engine_only:
- try:
- client = httpx.Client()
- response = client.delete(
- f"{cloud_url}/api/v1/workers/{worker_id}",
- headers={"Authorization": f"Bearer {config.api.key}"},
- )
- response.raise_for_status()
- except httpx.HTTPStatusError as e:
- if e.response.status_code == 404:
- handle_cli_error(
- "Deployment not found. To deregister the worker from the engine, use the --deregister flag."
- )
- else:
- handle_cli_error(f"Error deleting deployment: {e}")
- except Exception as e:
- handle_cli_error(f"Error deleting deployment: {e}")
-
- # Then try to delete from the engine
- try:
- arcade = Arcade(api_key=config.api.key, base_url=engine_url)
- arcade.workers.delete(worker_id)
- except NotFoundError:
- console.print("Worker not found", style="bold red")
- except Exception as e:
- handle_cli_error(f"Error deleting worker from engine: {e}")
-
-
-@app.command("logs", help="Get logs for a worker")
-def worker_logs(
- worker_id: str,
- cloud_host: str = typer.Option(
- PROD_CLOUD_HOST,
- "--cloud-host",
- "-c",
- help="The Arcade Engine host.",
- hidden=True,
- ),
- cloud_port: int = typer.Option(
- None,
- "--cloud-port",
- "-cp",
- help="The port of the Arcade Engine host.",
- hidden=True,
- ),
- force_tls: bool = typer.Option(
- False,
- "--tls",
- help="Whether to force TLS for the connection to the Arcade Engine.",
- hidden=True,
- ),
- force_no_tls: bool = typer.Option(
- False,
- "--no-tls",
- help="Whether to disable TLS for the connection to the Arcade Engine.",
- hidden=True,
- ),
-) -> None:
- config = validate_and_get_config()
- cloud_url = compute_base_url(force_tls, force_no_tls, cloud_host, cloud_port)
- try:
- with httpx.stream(
- "GET",
- f"{cloud_url}/api/v1/workers/logs/{worker_id}",
- headers={"Authorization": f"Bearer {config.api.key}", "Accept": "text/event-stream"},
- # allow the connection to stay open indefinitely
- timeout=None, # noqa: S113
- ) as s:
- for line in s.iter_lines():
- if not line or "[DONE]" in line: # Skip empty lines
- continue
- if "event: error" in line:
- handle_cli_error("Could not stream logs")
- if line.startswith("data:"):
- # Extract just the data portion after 'data:'
- data = line[5:].strip() # Remove 'data:' prefix and whitespace
- console.print(data, markup=False)
- except CLIError:
- raise
- except Exception as e:
- handle_cli_error(f"Error connecting to log stream: {e}")
-
-
-def get_toolkits(client: Arcade, worker_id: str | None) -> str:
- if worker_id is None:
- return ""
- try:
- # Get tools for the given worker
- tools = client.workers.tools(worker_id)
- toolkits: list[str] = []
- if not tools.items:
- return ""
-
- # Get toolkit names
- for page in tools.iter_pages():
- for tool in page.items:
- if tool.toolkit.name not in toolkits:
- toolkits.append(tool.toolkit.name)
- return ", ".join(toolkits)
- except NotFoundError:
- return ""
- except Exception as e:
- handle_cli_error(f"Error getting server tools: {e}")
- return ""
diff --git a/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py b/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py
index 70c4a03d..549a7f87 100644
--- a/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py
+++ b/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py
@@ -8,6 +8,7 @@ from __future__ import annotations
import asyncio
import os
+import re
import subprocess
import sys
from pathlib import Path
@@ -91,7 +92,7 @@ class MCPApp:
reload: Enable auto-reload for development
**kwargs: Additional server configuration
"""
- self.name = name
+ self._name = self._validate_name(name)
self.version = version
self.title = title or name
self.instructions = instructions
@@ -111,7 +112,7 @@ class MCPApp:
self._mcp_settings = MCPSettings(
server=ServerSettings(
- name=self.name,
+ name=self._name,
version=self.version,
title=self.title,
instructions=self.instructions,
@@ -122,7 +123,57 @@ class MCPApp:
if not logger._core.handlers: # type: ignore[attr-defined]
self._setup_logging(transport == "stdio")
+ def _validate_name(self, name: str) -> str:
+ """
+ Validate that the name follows the required pattern:
+ - Alphanumeric characters and underscores only
+ - Must end with alphanumeric character
+ - Cannot start with underscore
+ - Cannot have consecutive underscores
+
+ Args:
+ name: The name to validate
+
+ Returns:
+ The validated name
+
+ Raises:
+ TypeError: If the name is not a string
+ ValueError: If the name doesn't follow the required pattern
+ """
+ if not isinstance(name, str):
+ raise TypeError("MCPApp's name must be a string")
+
+ if not name:
+ raise ValueError("MCPApp's name cannot be empty")
+
+ if not re.match(r"^[a-zA-Z0-9_]+$", name):
+ raise ValueError(
+ "MCPApp's name must contain only alphanumeric characters and underscores"
+ )
+
+ if name.startswith("_"):
+ raise ValueError("MCPApp's name cannot start with an underscore")
+
+ if "__" in name:
+ raise ValueError("MCPApp's name cannot have consecutive underscores")
+
+ if not re.match(r".*[a-zA-Z0-9]$", name):
+ raise ValueError("MCPApp's name must end with an alphanumeric character")
+
+ return name
+
# Properties (exposed below initializer)
+ @property
+ def name(self) -> str:
+ """Get the server name."""
+ return self._name
+
+ @name.setter
+ def name(self, value: str) -> None:
+ """Set the server name with validation."""
+ self._name = self._validate_name(value)
+
@property
def tools(self) -> _ToolsAPI:
"""Runtime and build-time tools API: add/update/remove/list."""
@@ -245,7 +296,7 @@ class MCPApp:
# parent watcher has already been setup
reload = False
- logger.info(f"Starting {self.name} v{self.version} with {len(self._catalog)} tools")
+ logger.info(f"Starting {self._name} v{self.version} with {len(self._catalog)} tools")
if transport in ["http", "streamable-http", "streamable"]:
if reload:
diff --git a/libs/arcade-mcp-server/pyproject.toml b/libs/arcade-mcp-server/pyproject.toml
index bccb6a7a..1542ec5a 100644
--- a/libs/arcade-mcp-server/pyproject.toml
+++ b/libs/arcade-mcp-server/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "arcade-mcp-server"
-version = "1.6.1"
+version = "1.7.0"
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
readme = "README.md"
authors = [{ name = "Arcade.dev" }]
diff --git a/libs/tests/arcade_mcp_server/test_mcp_app.py b/libs/tests/arcade_mcp_server/test_mcp_app.py
index 7d717bb0..4192b51e 100644
--- a/libs/tests/arcade_mcp_server/test_mcp_app.py
+++ b/libs/tests/arcade_mcp_server/test_mcp_app.py
@@ -327,9 +327,10 @@ class TestMCPApp:
def test_create_and_run_server(self, mcp_app: MCPApp):
"""Test _create_and_run_server method with mocked dependencies."""
- with patch("arcade_mcp_server.mcp_app.create_arcade_mcp") as mock_create, patch(
- "arcade_mcp_server.mcp_app.uvicorn"
- ) as mock_uvicorn:
+ with (
+ patch("arcade_mcp_server.mcp_app.create_arcade_mcp") as mock_create,
+ patch("arcade_mcp_server.mcp_app.uvicorn") as mock_uvicorn,
+ ):
mock_fastapi_app = Mock()
mock_create.return_value = mock_fastapi_app
@@ -352,9 +353,10 @@ class TestMCPApp:
)
# Test with DEBUG log level
- with patch("arcade_mcp_server.mcp_app.create_arcade_mcp") as mock_create, patch(
- "arcade_mcp_server.mcp_app.uvicorn"
- ) as mock_uvicorn:
+ with (
+ patch("arcade_mcp_server.mcp_app.create_arcade_mcp") as mock_create,
+ patch("arcade_mcp_server.mcp_app.uvicorn") as mock_uvicorn,
+ ):
mock_fastapi_app = Mock()
mock_create.return_value = mock_fastapi_app
@@ -381,9 +383,10 @@ class TestMCPApp:
mock_process.terminate = Mock()
mock_process.wait = Mock()
- with patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen, patch(
- "arcade_mcp_server.mcp_app.watch"
- ) as mock_watch:
+ with (
+ patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen,
+ patch("arcade_mcp_server.mcp_app.watch") as mock_watch,
+ ):
mock_popen.return_value = mock_process
# Return empty iterator to exit immediately
mock_watch.return_value = iter([])
@@ -401,9 +404,10 @@ class TestMCPApp:
mock_process1 = Mock()
mock_process2 = Mock()
- with patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen, patch(
- "arcade_mcp_server.mcp_app.watch"
- ) as mock_watch:
+ with (
+ patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen,
+ patch("arcade_mcp_server.mcp_app.watch") as mock_watch,
+ ):
mock_popen.side_effect = [mock_process1, mock_process2]
# Yield one set of changes then stop
mock_watch.return_value = iter([{("change", "test.py")}])
@@ -422,9 +426,10 @@ class TestMCPApp:
mock_process = Mock()
mock_process.wait = Mock() # Succeeds without timeout
- with patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen, patch(
- "arcade_mcp_server.mcp_app.watch"
- ) as mock_watch:
+ with (
+ patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen,
+ patch("arcade_mcp_server.mcp_app.watch") as mock_watch,
+ ):
mock_popen.return_value = mock_process
mock_watch.return_value = iter([{("change", "test.py")}])
@@ -439,13 +444,12 @@ class TestMCPApp:
"""Test _run_with_reload force kills process on timeout."""
mock_process = Mock()
# First wait times out, second succeeds
- mock_process.wait = Mock(
- side_effect=[subprocess.TimeoutExpired("cmd", 5), None]
- )
+ mock_process.wait = Mock(side_effect=[subprocess.TimeoutExpired("cmd", 5), None])
- with patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen, patch(
- "arcade_mcp_server.mcp_app.watch"
- ) as mock_watch:
+ with (
+ patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen,
+ patch("arcade_mcp_server.mcp_app.watch") as mock_watch,
+ ):
mock_popen.return_value = mock_process
mock_watch.return_value = iter([{("change", "test.py")}])
@@ -460,9 +464,10 @@ class TestMCPApp:
"""Test _run_with_reload handles KeyboardInterrupt gracefully."""
mock_process = Mock()
- with patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen, patch(
- "arcade_mcp_server.mcp_app.watch"
- ) as mock_watch:
+ with (
+ patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen,
+ patch("arcade_mcp_server.mcp_app.watch") as mock_watch,
+ ):
mock_popen.return_value = mock_process
mock_watch.side_effect = KeyboardInterrupt()
@@ -474,9 +479,10 @@ class TestMCPApp:
def test_run_routes_to_reload_method(self, mcp_app: MCPApp):
"""Test run() routes to _run_with_reload when reload=True."""
- with patch.object(mcp_app, "_run_with_reload") as mock_reload, patch.object(
- mcp_app, "_create_and_run_server"
- ) as mock_direct:
+ with (
+ patch.object(mcp_app, "_run_with_reload") as mock_reload,
+ patch.object(mcp_app, "_create_and_run_server") as mock_direct,
+ ):
mcp_app.run(reload=True, transport="http", host="127.0.0.1", port=8000)
mock_reload.assert_called_once_with("127.0.0.1", 8000)
@@ -484,9 +490,10 @@ class TestMCPApp:
def test_run_routes_to_direct_method(self, mcp_app: MCPApp):
"""Test run() routes to _create_and_run_server when reload=False."""
- with patch.object(mcp_app, "_run_with_reload") as mock_reload, patch.object(
- mcp_app, "_create_and_run_server"
- ) as mock_direct:
+ with (
+ patch.object(mcp_app, "_run_with_reload") as mock_reload,
+ patch.object(mcp_app, "_create_and_run_server") as mock_direct,
+ ):
mcp_app.run(reload=False, transport="http", host="127.0.0.1", port=8000)
mock_direct.assert_called_once_with("127.0.0.1", 8000)
@@ -496,9 +503,10 @@ class TestMCPApp:
"""Test run() disables reload when ARCADE_MCP_CHILD_PROCESS is set."""
monkeypatch.setenv("ARCADE_MCP_CHILD_PROCESS", "1")
- with patch.object(mcp_app, "_run_with_reload") as mock_reload, patch.object(
- mcp_app, "_create_and_run_server"
- ) as mock_direct:
+ with (
+ patch.object(mcp_app, "_run_with_reload") as mock_reload,
+ patch.object(mcp_app, "_create_and_run_server") as mock_direct,
+ ):
mcp_app.run(reload=True, transport="http", host="127.0.0.1", port=8000)
# Should route to direct method even though reload=True
@@ -517,3 +525,105 @@ class TestMCPApp:
# Test with reload=False
mcp_app.run(reload=False, transport="stdio")
mock_stdio.assert_called_once()
+
+ @pytest.mark.parametrize(
+ "name,expected_result",
+ [
+ # Valid names
+ ("ValidName", "ValidName"),
+ ("valid_name", "valid_name"),
+ ("ValidName123", "ValidName123"),
+ ("valid_name_123", "valid_name_123"),
+ ("a", "a"),
+ ("A", "A"),
+ ("1", "1"),
+ ("name1", "name1"),
+ ("Name1", "Name1"),
+ ("validName", "validName"),
+ ("Valid_Name", "Valid_Name"),
+ ("valid_name_test", "valid_name_test"),
+ ("Test123Name", "Test123Name"),
+ ("a1b2c3", "a1b2c3"),
+ ("A1B2C3", "A1B2C3"),
+ ],
+ )
+ def test_validate_name_valid_names(self, name: str, expected_result: str):
+ """Test _validate_name with valid names."""
+ app = MCPApp()
+ result = app._validate_name(name)
+ assert result == expected_result
+
+ @pytest.mark.parametrize(
+ "name,expected_error",
+ [
+ # Empty name
+ ("", ValueError),
+ # Non-string types
+ (None, TypeError),
+ (123, TypeError),
+ ([], TypeError),
+ ({}, TypeError),
+ # Names starting with underscore
+ ("_invalid", ValueError),
+ ("_name", ValueError),
+ ("_123", ValueError),
+ ("_", ValueError),
+ # Names with consecutive underscores
+ ("name__test", ValueError),
+ ("test__name", ValueError),
+ ("__name", ValueError),
+ ("name__", ValueError),
+ ("__", ValueError),
+ # Names ending with underscore
+ ("name_", ValueError),
+ ("test_", ValueError),
+ ("_", ValueError),
+ # Names with invalid characters
+ ("name-test", ValueError),
+ ("name.test", ValueError),
+ ("name test", ValueError),
+ ("name@test", ValueError),
+ ("name#test", ValueError),
+ ("name$test", ValueError),
+ ("name%test", ValueError),
+ ("name^test", ValueError),
+ ("name&test", ValueError),
+ ("name*test", ValueError),
+ ("name+test", ValueError),
+ ("name=test", ValueError),
+ ("name[test", ValueError),
+ ("name]test", ValueError),
+ ("name{test", ValueError),
+ ("name}test", ValueError),
+ ("name|test", ValueError),
+ ("name\\test", ValueError),
+ ("name:test", ValueError),
+ ("name;test", ValueError),
+ ("name'test", ValueError),
+ ('name"test', ValueError),
+ ("nametest", ValueError),
+ ("name,test", ValueError),
+ ("name.test", ValueError),
+ ("name?test", ValueError),
+ ("name/test", ValueError),
+ ("name!test", ValueError),
+ ("name~test", ValueError),
+ ("name`test", ValueError),
+ # Names with spaces
+ ("name test", ValueError),
+ (" name", ValueError),
+ ("name ", ValueError),
+ (" name ", ValueError),
+ # Names with special unicode characters
+ ("nameñ", ValueError),
+ ("nameé", ValueError),
+ ("name中", ValueError),
+ ("name🚀", ValueError),
+ ],
+ )
+ def test_validate_name_invalid_names(self, name, expected_error):
+ """Test _validate_name with invalid names."""
+ app = MCPApp()
+ with pytest.raises(expected_error):
+ app._validate_name(name)
diff --git a/pyproject.toml b/pyproject.toml
index 847d9a96..db1cad14 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "arcade-mcp"
-version = "1.4.0"
+version = "1.5.0"
description = "Arcade.dev - Tool Calling platform for Agents"
readme = "README.md"
license = {file = "LICENSE"}
@@ -21,7 +21,7 @@ requires-python = ">=3.10"
dependencies = [
# CLI dependencies
- "arcade-mcp-server>=1.5.0,<2.0.0",
+ "arcade-mcp-server>=1.7.0,<2.0.0",
"arcade-core>=3.0.0,<4.0.0",
"typer==0.10.0",
"rich==13.9.4",
@@ -42,7 +42,7 @@ all = [
"pytz>=2024.1",
"python-dateutil>=2.8.2",
# mcp
- "arcade-mcp-server>=1.5.0,<2.0.0",
+ "arcade-mcp-server>=1.7.0,<2.0.0",
# serve
"arcade-serve>=3.0.0,<4.0.0",
# tdk