arcade deploy for MCP Servers (#618)

Blocked by https://github.com/ArcadeAI/arcade-mcp/pull/614 (and the
reason for failing tests)

# PR Description
`arcade deploy` will deploy your local MCP server to Arcade. `arcade
deploy` should be executed at the root of your MCP Server package.
Before deploying, the command runs your server locally to ensure your
project is setup correctly and the server runs properly. `arcade deploy`
assumes your entrypoint file will execute `MCPApp.run` when the file is
invoked directly. This means you must either have an `if __name__ ==
"__main__" block that contains `MCPApp.run`, or `MCPApp.run` should be
top-level code (unindented living directly in the body of the file).

<img width="3318" height="594" alt="image"
src="https://github.com/user-attachments/assets/8249843e-6f9d-4d01-854d-356b0aae5055"
/>

<img width="1662" height="1056" alt="image"
src="https://github.com/user-attachments/assets/f44951f2-2718-4799-aecc-0e22c1b951b8"
/>
This commit is contained in:
Eric Gustin 2025-10-16 09:00:10 -07:00 committed by GitHub
parent 274fb1c025
commit a8fc6691e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1293 additions and 904 deletions

View file

@ -0,0 +1,631 @@
import base64
import io
import os
import random
import subprocess
import sys
import tarfile
import time
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 arcade_cli.secret import load_env_file
from arcade_cli.utils import compute_base_url, validate_and_get_config
console = Console()
# Models
class MCPClientInfo(BaseModel):
"""MCP client information for initialize request."""
name: str
version: str
class MCPInitializeParams(BaseModel):
"""Parameters for MCP initialize request."""
capabilities: dict = Field(default_factory=dict)
clientInfo: MCPClientInfo
protocolVersion: str
class MCPInitializeRequest(BaseModel):
"""MCP initialize request payload."""
id: int
jsonrpc: str = "2.0"
method: str = "initialize"
params: MCPInitializeParams
class ToolkitBundle(BaseModel):
"""A toolkit bundle for deployment."""
name: str
version: str
bytes: str
type: str = "mcp"
entrypoint: str
class DeploymentToolkits(BaseModel):
"""Toolkits section of deployment request."""
bundles: list[ToolkitBundle]
packages: list[str] = Field(default_factory=list)
class DeploymentRequest(BaseModel):
"""Deployment request payload for /v1/deployments endpoint."""
name: str
description: str
toolkits: DeploymentToolkits
# Functions
def create_package_archive(package_dir: Path) -> str:
"""
Create a tar.gz archive of the package directory.
Args:
package_dir: Path to the package directory to archive
Returns:
Base64-encoded string of the tar.gz archive bytes
Raises:
ValueError: If package_dir doesn't exist or is not a directory
"""
if not package_dir.exists():
raise ValueError(f"Package directory not found: {package_dir}")
if not package_dir.is_dir():
raise ValueError(f"Package path must be a directory: {package_dir}")
def exclude_filter(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo | None:
"""Filter for files/directories to exclude from the archive.
Filters out:
- Hidden files and directories
- __pycache__ directories
- .egg-info directories
- dist and build directories
- files ending with .lock
"""
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
return tarinfo
# Create tar.gz archive in memory
byte_stream = io.BytesIO()
with tarfile.open(fileobj=byte_stream, mode="w:gz") as tar:
tar.add(package_dir, arcname=package_dir.name, filter=exclude_filter)
# Get bytes and encode to base64
byte_stream.seek(0)
package_bytes = byte_stream.read()
package_bytes_b64 = base64.b64encode(package_bytes).decode("utf-8")
return package_bytes_b64
def start_server_process(entrypoint: str, debug: bool = False) -> tuple[subprocess.Popen, int]:
"""
Start the MCP server process on a random port.
Args:
entrypoint: Path to the entrypoint file that runs the MCPApp instance
debug: Whether to show debug information
Returns:
Tuple of (process, port)
Raises:
ValueError: If the server process exits immediately
"""
port = random.randint(8000, 9000) # noqa: S311
# override app.run() settings
env = {
**os.environ,
"ARCADE_SERVER_HOST": "localhost",
"ARCADE_SERVER_PORT": str(port),
"ARCADE_SERVER_TRANSPORT": "http",
"ARCADE_AUTH_DISABLED": "true",
}
cmd = [sys.executable, entrypoint]
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=env,
)
# Check for immediate failure on start up
time.sleep(0.5)
if process.poll() is not None:
_, stderr = process.communicate()
error_msg = stderr.strip() if stderr else "Unknown error"
raise ValueError(f"Server process exited immediately: {error_msg}")
return process, port
def wait_for_health(base_url: str, process: subprocess.Popen, timeout: int = 30) -> None:
"""
Wait for the server to become healthy.
Args:
base_url: Base URL of the server
process: The server process
timeout: Maximum time to wait in seconds
Raises:
ValueError: If the server doesn't become healthy within timeout
"""
health_url = f"{base_url}/worker/health"
start_time = time.time()
is_healthy = False
while time.time() - start_time < timeout:
try:
response = httpx.get(health_url, timeout=2.0)
if response.status_code == 200:
is_healthy = True
break
except (httpx.ConnectError, httpx.TimeoutException):
pass
except Exception:
console.print(" Health check failed. Trying again...", style="dim")
time.sleep(0.5)
if not is_healthy:
process.terminate()
try:
_, stderr = process.communicate(timeout=2)
error_msg = stderr.strip() if stderr else "Server failed to become healthy"
except subprocess.TimeoutExpired:
process.kill()
error_msg = f"Server failed to become healthy within {timeout} seconds"
raise ValueError(error_msg)
console.print("✓ Server is healthy", style="green")
def get_server_info(base_url: str) -> tuple[str, str]:
"""
Extract server name and version via the MCP initialize endpoint.
Args:
base_url: Base URL of the server
Returns:
Tuple of (server_name, server_version)
Raises:
ValueError: If server info extraction fails
"""
mcp_url = f"{base_url}/mcp"
initialize_request = MCPInitializeRequest(
id=1,
params=MCPInitializeParams(
clientInfo=MCPClientInfo(name="arcade-deploy-client", version="1.0.0"),
protocolVersion="2025-06-18",
),
)
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
}
try:
mcp_response = httpx.post(
mcp_url, json=initialize_request.model_dump(), headers=headers, timeout=10.0
)
mcp_response.raise_for_status()
mcp_data = mcp_response.json()
server_name = mcp_data["result"]["serverInfo"]["name"]
server_version = mcp_data["result"]["serverInfo"]["version"]
console.print(f"✓ Found server name: {server_name}", style="green")
console.print(f"✓ Found server version: {server_version}", style="green")
except Exception as e:
raise ValueError(f"Failed to extract server info from /mcp endpoint: {e}") from e
else:
return server_name, server_version
def get_required_secrets(
base_url: str, server_name: str, server_version: str, debug: bool = False
) -> set[str]:
"""
Extract required secrets from the /worker/tools endpoint.
Args:
base_url: Base URL of the server
server_name: Name of the server (for display purposes)
server_version: Version of the server (for display purposes)
debug: Whether to show debug information
Returns:
Set of required secret keys
Raises:
ValueError: If secrets extraction fails
"""
tools_url = f"{base_url}/worker/tools"
try:
tools_response = httpx.get(tools_url, timeout=10.0)
tools_response.raise_for_status()
tools_data = tools_response.json()
required_secrets = set()
for tool in tools_data:
if (
"requirements" in tool
and tool["requirements"]
and "secrets" in tool["requirements"]
and tool["requirements"]["secrets"]
):
for secret in tool["requirements"]["secrets"]:
if secret.get("key"):
required_secrets.add(secret["key"])
console.print(f"✓ Found {len(tools_data)} tools", style="green")
except Exception as e:
raise ValueError(f"Failed to extract tool secrets from /worker/tools endpoint: {e}") from e
else:
return required_secrets
def verify_server_and_get_metadata(
entrypoint: str, debug: bool = False
) -> tuple[str, str, set[str]]:
"""
Start the server, verify it's healthy, and extract metadata.
This function orchestrates:
1. Starting the server on a random port
2. Waiting for the server to become healthy
3. Extracting server name and version via POST /mcp (initialize method)
4. Extracting required secrets via GET /worker/tools
5. Stopping the server
6. Returning the metadata
Args:
entrypoint: Path to the entrypoint file that runs the MCPApp instance
debug: Whether to show debug information
Returns:
Tuple of (server_name, server_version, required_secrets_set)
Raises:
ValueError: If the server fails to start or metadata extraction fails
"""
process, port = start_server_process(entrypoint, debug)
console.print(f"✓ Server started on port {port}", style="green")
base_url = f"http://127.0.0.1:{port}"
try:
wait_for_health(base_url, process)
server_name, server_version = get_server_info(base_url)
required_secrets = get_required_secrets(base_url, server_name, server_version, debug)
console.print(f"✓ Found {len(required_secrets)} required secret(s)", style="green")
return server_name, server_version, required_secrets
finally:
# Always stop the server
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
if debug:
console.print("✓ Server stopped", style="green")
def upsert_secrets_to_engine(
engine_url: str, api_key: str, secrets: set[str], debug: bool = False
) -> None:
"""
Upsert secrets to the Arcade Engine.
Args:
engine_url: The base URL of the Arcade Engine
api_key: The API key for authentication
secrets: Set of secret keys to upsert
debug: Whether to show debug information
"""
if not secrets:
return
client = httpx.Client(base_url=engine_url, headers={"Authorization": f"Bearer {api_key}"})
for secret_key in sorted(secrets):
secret_value = os.getenv(secret_key)
if secret_value:
console.print(
f"✓ Uploading '{secret_key}' with value ending in ...{secret_value[-4:]}",
style="green",
)
else:
console.print(
f"⚠️ Secret '{secret_key}' not found in environment, skipping upload.",
style="yellow",
)
continue
try:
# Upsert secret to engine
response = client.put(
f"/v1/admin/secrets/{secret_key}",
json={"description": "Secret set via CLI", "value": secret_value},
timeout=30,
)
response.raise_for_status()
console.print(f"✓ Secret '{secret_key}' uploaded", style="green")
except httpx.HTTPStatusError as e:
error_msg = f"Failed to upload secret '{secret_key}': HTTP {e.response.status_code}"
if debug:
console.print(f"{error_msg}: {e.response.text}", style="red")
else:
console.print(f"{error_msg}", style="red")
except Exception as e:
error_msg = f"Failed to upload secret '{secret_key}': {e}"
console.print(f"{error_msg}", style="red")
client.close()
def deploy_server_to_engine(
engine_url: str, api_key: str, deployment_request: dict, debug: bool = False
) -> dict:
"""
Deploy the server to Arcade Engine.
Args:
engine_url: The base URL of the Arcade Engine
api_key: The API key for authentication
deployment_request: The deployment request payload
debug: Whether to show debug information
Returns:
The response JSON from the deployment API
Raises:
httpx.HTTPStatusError: If the deployment request fails
httpx.ConnectError: If connection to the engine fails
"""
client = httpx.Client(
base_url=engine_url,
headers={"Authorization": f"Bearer {api_key}"},
timeout=360,
)
try:
response = client.post("/v1/deployments", json=deployment_request)
response.raise_for_status()
return cast(dict, response.json())
except httpx.ConnectError as e:
raise ValueError(f"Failed to connect to Arcade Engine at {engine_url}: {e}") from e
except httpx.HTTPStatusError as e:
error_detail = ""
try:
error_json = e.response.json()
error_detail = f": {error_json}"
except Exception:
error_detail = f": {e.response.text}"
raise ValueError(
f"Deployment failed with HTTP {e.response.status_code}{error_detail}"
) from e
finally:
client.close()
def deploy_server_logic(
entrypoint: str,
skip_validate: bool,
server_name: str | None,
server_version: str | None,
secrets: str,
host: str,
port: int | None,
force_tls: bool,
force_no_tls: bool,
debug: bool,
) -> None:
"""
Main logic for deploying an MCP server to Arcade Engine.
Args:
entrypoint: Path (relative to project root) to the entrypoint file that runs the MCPApp instance.
This file must execute the `run()` method on your `MCPApp` instance when invoked directly.
skip_validate: Skip running the server locally for health/metadata checks.
server_name: Explicit server name to use when --skip-validate is set.
server_version: Explicit server version to use when --skip-validate is set.
secrets: How to upsert secrets before deploy.
host: Arcade Engine host
port: Arcade Engine port (optional)
force_tls: Force TLS connection
force_no_tls: Disable TLS connection
debug: Show debug information
"""
# Step 1: Validate user is logged in
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")
# Step 2: Validate pyproject.toml exists in current directory
console.print("\nValidating pyproject.toml exists in current directory...", style="dim")
current_dir = Path.cwd()
pyproject_path = current_dir / "pyproject.toml"
if not pyproject_path.exists():
raise FileNotFoundError(
f"pyproject.toml not found at {pyproject_path}\n"
"Please run this command from the root of your MCP server package."
)
console.print(f"✓ pyproject.toml found at {pyproject_path}", style="green")
# Step 3: Load .env file from current directory if it exists
console.print("\nLoading .env file from current directory if it exists...", style="dim")
env_path = current_dir / ".env"
if env_path.exists():
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")
# 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")
# 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:
console.print(
"\nValidating server is healthy and extracting metadata before deploying...",
style="dim",
)
try:
server_name, server_version, required_secrets_from_validation = (
verify_server_and_get_metadata(entrypoint, debug=debug)
)
except Exception as e:
raise ValueError(
f"Server verification failed: {e}\n"
"Please ensure your server starts correctly before deploying."
) from e
# Step 5: Determine which secrets to upsert based on --secrets flag
secrets_to_upsert: set[str] = set()
if secrets == "skip":
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())
if secrets_to_upsert:
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")
elif secrets == "auto":
# Only upload required secrets discovered during validation
if required_secrets_from_validation:
console.print(
f"\nUploading {len(required_secrets_from_validation)} required secret(s) to Arcade...",
style="dim",
)
upsert_secrets_to_engine(
engine_url, config.api.key, required_secrets_from_validation, debug
)
else:
console.print("\n✓ No required secrets found", style="green")
# Step 6: Create tar.gz archive of current directory
console.print("\nCreating deployment package...", style="dim")
try:
archive_base64 = create_package_archive(current_dir)
archive_size_kb = len(archive_base64) * 3 / 4 / 1024 # base64 is ~4/3 larger
console.print(f"✓ Package created ({archive_size_kb:.1f} KB)", style="green")
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")
try:
response = deploy_server_to_engine(
engine_url, config.api.key, deployment_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"
)
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")
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",
)

View file

@ -1,500 +0,0 @@
import base64
import io
import os
import re
import secrets
import tarfile
import time
from pathlib import Path
from typing import Any
import toml
from arcade_core import Toolkit
from arcade_core.catalog import ToolCatalog
from arcade_core.toolkit import Validate
from arcadepy import Arcade, NotFoundError
from httpx import Client, ConnectError, HTTPStatusError, TimeoutException
from packaging.requirements import Requirement
from pydantic import BaseModel, field_serializer, field_validator, model_validator
from rich.console import Console
from rich.table import Table
console = Console()
# Base class for versioned packages
class Package(BaseModel):
name: str
specifier: str | None = None
@classmethod
def from_requirement(cls, requirement_str: str) -> "Package":
req = Requirement(requirement_str)
return cls(name=req.name, specifier=str(req.specifier) if req.specifier else None)
# Base class for a list of packages
class Packages(BaseModel):
packages: list[Package]
# Convert string package i.e. "arcade>1.0.0" to a name and specifier
# Specifiers are currently unused
@field_validator("packages", mode="before")
@classmethod
def parse_package_requirements(cls, packages: list[str]) -> list[Package]:
"""Convert package requirement strings to Package objects."""
return [Package.from_requirement(pkg) for pkg in packages]
# Base class for a local package
class LocalPackage(BaseModel):
name: str
content: str
# Base class for a list of local packages
class LocalPackages(BaseModel):
packages: list[str]
# Custom repository configurations
class PackageRepository(Packages):
index: str
index_url: str
trusted_host: str
# Pypi is a special case of a package repository
class Pypi(PackageRepository):
index: str = "pypi"
index_url: str = "https://pypi.org/simple"
trusted_host: str = "pypi.org"
class Secret(BaseModel):
value: str
pattern: str | None = None
class AuthProvider(BaseModel):
"""Configuration for a local auth provider."""
provider_id: str
"""The provider ID (e.g., 'google', 'github', 'custom-oauth')"""
provider_type: str = "oauth2"
"""The type of provider, usually 'oauth2'"""
client_id: str
"""OAuth client ID for this provider"""
client_secret: str
"""OAuth client secret for this provider"""
# Mock tokens for local development
mock_tokens: dict[str, str] | None = None
"""
Mock access tokens by user ID for local development.
Example: {"user-123": "mock-google-token-abc", "user-456": "mock-google-token-def"}
"""
scopes: list[str] | None = None
"""Default scopes for this provider"""
class Config(BaseModel):
"""The configuration for an Arcade worker deployment."""
id: str
"""The unique id for the worker deployment."""
enabled: bool = True
"""Whether the worker is enabled. Defaults to True."""
secret: Secret | None = None
"""The shared secret between the worker and Arcade Engine server."""
timeout: int = 120
"""The maximum execution time in seconds for a tool in this worker."""
retries: int = 1
"""The number of times to retry a failed tool invocation. Defaults to 1."""
# Local development context - only used when running locally
local_context: dict[str, Any] | None = None
"""
Local context configuration for development. This section is only used when running
'arcade serve' locally and is ignored during deployment. It can include:
- user_id: Default user ID for local testing
- user_info: Dictionary of user metadata
- metadata: Additional metadata fields
Example:
[worker.config.local_context]
user_id = "test-user-123"
user_info = { email = "test@example.com", name = "Test User" }
"""
# Local auth providers - only used when running locally
local_auth_providers: list[AuthProvider] | None = None
"""
Local auth provider configurations for development. These are only used when running
'arcade serve' locally and are ignored during deployment. They define mock OAuth
providers and tokens for testing tools that require authentication.
Example:
[[worker.config.local_auth_providers]]
provider_id = "google"
client_id = "mock-google-client"
client_secret = "mock-google-secret"
[worker.config.local_auth_providers.mock_tokens]
"test-user-123" = "mock-google-access-token"
"""
# Validate and parse the secret if required
@field_validator("secret", mode="before")
@classmethod
def valid_secret(cls, v: str | Secret | None) -> Secret:
# If the secret is a string, attempt to parse it as an environment variable or return the secret
if isinstance(v, str):
secret = get_env_secret(v)
elif isinstance(v, Secret):
secret = v
else:
raise TypeError("Secret must be a string or a Secret object")
if secret.value.strip() == "":
raise ValueError("Secret must be a non-empty string")
return secret
@field_serializer("secret")
def serialize_secret(self, secret: Secret) -> str:
if secret.pattern:
return f"$env:{secret.pattern}"
return secret.value
# Cloud request for deploying a worker
class Request(BaseModel):
name: str
secret: Secret
enabled: bool
timeout: int
retries: int
pypi: Pypi | None = None
custom_repositories: list[PackageRepository] | None = None
local_packages: list[LocalPackage] | None = None
wait: bool = False
@field_serializer("secret")
def serialize_secret(self, secret: Secret) -> str:
return secret.value
def poll_worker_status(self, cloud_client: Client, worker_name: str) -> Any:
while True:
try:
worker_resp = cloud_client.get(
f"{cloud_client.base_url}/api/v1/workers/{worker_name}?wait_for_completion=true",
timeout=10,
)
worker_resp.raise_for_status()
except TimeoutException:
time.sleep(1)
continue
except ConnectError as e:
raise ValueError(f"Failed to connect to cloud: {e}")
except HTTPStatusError as e:
raise ValueError(f"Failed to start worker: {e.response.json()}")
except Exception as e:
raise ValueError(f"Failed to start worker: {e}")
status = worker_resp.json()["data"]["status"]
if status == "Running":
return worker_resp.json()["data"]
if status == "Failed":
raise ValueError(f"Worker failed to start: {worker_resp.json()['data']['error']}")
def execute(self, cloud_client: Client, engine_client: Arcade) -> Any:
# Attempt to deploy worker to the cloud
try:
cloud_response = cloud_client.put(
str(cloud_client.base_url) + "/api/v1/workers",
json=self.model_dump(mode="json"),
timeout=360,
)
cloud_response.raise_for_status()
except ConnectError as e:
raise ValueError(f"Failed to connect to cloud: {e}")
except HTTPStatusError as e:
raise ValueError(f"Failed to start worker: {e.response.json()}")
except Exception as e:
# change this so it handles errors that aren't just from cloud
raise ValueError(f"Failed to start worker: {e}")
parse_deployment_response(cloud_response.json())
worker_data = self.poll_worker_status(cloud_client, self.name)
try:
# Check if worker already exists
engine_client.workers.get(self.name)
engine_client.workers.update(
id=self.name,
enabled=self.enabled,
http={
"uri": worker_data["endpoint"],
"secret": self.secret.value,
"timeout": self.timeout,
"retry": self.retries,
},
)
# If worker does not exist, create it
except NotFoundError:
engine_client.workers.create(
id=self.name,
enabled=self.enabled,
http={
"uri": worker_data["endpoint"],
"secret": self.secret.value,
"timeout": self.timeout,
"retry": self.retries,
},
)
except Exception as e:
raise ValueError(f"Failed to add worker to engine: {e}")
return cloud_response.json()
class Worker(BaseModel):
toml_path: Path
config: Config
pypi_source: Pypi | None = None
custom_source: list[PackageRepository] | None = None
local_source: LocalPackages | None = None
def request(self) -> Request:
"""Convert Deployment to a Request object."""
self.validate_packages()
self.compress_local_packages()
if self.config.secret is None:
raise ValueError("Secret is required")
return Request(
name=self.config.id,
secret=self.config.secret,
enabled=self.config.enabled,
timeout=self.config.timeout,
retries=self.config.retries,
pypi=self.pypi_source,
custom_repositories=self.custom_source,
local_packages=self.compress_local_packages(),
)
# Search for local packages and compress the source code to send
def compress_local_packages(self) -> list[LocalPackage] | None:
"""Compress local packages into a list of LocalPackage objects."""
if self.local_source is None:
return None
def exclude_filter(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo | None:
"""Filter for files/directories to exclude from the compressed package"""
if not Validate.path(tarinfo.name):
return None
return tarinfo
# Compress local packages into a list of LocalPackage objects
def process_package(package_path_str: str) -> LocalPackage:
package_path = self.toml_path.parent / package_path_str
if not package_path.exists():
raise FileNotFoundError(f"Local package not found: {package_path}")
if not package_path.is_dir():
raise FileNotFoundError(f"Local package is not a directory: {package_path}")
# Check that the package is a valid python package
if (
not (package_path / "pyproject.toml").is_file()
and not (package_path / "setup.py").is_file()
):
raise ValueError(
f"package '{package_path}' must contain a pyproject.toml or setup.py file"
)
# Validate that we are able to load the package
# Use from_directory to properly resolve src/ layouts and avoid double prefixes
Toolkit.from_directory(package_path)
# Compress the package into a byte stream and tar
byte_stream = io.BytesIO()
with tarfile.open(fileobj=byte_stream, mode="w:gz") as tar:
tar.add(package_path, arcname=package_path.name, filter=exclude_filter)
byte_stream.seek(0)
package_bytes = byte_stream.read()
package_bytes_b64 = base64.b64encode(package_bytes).decode("utf-8")
return LocalPackage(name=package_path.name, content=package_bytes_b64)
return list(map(process_package, self.local_source.packages))
# Validate that there are no duplicate packages for each worker
def validate_packages(self) -> None:
"""Validate packages."""
packages: list[str] = []
if self.pypi_source:
for pypi_package in self.pypi_source.packages:
packages.append(pypi_package.name)
if self.custom_source:
for repository in self.custom_source:
for package in repository.packages:
packages.append(package.name)
if self.local_source:
for local_package in self.local_source.packages:
packages.append(os.path.normpath(Path(local_package)))
dupes = [x for n, x in enumerate(packages) if x in packages[:n]]
if dupes:
raise ValueError(f"Duplicate packages: {dupes}")
def get_required_secrets(self) -> set[str]:
"""Inspect local toolkits and return a set of required secret keys."""
all_secrets = set()
if self.local_source:
catalog = ToolCatalog()
for package_path_str in self.local_source.packages:
package_path = self.toml_path.parent / package_path_str
toolkit = Toolkit.from_directory(package_path)
catalog.add_toolkit(toolkit)
for tool in catalog:
if tool.definition.requirements and tool.definition.requirements.secrets:
for secret in tool.definition.requirements.secrets:
all_secrets.add(secret.key)
return all_secrets
class Deployment(BaseModel):
toml_path: Path
worker: list[Worker]
# Validate that there are no duplicate worker names
@model_validator(mode="after")
def validate_workers(self) -> "Deployment":
for worker in self.worker:
if sum(worker.config.id == w.config.id for w in self.worker) > 1:
raise ValueError(f"Duplicate worker name: {worker.config.id}")
return self
# Load a deployment from a toml file
@classmethod
def from_toml(cls, toml_path: Path) -> "Deployment":
try:
with open(toml_path) as f:
toml_data = toml.load(f)
if not toml_data:
raise ValueError(f"Empty TOML file: {toml_path}")
# Add the toml path to each worker so relative packages can be found
if "worker" in toml_data:
for worker in toml_data["worker"]:
worker["toml_path"] = toml_path
return cls(**toml_data, toml_path=toml_path)
except toml.TomlDecodeError as e:
raise ValueError(f"Invalid TOML format in {toml_path}: {e!s}")
except FileNotFoundError:
raise FileNotFoundError(f"Config file not found: {toml_path}")
# Save the deployment to a toml file
def save(self) -> None:
print("writing deployment file", self.toml_path)
with open(self.toml_path, "w") as f:
data = self.model_dump()
# Remove the toml_path from the deployment file
del data["toml_path"]
for worker in data["worker"]:
del worker["toml_path"]
toml.dump(data, f)
# Create a demo deployment file with one worker
def create_demo_deployment(toml_path: Path, toolkit_name: str) -> None:
"""Create a deployment from a toml file."""
deployment = Deployment(
toml_path=toml_path,
worker=[
Worker(
toml_path=toml_path,
config=Config(
id="demo-worker",
enabled=True,
timeout=30,
retries=3,
secret=Secret(value=secrets.token_hex(16), pattern=None),
),
local_source=LocalPackages(packages=[f"./{toolkit_name}"]),
)
],
)
deployment.save()
# Get a currently existing deployment and add an additional local package
def update_deployment_with_local_packages(toml_path: Path, toolkit_name: str) -> None:
"""Update a deployment from a toml file."""
deployment = Deployment.from_toml(toml_path)
if deployment.worker[0].local_source is None:
deployment.worker[0].local_source = LocalPackages(packages=[f"./{toolkit_name}"])
else:
deployment.worker[0].local_source.packages.append(f"./{toolkit_name}")
deployment.save()
def get_env_secret(secret: str) -> Secret:
"""Parse a secret from an environment variable."""
# Check if the secret contains the "${env:}" syntax
pattern = r"\${env:([^}]+)}"
matches = re.findall(pattern, secret)
# Only allow a single match
if matches and len(matches) == 1:
match = matches[0].strip()
# Attempt to lookup and create the secret
print(f"Looking up secret: {match}")
value = os.getenv(match)
if value:
return Secret(value=value, pattern=match)
else:
raise ValueError(f"Environment variable not found: {match}")
elif matches and len(matches) > 1:
raise ValueError(f"Multiple environment variables found in secret: {secret}")
# If no matches are found, return the secret as is
return Secret(value=secret, pattern=None)
def parse_deployment_response(response: dict) -> None:
# Check what changes were made to the worker and display
changes = response["data"]["changes"]
additions = changes.get("additions", [])
removals = changes.get("removals", [])
updates = changes.get("updates", [])
no_changes = changes.get("no_changes", [])
print_deployment_table(additions, removals, updates, no_changes)
def print_deployment_table(
additions: list, removals: list, updates: list, no_changes: list
) -> None:
table = Table(title="Changed Packages")
table.add_column("Adding", justify="right", style="green")
table.add_column("Removing", justify="right", style="red")
table.add_column("Updating", justify="right", style="yellow")
table.add_column("No Changes", justify="right", style="dim")
max_rows = max(len(additions), len(removals), len(updates), len(no_changes))
# Add each row of worker package changes to the table
for i in range(max_rows):
addition = additions[i] if i < len(additions) else ""
removal = removals[i] if i < len(removals) else ""
update = updates[i] if i < len(updates) else ""
no_change = no_changes[i] if i < len(no_changes) else ""
table.add_row(addition, removal, update, no_change)
console.print(table)

View file

@ -8,7 +8,7 @@ import webbrowser
from pathlib import Path
from typing import Optional
import httpx
import click
import typer
from arcadepy import Arcade
from rich.console import Console
@ -23,7 +23,6 @@ from arcade_cli.constants import (
PROD_CLOUD_HOST,
PROD_ENGINE_HOST,
)
from arcade_cli.deployment import Deployment
from arcade_cli.display import (
display_eval_results,
)
@ -531,106 +530,121 @@ def configure(
handle_cli_error(f"Failed to configure {client}", e, debug)
@cli.command(help="Deploy servers to Arcade Cloud", rich_help_panel="Run", hidden=True)
@cli.command(
name="deploy",
help="Deploy MCP servers to Arcade",
rich_help_panel="Run",
)
def deploy(
deployment_file: str = typer.Option(
"worker.toml",
"--deployment-file",
"-d",
help="The deployment file to 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.",
),
cloud_host: str = typer.Option(
PROD_CLOUD_HOST,
"--cloud-host",
"-c",
help="The Arcade Cloud host to deploy to.",
hidden=True,
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",
),
cloud_port: Optional[int] = typer.Option(
server_name: Optional[str] = typer.Option(
None,
"--cloud-port",
"-cp",
help="The port of the Arcade Cloud host.",
hidden=True,
"--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 register the server to.",
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 host.",
help="The port of the Arcade Engine",
hidden=True,
),
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.",
help="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.",
help="Disable TLS for the connection to the Arcade Engine",
hidden=True,
),
debug: bool = typer.Option(False, "--debug", help="Show debug information"),
debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"),
) -> None:
"""
Deploy a server to Arcade Cloud.
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
config = validate_and_get_config()
engine_url = compute_base_url(force_tls, force_no_tls, host, port)
engine_client = Arcade(api_key=config.api.key, base_url=engine_url)
cloud_url = compute_base_url(force_tls, force_no_tls, cloud_host, cloud_port)
cloud_client = httpx.Client(
base_url=cloud_url, headers={"Authorization": f"Bearer {config.api.key}"}
)
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"
# Fetch deployment configuration
try:
deployment = Deployment.from_toml(Path(deployment_file))
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 parse deployment file", e, debug)
with console.status(f"Deploying {len(deployment.worker)} servers"):
for worker in deployment.worker:
console.log(f"Deploying '{worker.config.id}...'", style="dim")
try:
# Discover and upload secrets
required_secret_keys = worker.get_required_secrets()
for secret_key in required_secret_keys:
secret_value = os.getenv(secret_key)
if not secret_value:
console.log(
f"⚠️ Secret '{secret_key}' not found in environment, skipping.",
style="yellow",
)
continue
try:
secret._upsert_secret_to_engine(
engine_url, config.api.key, secret_key, secret_value
)
except Exception as e:
handle_cli_error(
f"Failed to upload secret '{secret_key}'", e, debug, should_exit=False
)
else:
console.log(
f"✅ Secret '{secret_key}' uploaded successfully",
style="dim green",
)
# Attempt to deploy worker
worker.request().execute(cloud_client, engine_client)
console.log(
f"✅ Server '{worker.config.id}' deployed successfully.",
style="dim",
)
except Exception as e:
handle_cli_error(f"Failed to deploy server '{worker.config.id}'", e, debug)
handle_cli_error("Failed to deploy server", e, debug)
@cli.command(help="Open the Arcade Dashboard in a web browser", rich_help_panel="User")

View file

@ -224,7 +224,9 @@ class MCPApp:
logger.error("No tools added to the server. Use @app.tool decorator or app.add_tool().")
sys.exit(1)
host, port, transport = MCPApp._get_configuration_overrides(host, port, transport)
host, port, transport, reload = MCPApp._get_configuration_overrides(
host, port, transport, reload
)
# Since the transport could have changed since __init__, we need to setup logging again
self._setup_logging(transport == "stdio")
@ -257,8 +259,8 @@ class MCPApp:
@staticmethod
def _get_configuration_overrides(
host: str, port: int, transport: TransportType
) -> tuple[str, int, TransportType]:
host: str, port: int, transport: TransportType, reload: bool
) -> tuple[str, int, TransportType, bool]:
"""Get configuration overrides from environment variables."""
if envvar_transport := os.getenv("ARCADE_SERVER_TRANSPORT"):
transport = envvar_transport
@ -284,7 +286,18 @@ class MCPApp:
f"Using '{port}' as port from ARCADE_SERVER_PORT environment variable"
)
return host, port, transport
if envvar_reload := os.getenv("ARCADE_SERVER_RELOAD"):
if envvar_reload.lower() not in ["0", "1"]:
logger.warning(
f"Invalid reload: '{envvar_reload}' from ARCADE_SERVER_RELOAD environment variable. Using default reload {reload}"
)
else:
reload = bool(int(envvar_reload))
logger.debug(
f"Using '{reload}' as reload from ARCADE_SERVER_RELOAD environment variable"
)
return host, port, transport, reload
class _ToolsAPI:

View file

@ -226,23 +226,31 @@ class TestMCPApp:
monkeypatch.delenv("ARCADE_SERVER_TRANSPORT", raising=False)
monkeypatch.delenv("ARCADE_SERVER_HOST", raising=False)
monkeypatch.delenv("ARCADE_SERVER_PORT", raising=False)
monkeypatch.delenv("ARCADE_SERVER_RELOAD", raising=False)
# Test default values (no environment variables)
host, port, transport = MCPApp._get_configuration_overrides("127.0.0.1", 8000, "http")
host, port, transport, reload = MCPApp._get_configuration_overrides(
"127.0.0.1", 8000, "http", False
)
assert host == "127.0.0.1"
assert port == 8000
assert transport == "http"
assert not reload
# Test transport override
monkeypatch.setenv("ARCADE_SERVER_TRANSPORT", "stdio")
host, port, transport = MCPApp._get_configuration_overrides("127.0.0.1", 8000, "http")
host, port, transport, reload = MCPApp._get_configuration_overrides(
"127.0.0.1", 8000, "http", False
)
assert transport == "stdio"
monkeypatch.delenv("ARCADE_SERVER_TRANSPORT")
# Test host override (only works with HTTP transport)
monkeypatch.setenv("ARCADE_SERVER_TRANSPORT", "http")
monkeypatch.setenv("ARCADE_SERVER_HOST", "192.168.1.1")
host, port, transport = MCPApp._get_configuration_overrides("127.0.0.1", 8000, "http")
host, port, transport, reload = MCPApp._get_configuration_overrides(
"127.0.0.1", 8000, "http", False
)
assert host == "192.168.1.1"
assert transport == "http"
monkeypatch.delenv("ARCADE_SERVER_HOST")
@ -250,27 +258,56 @@ class TestMCPApp:
# Test port override (only works with HTTP transport)
monkeypatch.setenv("ARCADE_SERVER_PORT", "9000")
host, port, transport = MCPApp._get_configuration_overrides("127.0.0.1", 8000, "http")
host, port, transport, reload = MCPApp._get_configuration_overrides(
"127.0.0.1", 8000, "http", False
)
assert port == 9000
monkeypatch.delenv("ARCADE_SERVER_PORT")
# Test invalid port value
monkeypatch.setenv("ARCADE_SERVER_TRANSPORT", "http")
monkeypatch.setenv("ARCADE_SERVER_PORT", "invalid_port")
host, port, transport = MCPApp._get_configuration_overrides("127.0.0.1", 8000, "http")
host, port, transport, reload = MCPApp._get_configuration_overrides(
"127.0.0.1", 8000, "http", False
)
assert port == 8000 # Should keep the default value
monkeypatch.delenv("ARCADE_SERVER_PORT")
monkeypatch.delenv("ARCADE_SERVER_TRANSPORT")
# Test host/port with stdio transport
# Test valid reload value
monkeypatch.setenv("ARCADE_SERVER_TRANSPORT", "http")
monkeypatch.setenv("ARCADE_SERVER_RELOAD", "1")
host, port, transport, reload = MCPApp._get_configuration_overrides(
"127.0.0.1", 8000, "http", False
)
assert reload
monkeypatch.delenv("ARCADE_SERVER_RELOAD")
monkeypatch.delenv("ARCADE_SERVER_TRANSPORT")
# Test invalid reload value
monkeypatch.setenv("ARCADE_SERVER_TRANSPORT", "http")
monkeypatch.setenv("ARCADE_SERVER_RELOAD", "invalid_reload")
host, port, transport, reload = MCPApp._get_configuration_overrides(
"127.0.0.1", 8000, "http", False
)
assert not reload # Should keep the default value
monkeypatch.delenv("ARCADE_SERVER_RELOAD")
monkeypatch.delenv("ARCADE_SERVER_TRANSPORT")
# Test host/port/reload with stdio transport
monkeypatch.setenv("ARCADE_SERVER_TRANSPORT", "stdio")
monkeypatch.setenv("ARCADE_SERVER_HOST", "192.168.1.1")
monkeypatch.setenv("ARCADE_SERVER_PORT", "9000")
host, port, transport = MCPApp._get_configuration_overrides("127.0.0.1", 8000, "http")
# For stdio, host and port are still returned but not used by the server
monkeypatch.setenv("ARCADE_SERVER_RELOAD", "true")
host, port, transport, reload = MCPApp._get_configuration_overrides(
"127.0.0.1", 8000, "http", False
)
# For stdio, host, port, and reload are still returned but not used by the server
assert host == "127.0.0.1" # Host should remain unchanged for stdio transport
assert port == 8000 # Port should remain unchanged for stdio transport
assert transport == "stdio"
assert not reload
monkeypatch.delenv("ARCADE_SERVER_RELOAD")
monkeypatch.delenv("ARCADE_SERVER_HOST")
monkeypatch.delenv("ARCADE_SERVER_PORT")
monkeypatch.delenv("ARCADE_SERVER_TRANSPORT")

View file

@ -0,0 +1,294 @@
import base64
import io
import subprocess
import tarfile
import time
from pathlib import Path
import pytest
from arcade_cli.deploy import (
create_package_archive,
get_required_secrets,
get_server_info,
start_server_process,
verify_server_and_get_metadata,
wait_for_health,
)
# Fixtures
@pytest.fixture
def test_dir() -> Path:
"""Return the path to the test directory."""
return Path(__file__).parent
@pytest.fixture
def valid_server_dir(test_dir: Path) -> Path:
"""Return the path to the valid server directory."""
return test_dir / "test_servers" / "valid_server"
@pytest.fixture
def valid_server_path(valid_server_dir: Path) -> str:
"""Return the path to the valid server entrypoint."""
return str(valid_server_dir / "server.py")
@pytest.fixture
def invalid_server_path(test_dir: Path) -> str:
"""Return the path to the invalid server entrypoint."""
return str(test_dir / "test_servers" / "invalid_server" / "server.py")
@pytest.fixture
def tmp_project_dir(tmp_path: Path) -> Path:
"""Create a temporary project directory with pyproject.toml."""
project_dir = tmp_path / "test_project"
project_dir.mkdir()
# Create a basic pyproject.toml
pyproject_content = """[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "test_project"
version = "0.1.0"
description = "Test project"
requires-python = ">=3.10"
"""
(project_dir / "pyproject.toml").write_text(pyproject_content)
return project_dir
# Tests for create_package_archive
def test_create_package_archive_success(valid_server_dir: Path) -> None:
"""Test creating an archive from a valid directory."""
archive_base64 = create_package_archive(valid_server_dir)
# Verify it returns a base64-encoded string
assert isinstance(archive_base64, str)
assert len(archive_base64) > 0
# Decode and verify the archive can be extracted
archive_bytes = base64.b64decode(archive_base64)
byte_stream = io.BytesIO(archive_bytes)
with tarfile.open(fileobj=byte_stream, mode="r:gz") as tar:
members = tar.getmembers()
filenames = [m.name for m in members]
# Verify expected files are present
assert any("server.py" in name for name in filenames)
assert any("pyproject.toml" in name for name in filenames)
def test_create_package_archive_nonexistent_dir(tmp_path: Path) -> None:
"""Test that archiving a non-existent directory raises ValueError."""
nonexistent_dir = tmp_path / "does_not_exist"
with pytest.raises(ValueError, match="Package directory not found"):
create_package_archive(nonexistent_dir)
def test_create_package_archive_file_not_dir(tmp_path: Path) -> None:
"""Test that archiving a file instead of directory raises ValueError."""
test_file = tmp_path / "test_file.txt"
test_file.write_text("test content")
with pytest.raises(ValueError, match="Package path must be a directory"):
create_package_archive(test_file)
def test_create_package_archive_excludes_files(tmp_path: Path) -> None:
"""Test that certain files are excluded from the archive."""
test_dir = tmp_path / "test_project"
test_dir.mkdir()
# Create files that should be excluded
(test_dir / ".hidden").write_text("hidden")
(test_dir / "__pycache__").mkdir()
(test_dir / "__pycache__" / "cache.pyc").write_text("cache")
(test_dir / "requirements.lock").write_text("lock")
(test_dir / "dist").mkdir()
(test_dir / "dist" / "package.tar.gz").write_text("dist")
(test_dir / "build").mkdir()
(test_dir / "build" / "lib").write_text("build")
# Create files that should be included
(test_dir / "main.py").write_text("main")
(test_dir / "pyproject.toml").write_text("project")
archive_base64 = create_package_archive(test_dir)
archive_bytes = base64.b64decode(archive_base64)
byte_stream = io.BytesIO(archive_bytes)
with tarfile.open(fileobj=byte_stream, mode="r:gz") as tar:
members = tar.getmembers()
filenames = [m.name for m in members]
# Verify excluded files are not present
assert not any(".hidden" in name for name in filenames)
assert not any("__pycache__" in name for name in filenames)
assert not any(".lock" in name for name in filenames)
assert not any("dist" in name for name in filenames)
assert not any("build" in name for name in filenames)
# Verify included files are present
assert any("main.py" in name for name in filenames)
assert any("pyproject.toml" in name for name in filenames)
# Tests for wait_for_health
def test_wait_for_health_success(valid_server_path: str, capsys) -> None:
"""Test waiting for a healthy server."""
process, port = start_server_process(valid_server_path, debug=False)
base_url = f"http://127.0.0.1:{port}"
try:
wait_for_health(base_url, process, timeout=10)
finally:
# Clean up
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
def test_wait_for_health_process_dies(valid_server_path: str) -> None:
"""Test handling when process dies during health check."""
process, port = start_server_process(valid_server_path, debug=False)
base_url = f"http://127.0.0.1:{port}"
# Kill the process immediately
process.kill()
process.wait()
# Mock process object to pass to wait_for_health
with pytest.raises(ValueError):
wait_for_health(base_url, process, timeout=2)
# Tests for get_server_info
def test_get_server_info_success(valid_server_path: str, capsys) -> None:
"""Test extracting server info from a running server."""
process, port = start_server_process(valid_server_path, debug=False)
base_url = f"http://127.0.0.1:{port}"
try:
# Wait for server to be healthy first
wait_for_health(base_url, process, timeout=10)
server_name, server_version = get_server_info(base_url)
assert server_name == "simpleserver"
assert server_version == "1.0.0"
finally:
# Clean up
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
def test_get_server_info_invalid_url() -> None:
"""Test that invalid URL raises ValueError."""
invalid_url = "http://127.0.0.1:9999"
with pytest.raises(ValueError):
get_server_info(invalid_url)
# Tests for get_required_secrets
def test_get_required_secrets_with_secrets(valid_server_path: str, capsys) -> None:
"""Test extracting required secrets from server tools."""
process, port = start_server_process(valid_server_path, debug=False)
base_url = f"http://127.0.0.1:{port}"
try:
# Wait for server to be healthy first
wait_for_health(base_url, process, timeout=10)
secrets = get_required_secrets(base_url, "simpleserver", "1.0.0", debug=True)
assert "MY_SECRET_KEY" in secrets
finally:
# Clean up
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
def test_get_required_secrets_no_secrets(valid_server_path: str) -> None:
"""Test getting secrets returns set even when checking actual tools."""
process, port = start_server_process(valid_server_path, debug=False)
base_url = f"http://127.0.0.1:{port}"
try:
# Wait for server to be healthy first
wait_for_health(base_url, process, timeout=10)
secrets = get_required_secrets(base_url, "simpleserver", "1.0.0", debug=False)
assert len(secrets) == 1
finally:
# Clean up
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
def test_get_required_secrets_invalid_url() -> None:
"""Test that invalid URL raises ValueError."""
invalid_url = "http://127.0.0.1:9999"
with pytest.raises(
ValueError, match="Failed to extract tool secrets from /worker/tools endpoint"
):
get_required_secrets(invalid_url, "test", "1.0.0")
# Tests for verify_server_and_get_metadata (integration tests)
def test_verify_server_and_get_metadata_success(valid_server_path: str, capsys) -> None:
"""Test full server verification flow."""
server_name, server_version, required_secrets = verify_server_and_get_metadata(
valid_server_path, debug=False
)
# Verify returned values
assert server_name == "simpleserver"
assert server_version == "1.0.0"
assert "MY_SECRET_KEY" in required_secrets
def test_verify_server_and_get_metadata_with_debug(valid_server_path: str, capsys) -> None:
"""Test server verification with debug mode enabled."""
server_name, server_version, required_secrets = verify_server_and_get_metadata(
valid_server_path, debug=True
)
# Verify returned values
assert server_name == "simpleserver"
assert server_version == "1.0.0"
assert "MY_SECRET_KEY" in required_secrets

View file

@ -0,0 +1,37 @@
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "simple_server"
version = "0.1.0"
description = "MCP Server created with Arcade.dev"
requires-python = ">=3.10"
dependencies = [
"arcade-mcp-server>=1.0.1,<2.0.0",
]
[project.optional-dependencies]
dev = [
"arcade-mcp[all]>=1.0.1,<2.0.0",
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"mypy>=1.0.0",
"ruff>=0.1.0",
]
# Tell Arcade.dev that this package has Arcade tools
[project.entry-points.arcade_toolkits]
toolkit_name = "simple_server"
[tool.setuptools.packages.find]
include = ["simple_server*"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.mypy]
python_version = "3.10"
warn_unused_configs = true
disallow_untyped_defs = false

View file

@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""simple_server MCP server"""
import sys
from typing import Annotated
import httpx
from arcade_mcp_server import Context, MCPApp
from arcade_mcp_server.auth import Reddit
app = MCPApp(name="simpleserver", version="1.0.0", log_level="DEBUG")
@app.tool
def greet(name: dict) -> str:
"""Greet a person by name."""
return f"Hello, {name}!"
# To use this tool, you need to either set the secret in the .env file or as an environment variable
@app.tool(requires_secrets=["MY_SECRET_KEY"])
def whisper_secret(context: Context) -> Annotated[str, "The last 4 characters of the secret"]:
"""Reveal the last 4 characters of a secret"""
# Secrets are injected into the context at runtime.
# LLMs and MCP clients cannot see or access your secrets
# You can define secrets in a .env file.
try:
secret = context.get_secret("MY_SECRET_KEY")
except Exception as e:
return str(e)
return "The last 4 characters of the secret are: " + secret[-4:]
# To use this tool, you need to either set your ARCADE_API_KEY as an environment variable or
# use the Arcade CLI (uv pip install arcade-mcp) and run 'arcade login' to authenticate.
@app.tool(requires_auth=Reddit(scopes=["read"]))
async def get_posts_in_subreddit(
context: Context, subreddit: Annotated[str, "The name of the subreddit"]
) -> dict:
"""Get posts from a specific subreddit"""
# Normalize the subreddit name
subreddit = subreddit.lower().replace("r/", "").replace(" ", "")
# Prepare the httpx request
# OAuth token is injected into the context at runtime.
# LLMs and MCP clients cannot see or access your OAuth tokens.
oauth_token = context.get_auth_token_or_empty()
headers = {
"Authorization": f"Bearer {oauth_token}",
"User-Agent": "simple_server-mcp-server",
}
params = {"limit": 5}
url = f"https://oauth.reddit.com/r/{subreddit}/hot"
# Make the request
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=headers, params=params)
response.raise_for_status()
# Return the response
return response.json()
# Run with specific transport
if __name__ == "__main__":
# Get transport from command line argument, default to "http"
transport = sys.argv[1] if len(sys.argv) > 1 else "http"
# Run the server
# - "http" (default): HTTPS streaming for Cursor, VS Code, etc.
# - "stdio": Standard I/O for Claude Desktop, CLI tools, etc.
app.run(transport=transport, host="127.0.0.1", port=8000)

View file

@ -0,0 +1,37 @@
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "simple_server"
version = "0.1.0"
description = "MCP Server created with Arcade.dev"
requires-python = ">=3.10"
dependencies = [
"arcade-mcp-server>=1.0.1,<2.0.0",
]
[project.optional-dependencies]
dev = [
"arcade-mcp[all]>=1.0.1,<2.0.0",
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"mypy>=1.0.0",
"ruff>=0.1.0",
]
# Tell Arcade.dev that this package has Arcade tools
[project.entry-points.arcade_toolkits]
toolkit_name = "simple_server"
[tool.setuptools.packages.find]
include = ["simple_server*"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.mypy]
python_version = "3.10"
warn_unused_configs = true
disallow_untyped_defs = false

View file

@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""simple_server MCP server"""
import sys
from typing import Annotated
import httpx
from arcade_mcp_server import Context, MCPApp
from arcade_mcp_server.auth import Reddit
app = MCPApp(name="simpleserver", version="1.0.0", log_level="DEBUG")
@app.tool
def greet(name: Annotated[str, "The name of the person to greet"]) -> str:
"""Greet a person by name."""
return f"Hello, {name}!"
# To use this tool, you need to either set the secret in the .env file or as an environment variable
@app.tool(requires_secrets=["MY_SECRET_KEY"])
def whisper_secret(context: Context) -> Annotated[str, "The last 4 characters of the secret"]:
"""Reveal the last 4 characters of a secret"""
# Secrets are injected into the context at runtime.
# LLMs and MCP clients cannot see or access your secrets
# You can define secrets in a .env file.
try:
secret = context.get_secret("MY_SECRET_KEY")
except Exception as e:
return str(e)
return "The last 4 characters of the secret are: " + secret[-4:]
# To use this tool, you need to either set your ARCADE_API_KEY as an environment variable or
# use the Arcade CLI (uv pip install arcade-mcp) and run 'arcade login' to authenticate.
@app.tool(requires_auth=Reddit(scopes=["read"]))
async def get_posts_in_subreddit(
context: Context, subreddit: Annotated[str, "The name of the subreddit"]
) -> dict:
"""Get posts from a specific subreddit"""
# Normalize the subreddit name
subreddit = subreddit.lower().replace("r/", "").replace(" ", "")
# Prepare the httpx request
# OAuth token is injected into the context at runtime.
# LLMs and MCP clients cannot see or access your OAuth tokens.
oauth_token = context.get_auth_token_or_empty()
headers = {
"Authorization": f"Bearer {oauth_token}",
"User-Agent": "simple_server-mcp-server",
}
params = {"limit": 5}
url = f"https://oauth.reddit.com/r/{subreddit}/hot"
# Make the request
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=headers, params=params)
response.raise_for_status()
# Return the response
return response.json()
# Run with specific transport
if __name__ == "__main__":
# Get transport from command line argument, default to "http"
transport = sys.argv[1] if len(sys.argv) > 1 else "http"
# Run the server
# - "http" (default): HTTPS streaming for Cursor, VS Code, etc.
# - "stdio": Standard I/O for Claude Desktop, CLI tools, etc.
app.run(transport=transport, host="127.0.0.1", port=8000)

View file

@ -1,231 +0,0 @@
# Ignore hardcoded secret linting
# ruff: noqa: S105
# ruff: noqa: S106
import json
import os
from pathlib import Path
import pytest
from arcade_cli.deployment import (
Config,
Deployment,
LocalPackages,
Package,
PackageRepository,
Pypi,
Secret,
Worker,
)
@pytest.fixture
def test_dir():
return Path(__file__).parent
def test_invalid_toml_path(test_dir):
with pytest.raises(FileNotFoundError):
Deployment.from_toml(test_dir / "test_files" / "invalid.toml")
def test_missing_fields(test_dir):
with pytest.raises(ValueError):
Deployment.from_toml(test_dir / "test_files" / "invalid.fields.worker.toml")
def test_deployment_parsing(test_dir):
config_path = test_dir / "test_files" / "full.worker.toml"
deployment = Deployment.from_toml(config_path)
# Test config section
assert deployment.worker[0].config.id == "test"
assert deployment.worker[0].config.enabled is True
assert deployment.worker[0].config.timeout == 10
assert deployment.worker[0].config.retries == 3
assert deployment.worker[0].config.secret == Secret(value="test-secret", pattern=None)
# Test pypi section
assert deployment.worker[0].pypi_source.packages == [Package(name="arcade-x")]
# Test local_packages section
assert deployment.worker[0].local_source.packages == ["./mock_toolkit"]
# Test custom_repositories section
repo = deployment.worker[0].custom_source[0]
assert repo.index == "pypi"
assert repo.index_url == "https://pypi.org/simple"
assert repo.trusted_host == "pypi.org"
assert repo.packages == [Package(name="arcade-mcp", specifier=">=1.0.0")]
repo = deployment.worker[0].custom_source[1]
assert repo.index == "pypi2"
assert repo.index_url == "https://pypi2.org/simple"
assert repo.trusted_host == "pypi2.org"
assert repo.packages == [Package(name="arcade-slack")]
def test_specifier():
from packaging.requirements import Requirement
req = Requirement("arcade-mcp>=1.0.0")
assert req.name == "arcade-mcp"
assert req.specifier == ">=1.0.0"
@pytest.mark.skip(reason="This test is flaky and needs to be fixed")
def test_deployment_dict(test_dir):
config_path = test_dir / "test_files" / "full.worker.toml"
deployment = Deployment.from_toml(config_path)
expected = json.loads("""{
"name": "test",
"secret": "test-secret",
"enabled": true,
"timeout": 10,
"retries": 3,
"wait": false,
"pypi": {
"packages": [
{
"name": "arcade-x",
"specifier": null
}
],
"index": "pypi",
"index_url": "https://pypi.org/simple",
"trusted_host": "pypi.org"
},
"custom_repositories": [
{
"packages": [
{
"name": "arcade-mcp",
"specifier": ">=1.0.0"
}
],
"index": "pypi",
"index_url": "https://pypi.org/simple",
"trusted_host": "pypi.org"
},
{
"packages": [
{
"name": "arcade-slack",
"specifier": null
}
],
"index": "pypi2",
"index_url": "https://pypi2.org/simple",
"trusted_host": "pypi2.org"
}
],
"local_packages": [
{
"name": "mock_toolkit",
"content": "H4sIAOgdymcC/+2XwWuDMBTGPftXZDltMNIkJtrCOrpbL4PdSxmiKXNVIzHt6n+/OAvtNrqbMur7Xd7j5YGH5Ps+JBMyWbzEh6WKU2W8XqAdlyqlgTj17ZxRzriHDt4A7GobG/d5b5zwKSpsVqg5iwRjs6kMBJEzMYtC7nvA1VPoZPtqtc63mZ14/ek/krKrYVcp/655JtyLY4wHNHL6D5iMPCSH1H+dGtX84YBubbO5vvsn4P/g/+f+LyihPBSUSfD/sfl/EWclqZo+9B8Kcdn/eXTyf+bmTAjp9E+H1P9I/b8yWWlv8VLlub5HH9rk6Q2+A+mPhf+R/8Hv/GeQ/4Pkf/Qj/3lEpAgCOQUPGF3+V01l9LtKLLG6yAfLf07F2f9fq/+QhhTyfwhW7d2TSitrmrVfxoVCc4TPXwX298rUmS7bA0oYodhPVZ2YrLLH6bNbR8d1tNEGPZnExQn2451906Z2OyvczdBDqvaL+Ksnrn3EazAaAAAAAAAAAAAAAAAAAOiJT7MTVu0AKAAA"
}
]
}""")
got = deployment.worker[0].request().model_dump(mode="json")
# Remove encoding part that contains the content
got["local_packages"][0].pop("content")
expected["local_packages"][0].pop("content")
assert got == expected
def test_missing_local_package(test_dir):
config_path = test_dir / "test_files" / "invalid.localfile.worker.toml"
deployment = Deployment.from_toml(config_path)
with pytest.raises(FileNotFoundError):
deployment.worker[0].request()
def test_invalid_local_package(test_dir):
config_path = test_dir / "test_files" / "invalid.localfile.worker.toml"
deployment = Deployment.from_toml(config_path)
with pytest.raises(FileNotFoundError):
deployment.worker[1].request()
def test_unconfigured_local_package(test_dir):
config_path = test_dir / "test_files" / "invalid.localfile.worker.toml"
deployment = Deployment.from_toml(config_path)
with pytest.raises(ValueError):
deployment.worker[2].request()
def test_duplicate_pypi_packages():
worker = Worker(
toml_path=Path(__file__),
config=Config(id="test", secret=Secret(value="test-secret", pattern=None)),
pypi_source=Pypi(packages=["arcade-slack", "arcade-slack"]),
)
with pytest.raises(ValueError):
worker.validate_packages()
def test_duplicate_custom_repository_packages():
worker = Worker(
toml_path=Path(__file__),
config=Config(id="test", secret=Secret(value="test-secret", pattern=None)),
custom_source=[
PackageRepository(
index="pypi",
index_url="https://pypi.org/simple",
trusted_host="pypi.org",
packages=["arcade-slack", "arcade-slack"],
)
],
)
with pytest.raises(ValueError):
worker.validate_packages()
def test_duplicate_local_packages():
worker = Worker(
toml_path=Path(__file__),
config=Config(id="test", secret=Secret(value="test-secret", pattern=None)),
local_source=LocalPackages(packages=["./mock_toolkit", "./mock_toolkit"]),
)
with pytest.raises(ValueError):
worker.validate_packages()
def test_duplicate_all_typed_packages():
worker = Worker(
toml_path=Path(__file__),
config=Config(id="test", secret=Secret(value="test-secret", pattern=None)),
pypi_source=Pypi(packages=["arcade-slack"]),
custom_source=[
PackageRepository(
index="pypi",
index_url="https://pypi.org/simple",
trusted_host="pypi.org",
packages=["arcade-slack", "arcade-x"],
)
],
local_source=LocalPackages(packages=["./arcade-x"]),
)
with pytest.raises(ValueError):
worker.validate_packages()
def test_duplicate_worker_names():
worker = Worker(
toml_path=Path(__file__),
config=Config(id="test", secret=Secret(value="test-secret", pattern=None)),
)
worker2 = Worker(
toml_path=Path(__file__),
config=Config(id="test", secret=Secret(value="test-secret", pattern=None)),
)
with pytest.raises(ValueError):
Deployment(workers=[worker, worker2])
def test_secret_parsing(test_dir):
os.environ["TEST_WORKER_SECRET"] = "test-secret"
deployment = Deployment.from_toml(test_dir / "test_files" / "env.secret.worker.toml")
assert deployment.worker[0].config.secret == Secret(
value="test-secret", pattern="TEST_WORKER_SECRET"
)

View file

@ -1,5 +0,0 @@
[[worker]]
[worker.config]
id = "test"
secret = "${env: TEST_WORKER_SECRET}"

View file

@ -1,26 +0,0 @@
[[worker]]
[worker.config]
id = "test"
enabled = true
timeout = 10
retries = 3
secret = "test-secret"
[worker.pypi_source]
packages = ["arcade-x"]
[worker.local_source]
packages = ["./mock_toolkit"]
[[worker.custom_source]]
index = "pypi"
index_url = "https://pypi.org/simple"
trusted_host = "pypi.org"
packages = ["arcade-mcp>=1.0.0"]
[[worker.custom_source]]
index = "pypi2"
index_url = "https://pypi2.org/simple"
trusted_host = "pypi2.org"
packages = ["arcade-slack"]

View file

@ -1,3 +0,0 @@
[[worker]]
[worker.config]

View file

@ -1,42 +0,0 @@
[[worker]]
[worker.config]
id = "test"
enabled = true
timeout = 10
retries = 3
secret = "test-secret"
[worker.pypi_source]
packages = ["arcade-mcp"]
[worker.local_source]
packages = ["./missing_toolkit"]
[[worker]]
[worker.config]
id = "test-2"
enabled = true
timeout = 10
retries = 3
secret = "test-secret"
[worker.pypi_source]
packages = ["arcade-mcp"]
[worker.local_source]
packages = ["./invalid.localfile.worker.toml"]
[[worker]]
[worker.config]
id = "test-3"
enabled = true
timeout = 10
retries = 3
secret = "test-secret"
[worker.pypi_source]
packages = ["arcade-mcp"]
[worker.local_source]
packages = ["./invalid_toolkit"]

View file

@ -1,7 +0,0 @@
[[worker]]
[worker.config]
id = "test"
enabled = true
timeout = 10
retries = 3
secret = "dev"

View file

@ -1 +0,0 @@
print("Hello, world!")

View file

@ -1,5 +0,0 @@
[tool.poetry]
name = "mock_toolkit"
version = "0.1.0"
description = "Mock toolkit for Arcade"
authors = ["Arcade <dev@arcade.dev>"]