From a8fc6691e761fb038a5a3fc3430965da683c7d9d Mon Sep 17 00:00:00 2001 From: Eric Gustin <34000337+EricGustin@users.noreply.github.com> Date: Thu, 16 Oct 2025 09:00:10 -0700 Subject: [PATCH] `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). image image --- libs/arcade-cli/arcade_cli/deploy.py | 631 ++++++++++++++++++ libs/arcade-cli/arcade_cli/deployment.py | 500 -------------- libs/arcade-cli/arcade_cli/main.py | 158 +++-- .../arcade_mcp_server/mcp_app.py | 21 +- libs/tests/arcade_mcp_server/test_mcp_app.py | 53 +- libs/tests/cli/deploy/test_deploy.py | 294 ++++++++ .../invalid_server/pyproject.toml | 37 + .../test_servers/invalid_server/server.py | 73 ++ .../test_servers/valid_server/pyproject.toml | 37 + .../test_servers/valid_server/server.py | 73 ++ libs/tests/deployment/test_config.py | 231 ------- .../test_files/env.secret.worker.toml | 5 - .../deployment/test_files/full.worker.toml | 26 - .../test_files/invalid.fields.worker.toml | 3 - .../test_files/invalid.localfile.worker.toml | 42 -- .../test_files/invalid.secret.worker.toml | 7 - .../invalid_toolkit/invalid_main.py | 0 .../test_files/mock_toolkit/mock_main.py | 1 - .../test_files/mock_toolkit/pyproject.toml | 5 - 19 files changed, 1293 insertions(+), 904 deletions(-) create mode 100644 libs/arcade-cli/arcade_cli/deploy.py delete mode 100644 libs/arcade-cli/arcade_cli/deployment.py create mode 100644 libs/tests/cli/deploy/test_deploy.py create mode 100644 libs/tests/cli/deploy/test_servers/invalid_server/pyproject.toml create mode 100644 libs/tests/cli/deploy/test_servers/invalid_server/server.py create mode 100644 libs/tests/cli/deploy/test_servers/valid_server/pyproject.toml create mode 100644 libs/tests/cli/deploy/test_servers/valid_server/server.py delete mode 100644 libs/tests/deployment/test_config.py delete mode 100644 libs/tests/deployment/test_files/env.secret.worker.toml delete mode 100644 libs/tests/deployment/test_files/full.worker.toml delete mode 100644 libs/tests/deployment/test_files/invalid.fields.worker.toml delete mode 100644 libs/tests/deployment/test_files/invalid.localfile.worker.toml delete mode 100644 libs/tests/deployment/test_files/invalid.secret.worker.toml delete mode 100644 libs/tests/deployment/test_files/invalid_toolkit/invalid_main.py delete mode 100644 libs/tests/deployment/test_files/mock_toolkit/mock_main.py delete mode 100644 libs/tests/deployment/test_files/mock_toolkit/pyproject.toml diff --git a/libs/arcade-cli/arcade_cli/deploy.py b/libs/arcade-cli/arcade_cli/deploy.py new file mode 100644 index 00000000..996b2a67 --- /dev/null +++ b/libs/arcade-cli/arcade_cli/deploy.py @@ -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", + ) diff --git a/libs/arcade-cli/arcade_cli/deployment.py b/libs/arcade-cli/arcade_cli/deployment.py deleted file mode 100644 index 2787f242..00000000 --- a/libs/arcade-cli/arcade_cli/deployment.py +++ /dev/null @@ -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) diff --git a/libs/arcade-cli/arcade_cli/main.py b/libs/arcade-cli/arcade_cli/main.py index 5b536c9f..2e6613e4 100644 --- a/libs/arcade-cli/arcade_cli/main.py +++ b/libs/arcade-cli/arcade_cli/main.py @@ -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") diff --git a/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py b/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py index 4af3a8a8..3bccbf6b 100644 --- a/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py +++ b/libs/arcade-mcp-server/arcade_mcp_server/mcp_app.py @@ -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: diff --git a/libs/tests/arcade_mcp_server/test_mcp_app.py b/libs/tests/arcade_mcp_server/test_mcp_app.py index def09c0b..a88980dc 100644 --- a/libs/tests/arcade_mcp_server/test_mcp_app.py +++ b/libs/tests/arcade_mcp_server/test_mcp_app.py @@ -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") diff --git a/libs/tests/cli/deploy/test_deploy.py b/libs/tests/cli/deploy/test_deploy.py new file mode 100644 index 00000000..0a6768bc --- /dev/null +++ b/libs/tests/cli/deploy/test_deploy.py @@ -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 diff --git a/libs/tests/cli/deploy/test_servers/invalid_server/pyproject.toml b/libs/tests/cli/deploy/test_servers/invalid_server/pyproject.toml new file mode 100644 index 00000000..0656d926 --- /dev/null +++ b/libs/tests/cli/deploy/test_servers/invalid_server/pyproject.toml @@ -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 diff --git a/libs/tests/cli/deploy/test_servers/invalid_server/server.py b/libs/tests/cli/deploy/test_servers/invalid_server/server.py new file mode 100644 index 00000000..184b7851 --- /dev/null +++ b/libs/tests/cli/deploy/test_servers/invalid_server/server.py @@ -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) diff --git a/libs/tests/cli/deploy/test_servers/valid_server/pyproject.toml b/libs/tests/cli/deploy/test_servers/valid_server/pyproject.toml new file mode 100644 index 00000000..0656d926 --- /dev/null +++ b/libs/tests/cli/deploy/test_servers/valid_server/pyproject.toml @@ -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 diff --git a/libs/tests/cli/deploy/test_servers/valid_server/server.py b/libs/tests/cli/deploy/test_servers/valid_server/server.py new file mode 100644 index 00000000..1f028aaf --- /dev/null +++ b/libs/tests/cli/deploy/test_servers/valid_server/server.py @@ -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) diff --git a/libs/tests/deployment/test_config.py b/libs/tests/deployment/test_config.py deleted file mode 100644 index 805a78ac..00000000 --- a/libs/tests/deployment/test_config.py +++ /dev/null @@ -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" - ) diff --git a/libs/tests/deployment/test_files/env.secret.worker.toml b/libs/tests/deployment/test_files/env.secret.worker.toml deleted file mode 100644 index 309f61af..00000000 --- a/libs/tests/deployment/test_files/env.secret.worker.toml +++ /dev/null @@ -1,5 +0,0 @@ - -[[worker]] -[worker.config] -id = "test" -secret = "${env: TEST_WORKER_SECRET}" diff --git a/libs/tests/deployment/test_files/full.worker.toml b/libs/tests/deployment/test_files/full.worker.toml deleted file mode 100644 index 3b03b3d6..00000000 --- a/libs/tests/deployment/test_files/full.worker.toml +++ /dev/null @@ -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"] diff --git a/libs/tests/deployment/test_files/invalid.fields.worker.toml b/libs/tests/deployment/test_files/invalid.fields.worker.toml deleted file mode 100644 index bdecbcc0..00000000 --- a/libs/tests/deployment/test_files/invalid.fields.worker.toml +++ /dev/null @@ -1,3 +0,0 @@ - -[[worker]] -[worker.config] diff --git a/libs/tests/deployment/test_files/invalid.localfile.worker.toml b/libs/tests/deployment/test_files/invalid.localfile.worker.toml deleted file mode 100644 index 9ca398fb..00000000 --- a/libs/tests/deployment/test_files/invalid.localfile.worker.toml +++ /dev/null @@ -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"] diff --git a/libs/tests/deployment/test_files/invalid.secret.worker.toml b/libs/tests/deployment/test_files/invalid.secret.worker.toml deleted file mode 100644 index 4b6697c4..00000000 --- a/libs/tests/deployment/test_files/invalid.secret.worker.toml +++ /dev/null @@ -1,7 +0,0 @@ -[[worker]] -[worker.config] -id = "test" -enabled = true -timeout = 10 -retries = 3 -secret = "dev" diff --git a/libs/tests/deployment/test_files/invalid_toolkit/invalid_main.py b/libs/tests/deployment/test_files/invalid_toolkit/invalid_main.py deleted file mode 100644 index e69de29b..00000000 diff --git a/libs/tests/deployment/test_files/mock_toolkit/mock_main.py b/libs/tests/deployment/test_files/mock_toolkit/mock_main.py deleted file mode 100644 index f7cf60e1..00000000 --- a/libs/tests/deployment/test_files/mock_toolkit/mock_main.py +++ /dev/null @@ -1 +0,0 @@ -print("Hello, world!") diff --git a/libs/tests/deployment/test_files/mock_toolkit/pyproject.toml b/libs/tests/deployment/test_files/mock_toolkit/pyproject.toml deleted file mode 100644 index db164e63..00000000 --- a/libs/tests/deployment/test_files/mock_toolkit/pyproject.toml +++ /dev/null @@ -1,5 +0,0 @@ -[tool.poetry] -name = "mock_toolkit" -version = "0.1.0" -description = "Mock toolkit for Arcade" -authors = ["Arcade "]