arcade-mcp/libs/arcade-cli/arcade_cli/main.py
Eric Gustin bbba7aec90
Update arcade new --full (#805)
Monorepo has new linting and formatting preferences. Updated the
`--full` to reflect that.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes only affect the generated `--full` project template
and CLI help surface (flag is now hidden), plus a patch version bump.
> 
> **Overview**
> Updates `arcade new --full` to be *internal-only* by hiding the flag
and revises the full template to match monorepo conventions
(Ruff/pre-commit versions and hook IDs, `.ruff.toml` now extends the
repo config, `pyproject.toml` formatting/booleans, adds `pytest`
`asyncio_mode`, and removes `pre-commit install` from the template
`Makefile`).
> 
> Adds a regression test ensuring generated full-template files match
these conventions, and bumps the root package version to `1.12.2`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
2c1a285752d67dc4dd1aa8e0b6f25ca2f0a33fa2. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-03-27 14:22:32 -07:00

998 lines
34 KiB
Python

import asyncio
import os
import subprocess
import sys
from pathlib import Path
from typing import Optional
import click
import typer
from arcade_core.constants import CREDENTIALS_FILE_PATH, PROD_COORDINATOR_HOST, PROD_ENGINE_HOST
from arcade_core.subprocess_utils import get_windows_no_window_creationflags
from arcadepy import Arcade
from arcade_cli.authn import (
DEFAULT_OAUTH_TIMEOUT_SECONDS,
OAuthLoginError,
_credentials_file_contains_legacy,
_open_browser,
build_coordinator_url,
check_existing_login,
perform_oauth_login,
save_credentials_from_whoami,
)
from arcade_cli.console import console
from arcade_cli.evals_runner import run_capture, run_evaluations
from arcade_cli.org import app as org_app
from arcade_cli.project import app as project_app
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 (
ModelSpec,
Provider,
compute_base_url,
expand_provider_configs,
get_default_model,
get_eval_files,
handle_cli_error,
load_eval_suites,
log_engine_health,
parse_output_paths,
parse_provider_spec,
require_dependency,
resolve_provider_api_keys,
version_callback,
)
cli = TrackedTyper(
cls=TrackedTyperGroup,
add_completion=False,
no_args_is_help=True,
pretty_exceptions_enable=True,
pretty_exceptions_show_locals=False,
pretty_exceptions_short=True,
rich_markup_mode="markdown",
context_settings={"help_option_names": ["-h", "--help"]},
help="Arcade CLI - Build, deploy, and manage MCP servers and AI tools. Create new projects, run servers with multiple transports, configure clients, and deploy to Arcade Cloud.",
epilog="Pro tip: use --help after any command to see command-specific options.",
)
cli.add_typer(
server_app,
name="server",
help="Manage deployments of tool servers (logs, list, etc)",
rich_help_panel="Manage",
)
cli.add_typer(
secret_app,
name="secret",
help="Manage tool secrets in the cloud (set, unset, list)",
rich_help_panel="Manage",
)
@cli.command(help="Log in to Arcade", rich_help_panel="User")
def login(
host: str = typer.Option(
PROD_COORDINATOR_HOST,
"-h",
"--host",
help="The Arcade Coordinator host to log in to.",
),
port: Optional[int] = typer.Option(
None,
"-p",
"--port",
help="The port of the Arcade Coordinator host (if running locally).",
),
timeout: int = typer.Option(
DEFAULT_OAUTH_TIMEOUT_SECONDS,
"--timeout",
help="Seconds to wait for the local login callback.",
),
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
) -> None:
"""
Logs the user into Arcade using OAuth.
"""
if check_existing_login():
console.print("\nTo log out and delete your locally-stored credentials, use ", end="")
console.print("arcade logout", style="bold green", end="")
console.print(".\n")
return
coordinator_url = build_coordinator_url(host, port)
try:
result = perform_oauth_login(
coordinator_url,
on_status=lambda msg: console.print(msg, style="dim"),
callback_timeout_seconds=timeout,
)
# Save credentials
save_credentials_from_whoami(result.tokens, result.whoami, coordinator_url)
# Success message
console.print(f"\n✅ Logged in as {result.email}.", style="bold green")
if result.selected_org and result.selected_project:
console.print(
f"\nActive project: {result.selected_org.name} / {result.selected_project.name}",
style="dim",
)
console.print(
"Run 'arcade org list' or 'arcade project list' to see available options.",
style="dim",
)
except OAuthLoginError as e:
if debug:
console.print(f"Debug: {e.__cause__}", style="dim")
handle_cli_error(str(e), should_exit=True)
except KeyboardInterrupt:
console.print("\nLogin cancelled.", style="yellow")
except Exception as e:
handle_cli_error("Login failed", e, debug)
@cli.command(help="Log out of Arcade", rich_help_panel="User")
def logout(
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
) -> None:
"""
Logs the user out of Arcade.
"""
try:
# If the credentials file exists, delete it
if os.path.exists(CREDENTIALS_FILE_PATH):
os.remove(CREDENTIALS_FILE_PATH)
console.print("You're now logged out.", style="bold")
else:
console.print("You're not logged in.", style="bold red")
except PermissionError:
# On Windows, the file may be locked by another process.
handle_cli_error(
"Could not remove credentials file — it may be in use by another process. "
"Close other Arcade instances and try again.",
should_exit=True,
)
except Exception as e:
handle_cli_error("Logout failed", e, debug)
@cli.command(help="Show current login status and active context", rich_help_panel="User")
def whoami(
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
) -> None:
"""
Display the current logged-in user and active organization/project.
"""
from arcade_core.config_model import Config
try:
config = Config.load_from_file()
except FileNotFoundError:
console.print("Not logged in. Run 'arcade login' to authenticate.", style="bold red")
return
except Exception as e:
handle_cli_error("Failed to read credentials", e, debug, should_exit=True)
return
# Defensive - should not happen, because the main() callback prevents this:
if not config.auth:
console.print("Not logged in. Run 'arcade login' to authenticate.", style="bold red")
return
email = config.user.email if config.user else "unknown"
console.print(f"Logged in as: {email}", style="bold green")
if config.context:
console.print(f"\nActive organization: {config.context.org_name}", style="bold")
console.print(f" ID: {config.context.org_id}", style="dim")
console.print(f"\nActive project: {config.context.project_name}", style="bold")
console.print(f" ID: {config.context.project_id}", style="dim")
else:
console.print("\nNo active organization/project set.", style="yellow")
console.print("\nRun 'arcade org list' or 'arcade project list' to see options.", style="dim")
cli.add_typer(
org_app,
name="org",
help="Manage organizations (list, set active)",
rich_help_panel="User",
)
cli.add_typer(
project_app,
name="project",
help="Manage projects (list, set active)",
rich_help_panel="User",
)
@cli.command(
help="Create a new server package directory. Example usage: `arcade new my_mcp_server`",
rich_help_panel="Build",
)
def new(
server_name: str = typer.Argument(
help="The name of the server to create",
metavar="SERVER_NAME",
),
directory: str = typer.Option(os.getcwd(), "--dir", help="tools directory path"),
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
full: bool = typer.Option(
False,
"-f",
"--full",
help="[Internal] Create a full toolkit scaffold for Arcade development.",
hidden=True,
),
) -> None:
"""
Creates a new MCP server with the given name
"""
from arcade_cli.new import create_new_toolkit, create_new_toolkit_minimal
try:
if not full:
create_new_toolkit_minimal(directory, server_name)
else:
create_new_toolkit(directory, server_name)
except Exception as e:
handle_cli_error("Failed to create new server", e, debug)
@cli.command(
name="mcp",
help="Run MCP servers with different transports",
rich_help_panel="Run",
)
def mcp(
transport: str = typer.Argument("http", help="Transport type: stdio, http"),
host: str = typer.Option("127.0.0.1", "--host", help="Host to bind to (HTTP mode only)"),
port: int = typer.Option(8000, "--port", help="Port to bind to (HTTP mode only)"),
tool_package: Optional[str] = typer.Option(
None,
"--tool-package",
"--package",
"-p",
help="Specific tool package to load (e.g., 'github' for arcade-github)",
),
discover_installed: bool = typer.Option(
False, "--discover-installed", "--all", help="Discover all installed arcade tool packages"
),
show_packages: bool = typer.Option(
False, "--show-packages", help="Show loaded packages during discovery"
),
reload: bool = typer.Option(
False, "--reload", help="Enable auto-reload on code changes (HTTP mode only)"
),
debug: bool = typer.Option(False, "--debug", help="Enable debug mode with verbose logging"),
otel_enable: bool = typer.Option(
False, "--otel-enable", help="Send logs to OpenTelemetry", show_default=True
),
env_file: Optional[str] = typer.Option(None, "--env-file", help="Path to environment file"),
name: Optional[str] = typer.Option(None, "--name", help="Server name"),
version: Optional[str] = typer.Option(None, "--version", help="Server version"),
cwd: Optional[str] = typer.Option(None, "--cwd", help="Working directory to run from"),
) -> None:
"""
Run Arcade MCP Server (passthrough to arcade_mcp_server).
This command provides a unified CLI experience by passing through
all arguments to the arcade_mcp_server module.
Examples:
arcade mcp stdio
arcade mcp http --port 8080
arcade mcp --tool-package github
arcade mcp --discover-installed --show-packages
"""
# Build the command to pass through to arcade_mcp_server
cmd = [sys.executable, "-m", "arcade_mcp_server", transport]
# Add optional arguments
cmd.extend(["--host", host])
cmd.extend(["--port", str(port)])
if debug:
cmd.append("--debug")
if otel_enable:
cmd.append("--otel-enable")
if tool_package:
cmd.extend(["--tool-package", tool_package])
if discover_installed:
cmd.append("--discover-installed")
if show_packages:
cmd.append("--show-packages")
if reload:
cmd.append("--reload")
if env_file:
cmd.extend(["--env-file", env_file])
if name:
cmd.extend(["--name", name])
if version:
cmd.extend(["--version", version])
if cwd:
cmd.extend(["--cwd", cwd])
try:
# Show what command we're running in debug mode
if debug:
console.print(f"[dim]Running: {' '.join(cmd)}[/dim]")
# Execute the command and pass through all output.
# On Windows, set CREATE_NO_WINDOW to prevent a phantom console
# window from appearing (e.g. when an MCP client spawns this
# command without an attached console). The child process still
# inherits stdin/stdout/stderr for stdio transport communication.
run_kwargs: dict[str, object] = {"check": False}
creation_flags = get_windows_no_window_creationflags()
if creation_flags:
run_kwargs["creationflags"] = creation_flags
result = subprocess.run(cmd, **run_kwargs)
# Exit with the same code as the subprocess
if result.returncode != 0:
handle_cli_error("Failed to run MCP server")
except KeyboardInterrupt:
console.print("\n[yellow]MCP server gracefully shutdown[/yellow]")
except FileNotFoundError:
handle_cli_error(
"arcade_mcp_server module not found. Make sure arcade-mcp-server is installed"
)
@cli.command(
help="Show the installed tools or details of a specific tool",
rich_help_panel="Build",
)
def show(
server: Optional[str] = typer.Option(
None, "-T", "--server", help="The server to show the tools of"
),
tool: Optional[str] = typer.Option(
None, "-t", "--tool", help="The specific tool to show details for"
),
host: str = typer.Option(
PROD_ENGINE_HOST,
"-h",
"--host",
help="The Arcade Engine address to show the tools/servers of.",
),
local: bool = typer.Option(
False,
"--local",
"-l",
help="Show the local environment's catalog instead of an Arcade Engine's catalog.",
),
port: Optional[int] = typer.Option(
None,
"-p",
"--port",
help="The port of the Arcade Engine.",
),
force_tls: bool = typer.Option(
False,
"--tls",
help="Whether to force TLS for the connection to the Arcade Engine. If not specified, the connection will use TLS if the engine URL uses a 'https' scheme.",
),
force_no_tls: bool = typer.Option(
False,
"--no-tls",
help="Whether to disable TLS for the connection to the Arcade Engine.",
),
full: bool = typer.Option(
False,
"--full",
"-f",
help="Show full server response structure including error, logs, and authorization fields (only applies when used with -t/--tool).",
),
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
) -> None:
"""
Show the available tools or detailed information about a specific tool.
"""
if full and not tool:
console.print(
"⚠️ The -f/--full flag only affects output when used with -t/--tool flag",
style="bold yellow",
)
show_logic(
toolkit=server,
tool=tool,
host=host,
local=local,
port=port,
force_tls=force_tls,
force_no_tls=force_no_tls,
worker=full,
debug=debug,
)
@cli.command(help="Run tool calling evaluations", rich_help_panel="Build")
def evals(
directory: str = typer.Argument(".", help="Directory containing evaluation files"),
show_details: bool = typer.Option(False, "--details", "-d", help="Show detailed results"),
max_concurrent: int = typer.Option(
1,
"--max-concurrent",
"-c",
help="Maximum number of concurrent evaluations (default: 1)",
),
num_runs: int = typer.Option(
1,
"--num-runs",
"-n",
help="Number of runs per case (default: 1).",
),
seed: str = typer.Option(
"constant",
"--seed",
help="Seed policy for OpenAI runs (ignored for Anthropic): "
"'constant' (default), 'random', or an integer.",
),
multi_run_pass_rule: str = typer.Option(
"last",
"--multi-run-pass-rule",
help="Pass/fail aggregation for multi-run cases: 'last' (default), 'mean', or 'majority'.",
),
use_provider: Optional[list[str]] = typer.Option(
None,
"--use-provider",
"-p",
help="Provider(s) and models to use. Format: 'provider' or 'provider:model1,model2'. "
"Can be repeated. Examples: --use-provider openai or --use-provider openai:gpt-4o --use-provider anthropic:claude-sonnet-4-5-20250929",
),
api_key: Optional[list[str]] = typer.Option(
None,
"--api-key",
"-k",
help="API key(s) for provider(s). Format: 'provider:key'. "
"Can be repeated. Examples: --api-key openai:sk-... --api-key anthropic:sk-ant-...",
),
only_failed: bool = typer.Option(
False,
"--only-failed",
"-f",
help="Show only failed evaluations",
),
output: Optional[list[str]] = typer.Option(
None,
"--output",
"-o",
help="Output file(s) with auto-detected format from extension. "
"Examples: -o results.json, -o results.md -o results.html, -o results (all formats). "
"Can be repeated for multiple formats.",
),
capture: bool = typer.Option(
False,
"--capture",
help="Run in capture mode - record tool calls without evaluation scoring",
),
include_context: bool = typer.Option(
False,
"--include-context",
help="Include system_message and additional_messages in output (works for both eval and capture modes)",
),
host: Optional[str] = typer.Option(
None,
"--host",
help="Arcade API host for gateway connections (e.g., 'api.bosslevel.dev')",
),
port: Optional[int] = typer.Option(
None,
"--port",
help="Arcade API port for gateway connections (default: 443 for HTTPS)",
),
debug: bool = typer.Option(False, "--debug", help="Show debug information"),
) -> None:
"""
Find all files starting with 'eval_' in the given directory,
execute any functions decorated with @tool_eval, and display the results.
"""
require_dependency(
package_name="arcade_evals",
command_name="evals",
uv_install_command=r"uv tool install 'arcade-mcp[evals]'",
pip_install_command=r"pip install 'arcade-mcp[evals]'",
)
# Although Evals does not depend on the TDK, some evaluations import the
# ToolCatalog class from the TDK instead of from arcade_core, so we require
# the TDK to run the evals CLI command to avoid possible import errors.
require_dependency(
package_name="arcade_tdk",
command_name="evals",
uv_install_command=r"uv pip install arcade-tdk",
pip_install_command=r"pip install arcade-tdk",
)
# --- Validate multi-run parameters upfront (before any API calls) ---
if num_runs < 1:
handle_cli_error("--num-runs must be >= 1", should_exit=True)
return
seed_value: str | int
seed_lower = seed.strip().lower()
if seed_lower in {"constant", "random"}:
seed_value = seed_lower
else:
try:
seed_value = int(seed)
except ValueError:
handle_cli_error(
"Invalid --seed value. Use 'constant', 'random', or an integer.", should_exit=True
)
return
if seed_value < 0:
handle_cli_error("--seed must be a non-negative integer.", should_exit=True)
return
pass_rule = multi_run_pass_rule.strip().lower()
# Lazy import: arcade_evals requires optional deps (openai) that aren't
# available when the CLI is installed without the [evals] extra.
from arcade_evals._evalsuite._types import _VALID_PASS_RULES
if pass_rule not in _VALID_PASS_RULES:
handle_cli_error(
f"Invalid --multi-run-pass-rule. Valid values: {', '.join(sorted(_VALID_PASS_RULES))}.",
should_exit=True,
)
return
# --- Build model specs from flags ---
model_specs: list[ModelSpec] = []
# Resolve API keys from --api-key flags and environment
api_keys = resolve_provider_api_keys(api_keys_specs=api_key)
if use_provider:
# Parse provider specs - supports multiple --use-provider flags
# e.g., --use-provider openai:gpt-4o --use-provider anthropic:claude
try:
provider_configs = [parse_provider_spec(spec) for spec in use_provider]
except ValueError as e:
handle_cli_error(str(e), should_exit=True)
return # For type checker
# Expand to model specs
try:
model_specs = expand_provider_configs(provider_configs, api_keys)
except ValueError as e:
handle_cli_error(str(e), should_exit=True)
return # For type checker
else:
# Default: OpenAI with default model
if not api_keys.get(Provider.OPENAI):
handle_cli_error(
"API key not found for provider 'openai'. "
"Please provide it via --api-key openai:KEY, set the OPENAI_API_KEY environment variable, "
"or add it to a .env file in the current directory.\n\n"
"Tip: Use --use-provider to specify a different provider (e.g., --use-provider anthropic)",
should_exit=True,
)
return # For type checker
model_specs = [
ModelSpec(
provider=Provider.OPENAI,
model=get_default_model(Provider.OPENAI),
api_key=api_keys[Provider.OPENAI], # type: ignore[arg-type]
)
]
if not model_specs:
handle_cli_error("No models specified. Use --use-provider to specify models.")
return
eval_files = get_eval_files(directory)
if not eval_files:
return
# Warn about incompatible flag combinations
if capture:
console.print("\nRunning in capture mode", style="bold cyan")
if only_failed:
console.print("[yellow]⚠️ --only-failed is ignored in capture mode[/yellow]")
if show_details:
console.print("[yellow]⚠️ --details is ignored in capture mode[/yellow]")
else:
console.print("\nRunning evaluations", style="bold")
# Show which models will be used
unique_providers = {spec.provider.value for spec in model_specs}
if len(unique_providers) > 1:
console.print(
f"[bold cyan]Using {len(model_specs)} model(s) across {len(unique_providers)} providers[/bold cyan]"
)
for spec in model_specs:
console.print(f"{spec.display_name}", style="dim")
# Set arcade URL override BEFORE loading suites (so MCP connections use it)
if host or port:
# Build URL from --host and --port
if not host:
handle_cli_error("--port requires --host to be specified", should_exit=True)
return
# Default to HTTPS on port 443
scheme = "https"
port_str = f":{port}" if port and port != 443 else ""
constructed_url = f"{scheme}://{host}{port_str}"
os.environ["ARCADE_API_BASE_URL"] = constructed_url
# Use the new function to load eval suites
eval_suites = load_eval_suites(eval_files)
if not eval_suites:
console.print("No evaluation suites to run.", style="bold yellow")
return
if show_details:
suite_label = "suite" if len(eval_suites) == 1 else "suites"
console.print(
f"\nFound {len(eval_suites)} {suite_label} in the evaluation files.",
style="bold",
)
# Parse output paths with smart format detection
final_output_file: str | None = None
final_output_formats: list[str] = []
if output:
try:
final_output_file, final_output_formats = parse_output_paths(output)
except ValueError as e:
handle_cli_error(str(e), should_exit=True)
return
try:
if capture:
asyncio.run(
run_capture(
eval_suites=eval_suites,
model_specs=model_specs,
max_concurrent=max_concurrent,
include_context=include_context,
output_file=final_output_file,
output_format=",".join(final_output_formats) if final_output_formats else "txt",
console=console,
num_runs=num_runs,
seed=seed_value,
)
)
else:
asyncio.run(
run_evaluations(
eval_suites=eval_suites,
model_specs=model_specs,
max_concurrent=max_concurrent,
show_details=show_details,
output_file=final_output_file,
output_format=",".join(final_output_formats) if final_output_formats else "txt",
failed_only=only_failed,
include_context=include_context,
console=console,
num_runs=num_runs,
seed=seed_value,
multi_run_pass_rule=pass_rule,
)
)
except Exception as e:
handle_cli_error("Failed to run evaluations", e, debug)
@cli.command(help="Configure MCP clients to connect to your server", rich_help_panel="Manage")
def configure(
client: str = typer.Argument(
...,
help="The MCP client to configure (claude, cursor, vscode)",
click_type=click.Choice(["claude", "cursor", "vscode"], case_sensitive=False),
show_choices=True,
),
entrypoint_file: str = typer.Option(
"server.py",
"--entrypoint",
"-e",
help="The name of the Python file in the current directory that runs the server. This file must run the server when invoked directly. Only used for stdio servers.",
rich_help_panel="Stdio Options",
),
transport: str = typer.Option(
"stdio",
"--transport",
"-t",
help="The transport to use for the MCP server configuration",
click_type=click.Choice(["stdio", "http"], case_sensitive=False),
show_choices=True,
),
server_name: Optional[str] = typer.Option(
None,
"--name",
"-n",
help="Optional name of the server to set in the configuration file (defaults to the name of the current directory)",
rich_help_panel="Configuration File Options",
),
host: str = typer.Option(
"local",
"--host",
"-h",
help="The host of the HTTP server to configure. Use 'local' to connect to a local MCP server or 'arcade' to connect to an Arcade Cloud MCP server.",
click_type=click.Choice(["local", "arcade"], case_sensitive=False),
show_choices=True,
rich_help_panel="HTTP Options",
),
port: int = typer.Option(
8000,
"--port",
"-p",
help="Port for local HTTP servers",
rich_help_panel="HTTP Options",
),
config_path: Optional[Path] = typer.Option(
None,
"--config",
"-c",
exists=False,
help="Optional path to a specific MCP client config file (overrides default path)",
rich_help_panel="Configuration File Options",
),
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
) -> None:
"""
Configure MCP clients to connect to your server.
The default behavior is to configure the specified client for a local stdio server that
runs when the server.py file in the current directory is invoked directly.
Examples:
arcade configure claude
arcade configure cursor --transport http --port 8080
arcade configure vscode --host arcade --entrypoint my_server.py --config .vscode/mcp.json
arcade configure claude --host local --name my_server_name
"""
from arcade_cli.configure import configure_client
try:
configure_client(
client=client,
entrypoint_file=entrypoint_file,
server_name=server_name,
transport=transport,
host=host,
port=port,
config_path=config_path,
)
except Exception as e:
handle_cli_error(f"Failed to configure {client}", e, debug)
@cli.command(
name="deploy",
help="Deploy MCP servers to Arcade",
rich_help_panel="Run",
)
def deploy(
entrypoint: str = typer.Option(
"server.py",
"--entrypoint",
"-e",
help="Relative path to the Python file that runs the MCPApp instance (relative to project root). This file must execute the `run()` method on your `MCPApp` instance when invoked directly.",
),
skip_validate: bool = typer.Option(
False,
"--skip-validate",
"--yolo",
help="Skip running the server locally for health/metadata checks. "
"When set, you must provide `--server-name` and `--server-version`. "
"Secret handling is controlled by `--secrets`.",
rich_help_panel="Advanced",
),
server_name: Optional[str] = typer.Option(
None,
"--server-name",
"-n",
help="Explicit server name to use when `--skip-validate` is set. Only used when `--skip-validate` is set.",
rich_help_panel="Advanced",
),
server_version: Optional[str] = typer.Option(
None,
"--server-version",
"-v",
help="Explicit server version to use when `--skip-validate` is set. Only used when `--skip-validate` is set.",
rich_help_panel="Advanced",
),
secrets: str = typer.Option(
"auto",
"--secrets",
"-s",
help=(
"How to upsert secrets before deploy:\n"
" `auto` (default): During validation, discover required secret KEYS and upsert only those. "
"If `--skip-validate` is set, `auto` becomes `skip`.\n"
" `all`: Upsert every key/value pair from your server's .env file regardless of what the server needs.\n"
" `skip`: Do not upsert any secrets (assumes they are already present in Arcade)."
),
show_choices=True,
rich_help_panel="Advanced",
click_type=click.Choice(["auto", "all", "skip"], case_sensitive=False),
),
host: str = typer.Option(
PROD_ENGINE_HOST,
"--host",
"-h",
help="The Arcade Engine host to deploy to",
hidden=True,
),
port: Optional[int] = typer.Option(
None,
"--port",
"-p",
help="The port of the Arcade Engine",
hidden=True,
),
force_tls: bool = typer.Option(
False,
"--tls",
help="Force TLS for the connection to the Arcade Engine",
hidden=True,
),
force_no_tls: bool = typer.Option(
False,
"--no-tls",
help="Disable TLS for the connection to the Arcade Engine",
hidden=True,
),
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
) -> None:
"""
Deploy an MCP server directly to Arcade Engine.
This command should be run from the root of your MCP server package
(the directory containing pyproject.toml).
Examples:
cd my_mcp_server/
arcade deploy
arcade deploy --entrypoint src/server.py
arcade deploy --skip-validate --server-name my_server_name --server-version 1.0.0
"""
from arcade_cli.deploy import deploy_server_logic
if skip_validate and not (server_name and server_version):
handle_cli_error(
"When --skip-validate is set, you must provide --server-name and --server-version.",
should_exit=True,
)
if skip_validate and secrets == "auto":
secrets = "skip"
try:
deploy_server_logic(
entrypoint=entrypoint,
skip_validate=skip_validate,
server_name=server_name,
server_version=server_version,
secrets=secrets,
host=host,
port=port,
force_tls=force_tls,
force_no_tls=force_no_tls,
debug=debug,
)
except Exception as e:
handle_cli_error("Failed to deploy server", e, debug)
@cli.command(help="Open the Arcade Dashboard in a web browser", rich_help_panel="User")
def dashboard(
host: str = typer.Option(
PROD_ENGINE_HOST,
"-h",
"--host",
help="The Arcade Engine host that serves the dashboard.",
),
port: Optional[int] = typer.Option(
None,
"-p",
"--port",
help="The port of the Arcade Engine.",
),
local: bool = typer.Option(
False,
"--local",
"-l",
help="Open the local dashboard instead of the default remote dashboard.",
),
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.",
),
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
) -> None:
"""Opens the Arcade Dashboard in a web browser.
The Dashboard is a web-based Arcade user interface that is served by the Arcade Engine.
"""
try:
if local:
host = "localhost"
# Construct base URL (for both health check and dashboard)
base_url = compute_base_url(force_tls, force_no_tls, host, port)
dashboard_url = f"{base_url}/dashboard"
# Try to hit /health endpoint on engine and warn if it is down
with Arcade(api_key="", base_url=base_url) as client:
log_engine_health(client)
# Open the dashboard in a browser
console.print(f"Opening Arcade Dashboard at {dashboard_url}")
if not _open_browser(dashboard_url):
console.print(
f"If a browser doesn't open automatically, copy this URL and paste it into your browser: {dashboard_url}",
style="dim",
)
except Exception as e:
handle_cli_error("Failed to open dashboard", e, debug)
@cli.callback()
def main_callback(
ctx: typer.Context,
_: Optional[bool] = typer.Option(
None,
"-v",
"--version",
callback=version_callback,
is_eager=True,
help="Print version and exit.",
),
) -> None:
# Commands that do not require a logged in user
public_commands = {
login.__name__,
logout.__name__,
dashboard.__name__,
evals.__name__,
mcp.__name__,
new.__name__,
show.__name__,
configure.__name__,
}
if ctx.invoked_subcommand in public_commands:
return
if _credentials_file_contains_legacy():
console.print(
"\nYour credentials are from an older CLI version and are no longer supported.",
style="bold yellow",
)
console.print(
"Run `arcade logout` to remove the old credentials, "
"then run `arcade login` to sign back in.",
style="bold yellow",
)
console.print(
"\nNote: `arcade logout` will delete your API key from ~/.arcade/credentials.yaml. "
"If you need to preserve it, copy it before logging out.",
style="bold yellow",
)
handle_cli_error("Legacy credentials detected. Please re-authenticate.")
if not check_existing_login(suppress_message=True):
handle_cli_error("Not logged in to Arcade CLI. Use `arcade login` to log in.")