Improve arcade deploy CLI Command (#634)

Also fleshed out `arcade server` commands and MCPApp.name validation.

Example of output of `arcade deploy`:
<img width="2112" height="1320" alt="image"
src="https://github.com/user-attachments/assets/51fd3dd9-0ff1-442c-a9bb-1dbcd7337e7a"
/>
This commit is contained in:
Eric Gustin 2025-11-03 11:19:04 -08:00 committed by GitHub
parent 5f89497198
commit 4ca824cf8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 903 additions and 454 deletions

View file

@ -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")

View file

@ -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",

View file

@ -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"

View file

@ -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)

View file

@ -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 ""

View file

@ -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:

View file

@ -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" }]

View file

@ -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),
("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),
# 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)

View file

@ -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