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`: image --- 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