diff --git a/libs/arcade-cli/arcade_cli/configure.py b/libs/arcade-cli/arcade_cli/configure.py index 9f503799..72fb9ccd 100644 --- a/libs/arcade-cli/arcade_cli/configure.py +++ b/libs/arcade-cli/arcade_cli/configure.py @@ -199,6 +199,16 @@ def get_vscode_config_path() -> Path: return Path.home() / ".config" / "Code" / "User" / "mcp.json" +def get_windsurf_config_path() -> Path: + """Get the Windsurf (Codeium) configuration file path.""" + return Path.home() / ".codeium" / "windsurf" / "mcp_config.json" + + +def get_amazonq_config_path() -> Path: + """Get the Amazon Q Developer configuration file path.""" + return Path.home() / ".aws" / "amazonq" / "mcp.json" + + def is_uv_installed() -> bool: """Check if uv is installed and available in PATH.""" return shutil.which("uv") is not None @@ -328,13 +338,61 @@ def configure_claude_local( console.print(" Restart Claude Desktop for changes to take effect.", style="yellow") -def configure_claude_arcade( - server_name: str, transport: str, config_path: Path | None = None +def _configure_mcpservers_arcade( + server_name: str, + gateway_url: str, + auth_token: str | None, + config_path: Path, + display_name: str, ) -> None: - """Configure Claude Desktop to add an Arcade Cloud MCP server to the configuration.""" - # This would connect to the Arcade Cloud to get the server URL - # For now, this is a placeholder - console.print("[red]Connecting to Arcade Cloud servers not yet implemented[/red]") + """Shared helper for clients that use the ``mcpServers`` JSON key. + + Used by Claude Desktop, Windsurf, and Amazon Q which all share + the same config format — only the file path and display name differ. + """ + if not config_path.is_absolute(): + config_path = Path.cwd() / config_path + + config_path.parent.mkdir(parents=True, exist_ok=True) + + config: dict = {} + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + + if "mcpServers" not in config: + config["mcpServers"] = {} + + _warn_overwrite(config, "mcpServers", server_name, config_path) + + entry: dict = {"url": gateway_url} + if auth_token: + entry["headers"] = {"Authorization": f"Bearer {auth_token}"} + config["mcpServers"][server_name] = entry + + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + + console.print(f"[green]Configured {display_name} with Arcade gateway '{server_name}'[/green]") + console.print(f" Gateway URL: {gateway_url}", style="dim") + console.print(f" Config file: {_format_path_for_display(config_path)}", style="dim") + console.print(f" Restart {display_name} for changes to take effect.", style="yellow") + + +def configure_claude_arcade( + server_name: str, + gateway_url: str, + auth_token: str | None = None, + config_path: Path | None = None, +) -> None: + """Configure Claude Desktop to connect to an Arcade Cloud MCP gateway.""" + _configure_mcpservers_arcade( + server_name, + gateway_url, + auth_token, + config_path or get_claude_config_path(), + "Claude Desktop", + ) def configure_cursor_local( @@ -422,10 +480,55 @@ def configure_cursor_local( def configure_cursor_arcade( - server_name: str, transport: str, config_path: Path | None = None + server_name: str, + gateway_url: str, + auth_token: str | None = None, + config_path: Path | None = None, ) -> None: - """Configure Cursor to add an Arcade Cloud MCP server to the configuration.""" - console.print("[red]Connecting to Arcade Cloud servers not yet implemented[/red]") + """Configure Cursor to connect to an Arcade Cloud MCP gateway.""" + if config_path is not None: + target_paths = [config_path] + elif platform.system() == "Windows": + primary_path = get_cursor_config_path() + target_paths = _dedupe_paths([primary_path, *_get_windows_cursor_config_paths()]) + else: + target_paths = [get_cursor_config_path()] + + resolved_target_paths: list[Path] = [] + for path in target_paths: + resolved_target_paths.append(path if path.is_absolute() else Path.cwd() / path) + + server_config: dict = {"type": "sse", "url": gateway_url} + if auth_token: + server_config["headers"] = {"Authorization": f"Bearer {auth_token}"} + + for idx, target in enumerate(resolved_target_paths): + target.parent.mkdir(parents=True, exist_ok=True) + + config: dict = {} + if target.exists(): + with open(target, encoding="utf-8") as f: + config = json.load(f) + + if "mcpServers" not in config: + config["mcpServers"] = {} + + if idx == 0: + _warn_overwrite(config, "mcpServers", server_name, target) + + config["mcpServers"][server_name] = server_config + + with open(target, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + + primary_config_path = resolved_target_paths[0] + console.print(f"[green]Configured Cursor with Arcade gateway '{server_name}'[/green]") + console.print(f" Gateway URL: {gateway_url}", style="dim") + console.print( + f" Config file: {_format_path_for_display(primary_config_path)}", + style="dim", + ) + console.print(" Restart Cursor for changes to take effect.", style="yellow") def configure_vscode_local( @@ -495,9 +598,294 @@ def configure_vscode_local( console.print(" Restart VS Code for changes to take effect.", style="yellow") -def configure_vscode_arcade(server_name: str, transport: str, path: Path | None = None) -> None: - """Configure VS Code to add an Arcade Cloud MCP server to the configuration.""" - console.print("[red]Connecting to Arcade Cloud servers not yet implemented[/red]") +def configure_vscode_arcade( + server_name: str, + gateway_url: str, + auth_token: str | None = None, + config_path: Path | None = None, +) -> None: + """Configure VS Code to connect to an Arcade Cloud MCP gateway.""" + config_path = config_path or get_vscode_config_path() + if config_path and not config_path.is_absolute(): + config_path = Path.cwd() / config_path + + config_path.parent.mkdir(parents=True, exist_ok=True) + + config: dict = {} + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + try: + config = json.load(f) + except json.JSONDecodeError as e: + raise ValueError( + f"\n\tFailed to load MCP configuration file at {_format_path_for_display(config_path)} " + f"\n\tThe file contains invalid JSON: {e}. " + "\n\tPlease check the file format or delete it to create a new configuration." + ) + + if "servers" not in config: + config["servers"] = {} + + _warn_overwrite(config, "servers", server_name, config_path) + + entry: dict = {"type": "http", "url": gateway_url} + if auth_token: + entry["headers"] = {"Authorization": f"Bearer {auth_token}"} + config["servers"][server_name] = entry + + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + + console.print(f"[green]Configured VS Code with Arcade gateway '{server_name}'[/green]") + console.print(f" Gateway URL: {gateway_url}", style="dim") + console.print(f" Config file: {_format_path_for_display(config_path)}", style="dim") + console.print(" Restart VS Code for changes to take effect.", style="yellow") + + +def configure_windsurf_arcade( + server_name: str, + gateway_url: str, + auth_token: str | None = None, + config_path: Path | None = None, +) -> None: + """Configure Windsurf to connect to an Arcade Cloud MCP gateway.""" + _configure_mcpservers_arcade( + server_name, gateway_url, auth_token, config_path or get_windsurf_config_path(), "Windsurf" + ) + + +def configure_amazonq_arcade( + server_name: str, + gateway_url: str, + auth_token: str | None = None, + config_path: Path | None = None, +) -> None: + """Configure Amazon Q Developer to connect to an Arcade Cloud MCP gateway.""" + _configure_mcpservers_arcade( + server_name, gateway_url, auth_token, config_path or get_amazonq_config_path(), "Amazon Q" + ) + + +def get_toolkit_stdio_config(tool_packages: list[str], server_name: str) -> dict: + """Build a stdio config that runs ``arcade mcp stdio`` with ``--tool-package`` flags. + + This configuration is used by MCP clients (Claude Desktop, Cursor, VS Code) to + launch an Arcade MCP server via ``uv tool run`` (or direct Python) with one or more + toolkit packages loaded. + """ + uv_executable = shutil.which("uv") + if uv_executable: + args = ["tool", "run", "arcade-mcp", "mcp", "stdio"] + for pkg in tool_packages: + args.extend(["--tool-package", pkg]) + return { + "command": uv_executable, + "args": args, + "env": get_tool_secrets(), + } + else: + import sys + + args = ["-m", "arcade_mcp_server", "stdio"] + for pkg in tool_packages: + args.extend(["--tool-package", pkg]) + return { + "command": sys.executable, + "args": args, + "env": get_tool_secrets(), + } + + +def get_toolkit_http_config(client: str, tool_packages: list[str], port: int = 8000) -> dict: + """Build an HTTP/SSE config entry pointing at a local ``arcade mcp http`` server. + + The server must be started separately, e.g.:: + + arcade mcp http --tool-package github --port 8000 + + Each MCP client uses a slightly different JSON shape: + - Claude Desktop / Cursor: ``url`` (+ optional ``type`` for Cursor) + - VS Code: ``type: "http"`` + ``url`` + """ + url = f"http://localhost:{port}/mcp" + client_lower = client.lower() + if client_lower == "cursor": + return {"type": "sse", "url": url} + elif client_lower == "vscode": + return {"type": "http", "url": url} + else: + # Claude Desktop and anything else: just url + return {"url": url} + + +def configure_client_gateway( + client: str, + server_name: str, + gateway_url: str, + auth_token: str | None = None, + config_path: Path | None = None, +) -> None: + """Configure an MCP client to connect to an Arcade Cloud gateway. + + If *auth_token* is ``None`` the config contains only the URL and the MCP + client handles OAuth natively. If an API key is provided it is written as + a ``Bearer`` header. + """ + client_lower = client.lower() + dispatch = { + "claude": configure_claude_arcade, + "cursor": configure_cursor_arcade, + "vscode": configure_vscode_arcade, + "windsurf": configure_windsurf_arcade, + "amazonq": configure_amazonq_arcade, + } + func = dispatch.get(client_lower) + if not func: + supported = ", ".join(sorted(dispatch)) + raise typer.BadParameter(f"Unknown client: {client}. Supported clients: {supported}.") + func(server_name, gateway_url, auth_token, config_path) + + +def configure_client_toolkit( + client: str, + server_name: str, + tool_packages: list[str], + config_path: Path | None = None, + transport: str = "stdio", + port: int = 8000, +) -> None: + """Configure an MCP client for Arcade toolkits. + + When *transport* is ``"stdio"`` (default), writes a config that launches + ``arcade mcp stdio --tool-package `` via the MCP client. + + When *transport* is ``"http"``, writes a config pointing the client at + ``http://localhost:{port}/mcp``. The user must start the server separately:: + + arcade mcp http --tool-package --port + """ + client_lower = client.lower() + if transport == "http": + server_config = get_toolkit_http_config(client, tool_packages, port) + else: + server_config = get_toolkit_stdio_config(tool_packages, server_name) + + if client_lower == "claude": + _config_path = config_path or get_claude_config_path() + if _config_path and not _config_path.is_absolute(): + _config_path = Path.cwd() / _config_path + _config_path.parent.mkdir(parents=True, exist_ok=True) + + config: dict = {} + if _config_path.exists(): + with open(_config_path, encoding="utf-8") as f: + config = json.load(f) + if "mcpServers" not in config: + config["mcpServers"] = {} + _warn_overwrite(config, "mcpServers", server_name, _config_path) + config["mcpServers"][server_name] = server_config + with open(_config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + + console.print( + f"[green]Configured Claude Desktop with Arcade toolkits: {', '.join(tool_packages)}[/green]" + ) + console.print(f" Config file: {_format_path_for_display(_config_path)}", style="dim") + console.print(" Restart Claude Desktop for changes to take effect.", style="yellow") + + elif client_lower == "cursor": + if config_path is not None: + target_paths = [config_path] + elif platform.system() == "Windows": + primary_path = get_cursor_config_path() + target_paths = _dedupe_paths([primary_path, *_get_windows_cursor_config_paths()]) + else: + target_paths = [get_cursor_config_path()] + + resolved_paths: list[Path] = [] + for path in target_paths: + resolved_paths.append(path if path.is_absolute() else Path.cwd() / path) + + for idx, target in enumerate(resolved_paths): + target.parent.mkdir(parents=True, exist_ok=True) + config = {} + if target.exists(): + with open(target, encoding="utf-8") as f: + config = json.load(f) + if "mcpServers" not in config: + config["mcpServers"] = {} + if idx == 0: + _warn_overwrite(config, "mcpServers", server_name, target) + config["mcpServers"][server_name] = server_config + with open(target, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + + console.print( + f"[green]Configured Cursor with Arcade toolkits: {', '.join(tool_packages)}[/green]" + ) + console.print(f" Config file: {_format_path_for_display(resolved_paths[0])}", style="dim") + console.print(" Restart Cursor for changes to take effect.", style="yellow") + + elif client_lower == "vscode": + _config_path = config_path or get_vscode_config_path() + if _config_path and not _config_path.is_absolute(): + _config_path = Path.cwd() / _config_path + _config_path.parent.mkdir(parents=True, exist_ok=True) + + config = {} + if _config_path.exists(): + with open(_config_path, encoding="utf-8") as f: + try: + config = json.load(f) + except json.JSONDecodeError as e: + raise ValueError( + f"\n\tFailed to load MCP configuration file at {_format_path_for_display(_config_path)} " + f"\n\tThe file contains invalid JSON: {e}. " + "\n\tPlease check the file format or delete it to create a new configuration." + ) + if "servers" not in config: + config["servers"] = {} + _warn_overwrite(config, "servers", server_name, _config_path) + config["servers"][server_name] = server_config + with open(_config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + + console.print( + f"[green]Configured VS Code with Arcade toolkits: {', '.join(tool_packages)}[/green]" + ) + console.print(f" Config file: {_format_path_for_display(_config_path)}", style="dim") + console.print(" Restart VS Code for changes to take effect.", style="yellow") + + elif client_lower in ("windsurf", "amazonq"): + path_fn = ( + get_windsurf_config_path if client_lower == "windsurf" else get_amazonq_config_path + ) + display = "Windsurf" if client_lower == "windsurf" else "Amazon Q" + _config_path = config_path or path_fn() + if _config_path and not _config_path.is_absolute(): + _config_path = Path.cwd() / _config_path + _config_path.parent.mkdir(parents=True, exist_ok=True) + + config = {} + if _config_path.exists(): + with open(_config_path, encoding="utf-8") as f: + config = json.load(f) + if "mcpServers" not in config: + config["mcpServers"] = {} + _warn_overwrite(config, "mcpServers", server_name, _config_path) + config["mcpServers"][server_name] = server_config + with open(_config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + + console.print( + f"[green]Configured {display} with Arcade toolkits: {', '.join(tool_packages)}[/green]" + ) + console.print(f" Config file: {_format_path_for_display(_config_path)}", style="dim") + console.print(f" Restart {display} for changes to take effect.", style="yellow") + + else: + supported = "claude, cursor, vscode, windsurf, amazonq" + raise typer.BadParameter(f"Unknown client: {client}. Supported clients: {supported}.") def configure_client( @@ -540,23 +928,22 @@ def configure_client( client_lower = client.lower() + if host == "arcade": + console.print( + "Use [bold]arcade connect[/bold] to connect to Arcade Cloud gateways.\n" + "Example: [bold]arcade connect claude --gateway my-gateway[/bold]", + style="yellow", + ) + return + if client_lower == "claude": if transport != "stdio": raise ValueError("Claude Desktop only supports stdio transport via configuration file") - if host == "local": - configure_claude_local(entrypoint_file, server_name, port, config_path) - else: - configure_claude_arcade(server_name, transport, config_path) + configure_claude_local(entrypoint_file, server_name, port, config_path) elif client_lower == "cursor": - if host == "local": - configure_cursor_local(entrypoint_file, server_name, transport, port, config_path) - else: - configure_cursor_arcade(server_name, transport, config_path) + configure_cursor_local(entrypoint_file, server_name, transport, port, config_path) elif client_lower == "vscode": - if host == "local": - configure_vscode_local(entrypoint_file, server_name, transport, port, config_path) - else: - configure_vscode_arcade(server_name, transport, config_path) + configure_vscode_local(entrypoint_file, server_name, transport, port, config_path) else: raise typer.BadParameter( f"Unknown client: {client}. Supported clients: claude, cursor, vscode." diff --git a/libs/arcade-cli/arcade_cli/connect.py b/libs/arcade-cli/arcade_cli/connect.py new file mode 100644 index 00000000..fae09bb4 --- /dev/null +++ b/libs/arcade-cli/arcade_cli/connect.py @@ -0,0 +1,798 @@ +"""Connect command — one-command toolkit + gateway setup for Arcade MCP.""" + +from __future__ import annotations + +import json as _json +import logging +import time +from pathlib import Path + +import httpx +from arcade_core.constants import PROD_COORDINATOR_HOST, PROD_ENGINE_HOST + +from arcade_cli.console import console + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Tool catalog cache +# --------------------------------------------------------------------------- + +_CACHE_DIR = Path.home() / ".arcade" / "cache" +_CACHE_FILE = _CACHE_DIR / "tools.json" +_CACHE_TTL_SECONDS = 300 # 5 minutes + + +def _get_context_key() -> str: + """Return a string identifying the active org+project (for cache scoping).""" + try: + from arcade_cli.utils import get_org_project_context + + org_id, project_id = get_org_project_context() + except Exception: + return "unknown" + else: + return f"{org_id}:{project_id}" + + +def _read_cache(debug: bool = False) -> dict[str, list[str]] | None: + """Return cached toolkit map if the cache file exists, is fresh, and matches the active context.""" + try: + if not _CACHE_FILE.exists(): + return None + data = _json.loads(_CACHE_FILE.read_text(encoding="utf-8")) + age = time.time() - data.get("ts", 0) + if age > _CACHE_TTL_SECONDS: + if debug: + console.print(f" [dim]Cache expired ({age:.0f}s old)[/dim]") + return None + # Invalidate if org/project changed + cached_ctx = data.get("context") + current_ctx = _get_context_key() + if cached_ctx and cached_ctx != current_ctx: + if debug: + console.print(" [dim]Cache stale (different project context)[/dim]") + return None + if debug: + console.print(f" [dim]Using cached tool catalog ({age:.0f}s old)[/dim]") + return data.get("toolkits", {}) + except Exception: + return None + + +def _write_cache(toolkits: dict[str, list[str]]) -> None: + """Persist the toolkit map to disk, scoped to the active org/project.""" + try: + _CACHE_DIR.mkdir(parents=True, exist_ok=True) + _CACHE_FILE.write_text( + _json.dumps({ + "ts": time.time(), + "context": _get_context_key(), + "toolkits": toolkits, + }), + encoding="utf-8", + ) + except Exception: + logger.debug("Failed to write tool cache", exc_info=True) + + +# --------------------------------------------------------------------------- +# Well-known toolkit metadata +# --------------------------------------------------------------------------- + +TOOLKIT_EXAMPLES: dict[str, list[str]] = { + "github": [ + "List my open pull requests", + "Show recent issues in my repo", + "Create a new issue titled 'Bug: login fails'", + ], + "slack": [ + "Send a message to #general saying hello", + "List my unread Slack messages", + "Search Slack for messages about deployment", + ], + "google": [ + "List my upcoming Google Calendar events", + "Search my Gmail for emails from Alice", + "Create a new Google Doc titled 'Meeting Notes'", + ], + "linear": [ + "Show my assigned Linear issues", + "Create a new Linear issue for the API refactor", + ], + "notion": [ + "Search my Notion workspace for project plans", + "Create a new Notion page in my workspace", + ], + "jira": [ + "List my open Jira tickets", + "Create a Jira issue for the backend migration", + ], + "spotify": [ + "Play my Discover Weekly playlist", + "What song is currently playing?", + ], + "x": [ + "Post a tweet saying 'Hello from Arcade!'", + "Search recent tweets about AI tools", + ], + "reddit": [ + "Search Reddit for posts about MCP tools", + "Get the top posts from r/programming", + ], + "figma": [ + "List my recent Figma files", + "Get comments on my latest Figma design", + ], + "atlassian": [ + "List my Confluence pages", + "Search Jira for open bugs", + ], + "dropbox": [ + "List files in my Dropbox root folder", + "Search Dropbox for 'project plan'", + ], + "asana": [ + "List my Asana tasks", + "Create a new Asana task for the launch", + ], + "hubspot": [ + "List my recent HubSpot contacts", + "Search HubSpot deals closing this month", + ], + "discord": [ + "Send a message to my Discord server", + "List channels in my Discord server", + ], + "zoom": [ + "List my upcoming Zoom meetings", + "Create a Zoom meeting for tomorrow at 2pm", + ], + "microsoft": [ + "List my recent Outlook emails", + "Search OneDrive for 'quarterly report'", + ], + "pagerduty": [ + "List my on-call schedules", + "Show recent PagerDuty incidents", + ], +} + +PRESET_BUNDLES: dict[str, list[str]] = { + "Productivity": ["google", "slack", "notion"], + "Development": ["github", "linear", "jira"], + "Communication": ["slack", "google", "x"], + "Project Management": ["linear", "jira", "notion"], + "DevOps": ["github", "slack", "linear"], + "Social": ["x", "slack", "reddit"], + "Creative": ["spotify", "figma", "notion"], +} + + +def get_toolkit_examples(toolkits: list[str]) -> list[str]: + """Return example prompts for the given toolkit names.""" + examples: list[str] = [] + for tk in toolkits: + tk_lower = tk.lower().replace("arcade-", "").replace("arcade_", "") + if tk_lower in TOOLKIT_EXAMPLES: + examples.extend(TOOLKIT_EXAMPLES[tk_lower][:2]) + if not examples: + examples.append("Ask your AI assistant to use one of the configured tools!") + return examples + + +# --------------------------------------------------------------------------- +# Login helper +# --------------------------------------------------------------------------- + + +def ensure_login(coordinator_url: str | None = None) -> str: + """Ensure the user is logged in, triggering OAuth if needed. + + Returns the valid access token. + """ + from arcade_cli.authn import ( + OAuthLoginError, + check_existing_login, + get_valid_access_token, + perform_oauth_login, + save_credentials_from_whoami, + ) + + resolved_url = coordinator_url or f"https://{PROD_COORDINATOR_HOST}" + + if check_existing_login(suppress_message=True): + return get_valid_access_token(resolved_url) + + console.print("You need to log in to Arcade first.\n", style="yellow") + try: + result = perform_oauth_login( + resolved_url, + on_status=lambda msg: console.print(msg, style="dim"), + ) + save_credentials_from_whoami(result.tokens, result.whoami, resolved_url) + console.print(f"\nLogged in as {result.email}.", style="bold green") + return get_valid_access_token(resolved_url) + except OAuthLoginError as e: + raise SystemExit(f"Login failed: {e}") from e + + +# --------------------------------------------------------------------------- +# Arcade API helpers +# --------------------------------------------------------------------------- + + +def fetch_available_toolkits( + base_url: str | None = None, + debug: bool = False, + skip_cache: bool = False, +) -> dict[str, list[str]]: + """Fetch tools from the Arcade Engine and group them by toolkit name. + + Results are cached to ``~/.arcade/cache/tools.json`` for 5 minutes so + repeated invocations (e.g. interactive → allow-list) are instant. + + Returns a dict mapping toolkit names to lists of tool qualified names + (e.g. ``"Github.ListPRs"``). + """ + if not skip_cache: + cached = _read_cache(debug=debug) + if cached is not None: + return cached + + from arcadepy import NOT_GIVEN, APIConnectionError + + from arcade_cli.utils import compute_base_url, get_arcade_client + + url = base_url or compute_base_url(False, False, PROD_ENGINE_HOST, None, default_port=None) + if debug: + console.print(f" [dim]Connecting to Arcade Engine at {url}[/dim]") + client = get_arcade_client(url) + + toolkits: dict[str, list[str]] = {} + tool_count = 0 + try: + # limit= is the page size, not a cap — the iterator auto-paginates + for tool in client.tools.list(toolkit=NOT_GIVEN, limit=1000): + toolkit_name = getattr(tool.toolkit, "name", None) or "unknown" + tool_name = tool.name or "unknown" + # Gateway API requires qualified names: "ToolkitName.ToolName" + qualified = f"{toolkit_name}.{tool_name}" + toolkits.setdefault(toolkit_name, []).append(qualified) + tool_count += 1 + if debug: + console.print(f" [dim] Found tool: {qualified}[/dim]") + except APIConnectionError: + console.print(f"Could not connect to Arcade Engine at {url}.", style="bold red") + except Exception as e: + if debug: + console.print(f" [dim]Error fetching toolkits: {e}[/dim]") + else: + logger.debug("Failed to fetch toolkits: %s", e) + console.print( + "Could not fetch available toolkits from your account.", + style="bold red", + ) + + if debug: + console.print( + f" [dim]Fetched {tool_count} tools across {len(toolkits)} toolkits: " + f"{list(toolkits.keys())}[/dim]" + ) + + if toolkits: + _write_cache(toolkits) + + return toolkits + + +def list_gateways( + access_token: str, + base_url: str | None = None, + debug: bool = False, +) -> list[dict]: + """List existing MCP gateways from the user's project. + + Returns a list of gateway dicts (each with ``id``, ``slug``, ``name``, + ``tool_filter``, etc.). + """ + from arcade_cli.utils import compute_base_url, get_org_project_context + + url = base_url or compute_base_url(False, False, PROD_ENGINE_HOST, None, default_port=None) + org_id, project_id = get_org_project_context() + + endpoint = f"{url}/v1/orgs/{org_id}/projects/{project_id}/gateways" + + if debug: + console.print(f" [dim]GET {endpoint}[/dim]") + + resp = httpx.get( + endpoint, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30, + ) + + if resp.status_code != 200: + if debug: + console.print(f" [dim]Failed to list gateways: {resp.status_code}[/dim]") + return [] + + data = resp.json() + return data.get("items", []) + + +def find_matching_gateway( + gateways: list[dict], + tool_allow_list: list[str], + auth_type: str = "arcade", + debug: bool = False, +) -> dict | None: + """Find an existing gateway whose allow-list is a superset of *tool_allow_list* + and whose ``auth_type`` matches.""" + needed = set(tool_allow_list) + for gw in gateways: + if gw.get("auth_type", "arcade") != auth_type: + continue + existing = set(gw.get("tool_filter", {}).get("allowed_tools", [])) + if needed <= existing: + if debug: + console.print( + f" [dim]Found existing gateway '{gw.get('slug')}' " + f"with {len(existing)} tools (covers all {len(needed)} needed)[/dim]" + ) + return gw + return None + + +def create_gateway( + access_token: str, + name: str, + tool_allow_list: list[str], + auth_type: str = "arcade", + slug: str | None = None, + base_url: str | None = None, + debug: bool = False, +) -> dict: + """Create a new MCP gateway on Arcade Cloud. + + Args: + access_token: OAuth access token for the Engine API. + name: Human-readable gateway name. + tool_allow_list: Qualified tool names (e.g. ``"Github.CreateIssue"``). + auth_type: ``"arcade"`` (OAuth, default) or ``"arcade_header"`` (API key). + slug: Custom slug for the gateway URL. Auto-generated if not provided. + base_url: Engine API base URL override. + debug: Print debug output. + + Returns the gateway response dict (with ``slug``, ``id``, ``name``, etc.). + """ + from arcade_cli.utils import compute_base_url, get_org_project_context + + url = base_url or compute_base_url(False, False, PROD_ENGINE_HOST, None, default_port=None) + org_id, project_id = get_org_project_context() + + endpoint = f"{url}/v1/orgs/{org_id}/projects/{project_id}/gateways" + body: dict = { + "name": name, + "auth_type": auth_type, + "tool_filter": {"allowed_tools": tool_allow_list}, + } + if slug: + body["slug"] = slug + + if debug: + console.print(f" [dim]POST {endpoint}[/dim]") + console.print(f" [dim]Body: {body}[/dim]") + + resp = httpx.post( + endpoint, + json=body, + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + }, + timeout=30, + ) + + if debug: + console.print(f" [dim]Response: {resp.status_code}[/dim]") + console.print(f" [dim]{resp.text[:500]}[/dim]") + + if resp.status_code not in (200, 201): + raise RuntimeError(f"Failed to create gateway ({resp.status_code}): {resp.text}") + + data = resp.json() + + # The API may return the gateway directly or wrapped in a list/items envelope + if "slug" in data: + return data + if data.get("items"): + return data["items"][0] + if "id" in data: + return data + + if debug: + console.print(f" [dim]Unexpected response shape: {list(data.keys())}[/dim]") + return data + + +def create_project_api_key( + access_token: str, + label: str = "quickstart", + debug: bool = False, +) -> str: + """Create a project API key via the Coordinator. + + Calls ``POST /v1/projects/{project_id}/api_keys``. + + Returns the raw API key string (``arc_...``). + """ + from arcade_cli.utils import get_org_project_context + + _, project_id = get_org_project_context() + coordinator_url = f"https://{PROD_COORDINATOR_HOST}" + endpoint = f"{coordinator_url}/v1/projects/{project_id}/api_keys" + + if debug: + console.print(f" [dim]POST {endpoint}[/dim]") + + resp = httpx.post( + endpoint, + json={"label": label}, + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + }, + timeout=30, + ) + + if debug: + console.print(f" [dim]Response: {resp.status_code}[/dim]") + console.print(f" [dim]{resp.text[:300]}[/dim]") + + if resp.status_code not in (200, 201): + raise RuntimeError(f"Failed to create API key ({resp.status_code}): {resp.text}") + + return resp.json()["api_key"] + + +# --------------------------------------------------------------------------- +# Interactive selection +# --------------------------------------------------------------------------- + + +def prompt_toolkit_selection(available: dict[str, list[str]]) -> list[str]: + """Interactively prompt the user to select toolkits. + + Returns a list of selected toolkit names. + """ + if not available: + console.print("No toolkits available in your account.", style="bold red") + raise SystemExit(1) + + console.print("\n[bold]Available toolkits:[/bold]\n") + + # Case-insensitive lookup: preset says "github", API returns "Github" + avail_lower: dict[str, str] = {k.lower(): k for k in available} + + # Show preset bundles first + bundle_choices: list[tuple[str, list[str]]] = [] + for bundle_name, bundle_tks in PRESET_BUNDLES.items(): + # Resolve each preset toolkit to its actual API key + matching = [avail_lower[t] for t in bundle_tks if t in avail_lower] + if matching: + bundle_choices.append((bundle_name, matching)) + + sorted_toolkits = sorted(available.keys()) + + # Number the options + idx = 1 + option_map: dict[int, list[str]] = {} + + for bundle_name, bundle_tks in bundle_choices: + tool_count = sum(len(available.get(t, [])) for t in bundle_tks) + display_names = ", ".join(t.lower() for t in bundle_tks) + console.print( + f" [bold cyan]{idx}.[/bold cyan] {bundle_name} bundle " + f"({display_names}) — {tool_count} tools" + ) + option_map[idx] = bundle_tks + idx += 1 + + if bundle_choices: + console.print() + + for tk_name in sorted_toolkits: + tools = available[tk_name] + console.print(f" [bold cyan]{idx}.[/bold cyan] {tk_name} — {len(tools)} tools") + option_map[idx] = [tk_name] + idx += 1 + + console.print(f"\n [bold cyan]{idx}.[/bold cyan] All available toolkits") + option_map[idx] = sorted_toolkits + + console.print() + try: + raw = input("Select toolkits (comma-separated numbers, e.g. 1,3): ").strip() + except (EOFError, KeyboardInterrupt): + raise SystemExit("\nCancelled.") + + selected: list[str] = [] + for part in raw.split(","): + part = part.strip() + if not part: + continue + try: + choice = int(part) + except ValueError: + console.print(f" Skipping invalid choice: {part}", style="yellow") + continue + if choice in option_map: + for tk in option_map[choice]: + if tk not in selected: + selected.append(tk) + else: + console.print(f" Skipping unknown option: {choice}", style="yellow") + + if not selected: + console.print("No toolkits selected.", style="bold red") + raise SystemExit(1) + + return selected + + +# --------------------------------------------------------------------------- +# Main orchestrator +# --------------------------------------------------------------------------- + + +def run_connect( + client: str, + toolkits: list[str] | None = None, + tools: list[str] | None = None, + gateway: str | None = None, + all_tools: bool = False, + use_api_key: bool = False, + gateway_slug: str | None = None, + config_path: Path | None = None, + debug: bool = False, +) -> None: + """Run the quickstart flow: login → determine mode → configure client. + + Everything is configured as a cloud gateway — no local server required. + + Args: + toolkits: Whole toolkit names (e.g. ``["github"]``) — adds all tools. + tools: Individual qualified tool names (e.g. ``["Github.CreateIssue"]``). + gateway: Existing gateway slug to connect to directly. + """ + + # Step 1: Ensure login + access_token = ensure_login() + + # Step 2: Determine mode + if gateway: + # --- Direct gateway mode (existing gateway) --- + # Resolve the input: user may pass a name ("opencode") or slug ("pascal_opencode") + slug = _resolve_gateway_slug(gateway, access_token, debug=debug) + api_key: str | None = None + if use_api_key: + console.print("Creating project API key...", style="dim") + api_key = create_project_api_key(access_token, label=f"connect-{slug}", debug=debug) + console.print(" API key created.", style="green") + _configure_gateway(client, slug, config_path, api_key=api_key, name=gateway) + return + + # --- Toolkit / tool → gateway mode --- + + # If only --tool is given (no --toolkit, --all, or interactive), skip catalog fetch + if tools and not toolkits and not all_tools: + tool_allow_list = list(tools) + selected_toolkits = sorted({t.split(".")[0] for t in tools}) + console.print( + f"\nSetting up gateway with {len(tool_allow_list)} individual tool(s): " + f"[bold]{', '.join(tool_allow_list)}[/bold]\n" + ) + else: + selected_toolkits_list: list[str] + + # Fetch the tool catalog once — used for selection and allow-list + console.print("Fetching tool catalog...", style="dim") + available = fetch_available_toolkits(debug=debug) + + if toolkits: + selected_toolkits_list = toolkits + elif all_tools: + if not available: + console.print( + "No toolkits found. Make sure you have tools deployed in your Arcade account.", + style="bold red", + ) + raise SystemExit(1) + selected_toolkits_list = sorted(available.keys()) + else: + # Interactive mode + if not available: + console.print( + "No toolkits found in your account. You can specify toolkits manually:\n" + " [bold]arcade connect claude --toolkit github[/bold]", + style="yellow", + ) + raise SystemExit(1) + selected_toolkits_list = prompt_toolkit_selection(available) + + selected_toolkits = selected_toolkits_list + + console.print( + f"\nSetting up gateway for toolkits: [bold]{', '.join(selected_toolkits)}[/bold]\n" + ) + + # Build a case-insensitive lookup: "github" -> "Github", etc. + tk_lower_map: dict[str, str] = {k.lower(): k for k in available} + + if debug: + console.print(f" [dim]Available toolkit keys: {list(available.keys())}[/dim]") + console.print(f" [dim]Looking for: {selected_toolkits}[/dim]") + + tool_allow_list = [] + for tk in selected_toolkits: + actual_key = tk_lower_map.get(tk.lower()) + if actual_key: + tk_tools = available[actual_key] + tool_allow_list.extend(tk_tools) + if debug: + console.print( + f" [dim]Matched '{tk}' -> '{actual_key}' ({len(tk_tools)} tools)[/dim]" + ) + else: + console.print(f" [yellow]Warning: No tools found for toolkit '{tk}'.[/yellow]") + if available: + console.print( + f" [yellow]Available toolkits: " + f"{', '.join(sorted(available.keys()))}[/yellow]" + ) + + # Append any individual --tool names + if tools: + for t in tools: + if t not in tool_allow_list: + tool_allow_list.append(t) + if debug: + console.print(f" [dim]Added {len(tools)} individual tool(s)[/dim]") + + if not tool_allow_list: + console.print( + "\nNo tools to add to the gateway. Deploy toolkits first with " + "[bold]arcade deploy[/bold].", + style="bold red", + ) + raise SystemExit(1) + + # Check if an existing gateway already covers these tools + auth_type = "arcade_header" if use_api_key else "arcade" + console.print("Checking existing gateways...", style="dim") + existing_gateways = list_gateways(access_token, debug=debug) + existing = find_matching_gateway( + existing_gateways, tool_allow_list, auth_type=auth_type, debug=debug + ) + + if existing: + slug = existing["slug"] + console.print( + f" Found existing gateway: [bold]{existing.get('name', slug)}[/bold] (slug: {slug})\n", + style="green", + ) + else: + # Create a new gateway + if len(selected_toolkits) == 1: + gateway_name = selected_toolkits[0].lower() + else: + gateway_name = "-".join(sorted({tk.lower() for tk in selected_toolkits})) + console.print( + f"Creating gateway '{gateway_name}' with {len(tool_allow_list)} tools " + f"(auth: {auth_type})...", + style="dim", + ) + + gw = create_gateway( + access_token=access_token, + name=gateway_name, + tool_allow_list=tool_allow_list, + auth_type=auth_type, + slug=gateway_slug, + debug=debug, + ) + + slug = gw.get("slug", gateway_name) + if debug: + console.print(f" [dim]Gateway response: id={gw.get('id')}, slug={slug}[/dim]") + console.print(f" Gateway created: [bold]{slug}[/bold]\n", style="green") + + # For API-key auth, create a project key and include it in the config + api_key: str | None = None + if use_api_key: + console.print("Creating project API key...", style="dim") + api_key = create_project_api_key(access_token, label=f"connect-{slug}", debug=debug) + console.print(" API key created.", style="green") + + # Config key: prefer --slug if given, otherwise derive from toolkit names + if gateway_slug: + display_name = gateway_slug + elif len(selected_toolkits) == 1: + display_name = selected_toolkits[0].lower() + else: + display_name = "-".join(sorted({tk.lower() for tk in selected_toolkits})) + _configure_gateway(client, slug, config_path, api_key=api_key, name=display_name) + + # Print examples + examples = get_toolkit_examples(selected_toolkits) + console.print("\nTry asking your AI assistant:", style="bold") + for ex in examples[:3]: + console.print(f" - {ex}", style="dim") + + +def _resolve_gateway_slug( + user_input: str, + access_token: str, + debug: bool = False, +) -> str: + """Resolve a gateway name or slug to the actual slug. + + The user may pass a name (``opencode``) or a slug (``pascal_opencode``). + We look up existing gateways and match by slug first, then by name. + Falls back to the original input if no match is found. + """ + gateways = list_gateways(access_token, debug=debug) + input_lower = user_input.lower() + for gw in gateways: + if gw.get("slug", "").lower() == input_lower: + if debug: + console.print(f" [dim]Matched by slug: {gw['slug']}[/dim]") + return gw["slug"] + for gw in gateways: + if gw.get("name", "").lower() == input_lower: + slug = gw["slug"] + if debug: + console.print(f" [dim]Matched by name '{gw['name']}' -> slug: {slug}[/dim]") + return slug + if debug: + available = [f"{g.get('name')} ({g.get('slug')})" for g in gateways] + console.print(f" [dim]No match for '{user_input}', available: {available}[/dim]") + return user_input + + +def _configure_gateway( + client: str, + slug: str, + config_path: Path | None, + api_key: str | None = None, + name: str | None = None, +) -> None: + """Configure the MCP client to connect to a gateway by slug. + + *name* is the human-readable label used as the config key (e.g. ``github``). + Defaults to *slug* if not provided. + """ + from arcade_cli.configure import configure_client_gateway + from arcade_cli.utils import compute_base_url + + api_base = compute_base_url(False, False, PROD_ENGINE_HOST, None, default_port=None) + gateway_url = f"{api_base}/mcp/{slug}" + server_name = name or slug + + console.print(f"Configuring [bold]{client}[/bold] to connect to gateway: [bold]{slug}[/bold]\n") + + configure_client_gateway( + client=client, + server_name=server_name, + gateway_url=gateway_url, + auth_token=api_key, + config_path=config_path, + ) + + console.print("\n[bold green]Setup complete![/bold green]") + console.print(f" Gateway URL: {gateway_url}", style="dim") + if api_key: + console.print(" Auth: API key (included in config)", style="dim") + else: + console.print(" Auth: OAuth (handled by your MCP client)", style="dim") diff --git a/libs/arcade-cli/arcade_cli/main.py b/libs/arcade-cli/arcade_cli/main.py index 9d7add7a..93e6ce9d 100644 --- a/libs/arcade-cli/arcade_cli/main.py +++ b/libs/arcade-cli/arcade_cli/main.py @@ -693,7 +693,10 @@ def evals( handle_cli_error("Failed to run evaluations", e, debug) -@cli.command(help="Configure MCP clients to connect to your server", rich_help_panel="Manage") +@cli.command( + help="Configure an MCP client to use a local server on your filesystem", + rich_help_panel="Manage", +) def configure( client: str = typer.Argument( ..., @@ -727,7 +730,7 @@ def configure( "local", "--host", "-h", - help="The host of the HTTP server to configure. Use 'local' to connect to a local MCP server or 'arcade' to connect to an Arcade Cloud MCP server.", + help="The host for HTTP transport. Use 'local' for a local server. ('arcade' is supported but 'arcade connect' is the recommended way to set up remote gateways.)", click_type=click.Choice(["local", "arcade"], case_sensitive=False), show_choices=True, rich_help_panel="HTTP Options", @@ -750,16 +753,19 @@ def configure( debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), ) -> None: """ - Configure MCP clients to connect to your server. + Configure an MCP client to use a local server on your filesystem. - The default behavior is to configure the specified client for a local stdio server that - runs when the server.py file in the current directory is invoked directly. + Points your MCP client at a server you are developing or running locally. + By default, configures a stdio transport that launches the server.py file + in the current directory. Use --transport http for a running local HTTP server. + + To connect to remote Arcade Cloud gateways instead, use 'arcade connect'. Examples: arcade configure claude arcade configure cursor --transport http --port 8080 - arcade configure vscode --host arcade --entrypoint my_server.py --config .vscode/mcp.json - arcade configure claude --host local --name my_server_name + arcade configure vscode --entrypoint my_server.py --config .vscode/mcp.json + arcade configure claude --name my_server_name """ from arcade_cli.configure import configure_client @@ -777,6 +783,118 @@ def configure( handle_cli_error(f"Failed to configure {client}", e, debug) +@cli.command( + name="connect", + help="Connect an MCP client to a remote Arcade Cloud gateway", + rich_help_panel="Run", +) +def connect( + client: str = typer.Argument( + ..., + help="MCP client to connect to the remote gateway", + click_type=click.Choice( + ["claude", "cursor", "vscode", "windsurf", "amazonq"], + case_sensitive=False, + ), + show_choices=True, + ), + server: Optional[list[str]] = typer.Option( + None, + "--server", + "-t", + help="Server(s) to set up — adds all tools from each server. Can be repeated.", + ), + tool: Optional[list[str]] = typer.Option( + None, + "--tool", + help="Individual tool(s) by qualified name (e.g., Github.CreateIssue). Can be repeated.", + ), + preset: Optional[str] = typer.Option( + None, + "--preset", + help="Use a preset bundle (productivity, development, communication, devops, social, creative, project-management).", + ), + gateway: Optional[str] = typer.Option( + None, + "--gateway", + "-g", + help="Connect to an Arcade Cloud gateway by slug instead of local toolkits.", + ), + all_tools: bool = typer.Option( + False, + "--all", + help="Set up all available toolkits from your account without prompting.", + ), + slug: Optional[str] = typer.Option( + None, + "--slug", + "-s", + help="Custom slug for the created gateway (only with --server/--tool/--preset).", + ), + api_key: bool = typer.Option( + False, + "--api-key", + help="Use API-key auth instead of OAuth. Creates a project API key and includes it in the client config.", + ), + config_path: Optional[Path] = typer.Option( + None, + "--config", + "-c", + exists=False, + help="Custom path to the MCP client config file (overrides default).", + ), + debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), +) -> None: + """ + Connect an MCP client to a remote Arcade Cloud gateway. + + No local server needed — tools run in the cloud. Logs you in (if needed), + creates an Arcade Cloud gateway for the selected toolkits, and writes your + MCP client config, all in one step. + + By default gateways use OAuth (the MCP client handles the auth flow). + Pass --api-key to use API-key auth instead (creates a key automatically). + + To configure a local server on your filesystem instead, use 'arcade configure'. + + Examples:\n + arcade connect claude --server github\n + arcade connect cursor --preset productivity\n + arcade connect claude --tool Github.CreateIssue --tool Linear.UpdateIssue\n + arcade connect claude --gateway my-existing-gw\n + arcade connect vscode --all --api-key\n + """ + from arcade_cli.connect import PRESET_BUNDLES, run_connect + + # Resolve --preset to toolkit list + resolved_toolkits = list(server) if server else None + if preset: + preset_lower = preset.lower().replace("-", " ") + match = {k.lower(): v for k, v in PRESET_BUNDLES.items()}.get(preset_lower) + if not match: + available = ", ".join(k.lower().replace(" ", "-") for k in PRESET_BUNDLES) + handle_cli_error(f"Unknown preset '{preset}'. Available presets: {available}") + return + resolved_toolkits = (resolved_toolkits or []) + match + + try: + run_connect( + client=client, + toolkits=resolved_toolkits, + tools=list(tool) if tool else None, + gateway=gateway, + all_tools=all_tools, + use_api_key=api_key, + gateway_slug=slug, + config_path=config_path, + debug=debug, + ) + except SystemExit: + raise + except Exception as e: + handle_cli_error("Quickstart failed", e, debug) + + @cli.command( name="deploy", help="Deploy MCP servers to Arcade", @@ -1005,6 +1123,7 @@ def main_callback( new.__name__, show.__name__, configure.__name__, + connect.__name__, update.__name__, upgrade.__name__, } diff --git a/libs/tests/cli/test_configure.py b/libs/tests/cli/test_configure.py index b2d9e3de..fc7ba9b3 100644 --- a/libs/tests/cli/test_configure.py +++ b/libs/tests/cli/test_configure.py @@ -1,4 +1,4 @@ -"""Tests for get_tool_secrets() in arcade configure.""" +"""Tests for get_tool_secrets() and gateway configuration in arcade configure.""" import json import sys @@ -11,8 +11,17 @@ from arcade_cli.configure import ( _format_path_for_display, _resolve_windows_appdata, _warn_overwrite, + configure_amazonq_arcade, + configure_claude_arcade, configure_client, + configure_client_gateway, + configure_client_toolkit, + configure_cursor_arcade, + configure_vscode_arcade, + configure_windsurf_arcade, get_tool_secrets, + get_toolkit_http_config, + get_toolkit_stdio_config, ) @@ -89,8 +98,7 @@ def test_format_path_for_display_posix_escapes() -> None: else: path = Path("/tmp/with space/mcp.json") assert ( - _format_path_for_display(path, platform_system="Linux") - == "/tmp/with\\ space/mcp.json" + _format_path_for_display(path, platform_system="Linux") == "/tmp/with\\ space/mcp.json" ) @@ -108,9 +116,7 @@ def test_resolve_windows_appdata_delegates_to_platformdirs( monkeypatch.delenv("USERPROFILE", raising=False) fake_platformdirs = types.ModuleType("platformdirs") - fake_platformdirs.user_data_dir = ( - lambda *args, **kwargs: r"C:\Users\Alice\AppData\Roaming" - ) + fake_platformdirs.user_data_dir = lambda *args, **kwargs: r"C:\Users\Alice\AppData\Roaming" monkeypatch.setitem(sys.modules, "platformdirs", fake_platformdirs) assert _resolve_windows_appdata() == Path(r"C:\Users\Alice\AppData\Roaming") @@ -145,7 +151,9 @@ def test_resolve_windows_appdata_handles_older_platformdirs( assert len(received_args) == 1, "Fallback must make exactly one positional call" fallback_args = received_args[0] # args: (None, False, None, True) — roaming is the 4th positional arg - assert len(fallback_args) == 4, f"Expected 4 positional args, got {len(fallback_args)}: {fallback_args}" + assert len(fallback_args) == 4, ( + f"Expected 4 positional args, got {len(fallback_args)}: {fallback_args}" + ) assert fallback_args[3] is True, f"4th arg (roaming) must be True, got {fallback_args[3]}" assert fallback_args[2] is None, f"3rd arg (version) must be None, got {fallback_args[2]}" @@ -273,7 +281,9 @@ def test_config_written_as_utf8(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) assert "demo" in data["mcpServers"] -def test_config_roundtrip_preserves_unicode(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_config_roundtrip_preserves_unicode( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Write a config with Unicode, then overwrite and verify it still decodes.""" monkeypatch.chdir(tmp_path) _write_entrypoint(tmp_path) @@ -507,3 +517,305 @@ def test_claude_config_stdio_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatc port=8000, config_path=config_path, ) + + +# --------------------------------------------------------------------------- +# configure_*_arcade() — gateway configuration +# --------------------------------------------------------------------------- + + +class TestConfigureClaudeArcade: + def test_writes_gateway_url_and_headers(self, tmp_path: Path) -> None: + config_path = tmp_path / "claude.json" + configure_claude_arcade( + server_name="my-gw", + gateway_url="https://api.arcade.dev/mcp/my-gw", + auth_token="tok_abc", + config_path=config_path, + ) + config = _load_json(config_path) + entry = config["mcpServers"]["my-gw"] + assert entry["url"] == "https://api.arcade.dev/mcp/my-gw" + assert entry["headers"]["Authorization"] == "Bearer tok_abc" + + def test_preserves_existing_entries(self, tmp_path: Path) -> None: + config_path = tmp_path / "claude.json" + config_path.write_text( + json.dumps({"mcpServers": {"existing": {"command": "old"}}}), + encoding="utf-8", + ) + configure_claude_arcade( + server_name="new-gw", + gateway_url="https://api.arcade.dev/mcp/new-gw", + auth_token="tok", + config_path=config_path, + ) + config = _load_json(config_path) + assert "existing" in config["mcpServers"] + assert "new-gw" in config["mcpServers"] + + +class TestConfigureCursorArcade: + def test_writes_sse_config(self, tmp_path: Path) -> None: + config_path = tmp_path / "cursor.json" + configure_cursor_arcade( + server_name="my-gw", + gateway_url="https://api.arcade.dev/mcp/my-gw", + auth_token="tok_abc", + config_path=config_path, + ) + config = _load_json(config_path) + entry = config["mcpServers"]["my-gw"] + assert entry["type"] == "sse" + assert entry["url"] == "https://api.arcade.dev/mcp/my-gw" + assert entry["headers"]["Authorization"] == "Bearer tok_abc" + + +class TestConfigureVscodeArcade: + def test_writes_http_config(self, tmp_path: Path) -> None: + config_path = tmp_path / "vscode.json" + configure_vscode_arcade( + server_name="my-gw", + gateway_url="https://api.arcade.dev/mcp/my-gw", + auth_token="tok_abc", + config_path=config_path, + ) + config = _load_json(config_path) + entry = config["servers"]["my-gw"] + assert entry["type"] == "http" + assert entry["url"] == "https://api.arcade.dev/mcp/my-gw" + assert entry["headers"]["Authorization"] == "Bearer tok_abc" + + +# --------------------------------------------------------------------------- +# configure_client_gateway() — dispatcher +# --------------------------------------------------------------------------- + + +class TestConfigureClientGateway: + @pytest.mark.parametrize( + "client,section", + [ + ("claude", "mcpServers"), + ("cursor", "mcpServers"), + ("vscode", "servers"), + ("windsurf", "mcpServers"), + ("amazonq", "mcpServers"), + ], + ) + def test_dispatches_to_correct_client(self, tmp_path: Path, client: str, section: str) -> None: + config_path = tmp_path / f"{client}.json" + configure_client_gateway( + client=client, + server_name="test-gw", + gateway_url="https://api.arcade.dev/mcp/test-gw", + auth_token="tok", + config_path=config_path, + ) + config = _load_json(config_path) + assert "test-gw" in config[section] + + +# --------------------------------------------------------------------------- +# configure_client_toolkit() — toolkit stdio config +# --------------------------------------------------------------------------- + + +class TestConfigureClientToolkit: + def test_claude_toolkit_stdio(self, tmp_path: Path) -> None: + config_path = tmp_path / "claude.json" + configure_client_toolkit( + client="claude", + server_name="arcade-github", + tool_packages=["github"], + config_path=config_path, + transport="stdio", + ) + config = _load_json(config_path) + entry = config["mcpServers"]["arcade-github"] + assert "command" in entry + assert "--tool-package" in entry["args"] + assert "github" in entry["args"] + + def test_claude_toolkit_http(self, tmp_path: Path) -> None: + config_path = tmp_path / "claude.json" + configure_client_toolkit( + client="claude", + server_name="arcade-github", + tool_packages=["github"], + config_path=config_path, + transport="http", + port=8000, + ) + config = _load_json(config_path) + entry = config["mcpServers"]["arcade-github"] + assert entry["url"] == "http://localhost:8000/mcp" + assert "command" not in entry + + def test_cursor_toolkit_http(self, tmp_path: Path) -> None: + config_path = tmp_path / "cursor.json" + configure_client_toolkit( + client="cursor", + server_name="arcade-github", + tool_packages=["github"], + config_path=config_path, + transport="http", + port=9000, + ) + config = _load_json(config_path) + entry = config["mcpServers"]["arcade-github"] + assert entry["type"] == "sse" + assert entry["url"] == "http://localhost:9000/mcp" + + def test_vscode_toolkit_stdio(self, tmp_path: Path) -> None: + config_path = tmp_path / "vscode.json" + configure_client_toolkit( + client="vscode", + server_name="arcade-tools", + tool_packages=["github", "slack"], + config_path=config_path, + transport="stdio", + ) + config = _load_json(config_path) + entry = config["servers"]["arcade-tools"] + assert "command" in entry + args_str = " ".join(str(a) for a in entry["args"]) + assert "github" in args_str + assert "slack" in args_str + + def test_vscode_toolkit_http(self, tmp_path: Path) -> None: + config_path = tmp_path / "vscode.json" + configure_client_toolkit( + client="vscode", + server_name="arcade-tools", + tool_packages=["github", "slack"], + config_path=config_path, + transport="http", + ) + config = _load_json(config_path) + entry = config["servers"]["arcade-tools"] + assert entry["type"] == "http" + assert entry["url"] == "http://localhost:8000/mcp" + + def test_windsurf_toolkit_stdio(self, tmp_path: Path) -> None: + config_path = tmp_path / "windsurf.json" + configure_client_toolkit( + client="windsurf", + server_name="arcade-github", + tool_packages=["github"], + config_path=config_path, + ) + config = _load_json(config_path) + entry = config["mcpServers"]["arcade-github"] + assert "command" in entry + assert "--tool-package" in entry["args"] + + def test_amazonq_toolkit_stdio(self, tmp_path: Path) -> None: + config_path = tmp_path / "amazonq.json" + configure_client_toolkit( + client="amazonq", + server_name="arcade-github", + tool_packages=["github"], + config_path=config_path, + ) + config = _load_json(config_path) + entry = config["mcpServers"]["arcade-github"] + assert "command" in entry + assert "--tool-package" in entry["args"] + + +# --------------------------------------------------------------------------- +# get_toolkit_stdio_config() +# --------------------------------------------------------------------------- + + +class TestGetToolkitStdioConfig: + def test_uses_uv_when_available(self, monkeypatch: pytest.MonkeyPatch) -> None: + import arcade_cli.configure as configure_mod + + monkeypatch.setattr( + configure_mod.shutil, "which", lambda exe: "/usr/bin/uv" if exe == "uv" else None + ) + config = get_toolkit_stdio_config(["github"], "arcade-github") + assert config["command"] == "/usr/bin/uv" + assert "tool" in config["args"] + assert "run" in config["args"] + assert "--tool-package" in config["args"] + assert "github" in config["args"] + + def test_falls_back_to_python(self, monkeypatch: pytest.MonkeyPatch) -> None: + import arcade_cli.configure as configure_mod + + monkeypatch.setattr(configure_mod.shutil, "which", lambda exe: None) + config = get_toolkit_stdio_config(["github"], "arcade-github") + assert "python" in config["command"].lower() or config["command"].endswith("python3") + assert "--tool-package" in config["args"] + + +# --------------------------------------------------------------------------- +# get_toolkit_http_config() +# --------------------------------------------------------------------------- + + +class TestGetToolkitHttpConfig: + def test_claude_config(self) -> None: + config = get_toolkit_http_config("claude", ["github"]) + assert config["url"] == "http://localhost:8000/mcp" + assert "type" not in config + + def test_cursor_config(self) -> None: + config = get_toolkit_http_config("cursor", ["github"]) + assert config["type"] == "sse" + assert config["url"] == "http://localhost:8000/mcp" + + def test_vscode_config(self) -> None: + config = get_toolkit_http_config("vscode", ["github"]) + assert config["type"] == "http" + assert config["url"] == "http://localhost:8000/mcp" + + def test_custom_port(self) -> None: + config = get_toolkit_http_config("claude", ["github"], port=9000) + assert config["url"] == "http://localhost:9000/mcp" + + +# --------------------------------------------------------------------------- +# New clients: Windsurf, Amazon Q, Zed +# --------------------------------------------------------------------------- + + +class TestConfigureWindsurfArcade: + def test_writes_mcpservers_config(self, tmp_path: Path) -> None: + config_path = tmp_path / "windsurf.json" + configure_windsurf_arcade( + server_name="my-gw", + gateway_url="https://api.arcade.dev/mcp/my-gw", + config_path=config_path, + ) + config = _load_json(config_path) + entry = config["mcpServers"]["my-gw"] + assert entry["url"] == "https://api.arcade.dev/mcp/my-gw" + assert "headers" not in entry + + def test_with_api_key(self, tmp_path: Path) -> None: + config_path = tmp_path / "windsurf.json" + configure_windsurf_arcade( + server_name="my-gw", + gateway_url="https://api.arcade.dev/mcp/my-gw", + auth_token="arc_test", + config_path=config_path, + ) + config = _load_json(config_path) + assert config["mcpServers"]["my-gw"]["headers"]["Authorization"] == "Bearer arc_test" + + +class TestConfigureAmazonqArcade: + def test_writes_mcpservers_config(self, tmp_path: Path) -> None: + config_path = tmp_path / "amazonq.json" + configure_amazonq_arcade( + server_name="my-gw", + gateway_url="https://api.arcade.dev/mcp/my-gw", + config_path=config_path, + ) + config = _load_json(config_path) + entry = config["mcpServers"]["my-gw"] + assert entry["url"] == "https://api.arcade.dev/mcp/my-gw" diff --git a/libs/tests/cli/test_connect.py b/libs/tests/cli/test_connect.py new file mode 100644 index 00000000..e4614c4c --- /dev/null +++ b/libs/tests/cli/test_connect.py @@ -0,0 +1,769 @@ +"""Tests for the arcade connect command.""" + +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from arcade_cli.connect import ( + _get_context_key, + _read_cache, + _write_cache, + create_gateway, + ensure_login, + fetch_available_toolkits, + find_matching_gateway, + get_toolkit_examples, + list_gateways, + run_connect, +) + +# --------------------------------------------------------------------------- +# get_toolkit_examples +# --------------------------------------------------------------------------- + + +class TestGetToolkitExamples: + def test_known_toolkit_returns_examples(self) -> None: + examples = get_toolkit_examples(["github"]) + assert len(examples) == 2 + assert any("pull request" in e.lower() for e in examples) + + def test_multiple_toolkits(self) -> None: + examples = get_toolkit_examples(["github", "slack"]) + assert len(examples) == 4 + + def test_unknown_toolkit_returns_fallback(self) -> None: + examples = get_toolkit_examples(["nonexistent_toolkit_xyz"]) + assert len(examples) == 1 + assert "assistant" in examples[0].lower() + + def test_strips_arcade_prefix(self) -> None: + examples = get_toolkit_examples(["arcade-github"]) + assert len(examples) == 2 + + def test_empty_list_returns_fallback(self) -> None: + examples = get_toolkit_examples([]) + assert len(examples) == 1 + + +# --------------------------------------------------------------------------- +# ensure_login +# --------------------------------------------------------------------------- + + +class TestEnsureLogin: + @patch("arcade_cli.connect.console") + @patch("arcade_cli.authn.get_valid_access_token", return_value="tok_abc") + @patch("arcade_cli.authn.check_existing_login", return_value=True) + def test_already_logged_in_returns_token( + self, _check: MagicMock, _get_token: MagicMock, _console: MagicMock + ) -> None: + token = ensure_login() + assert token == "tok_abc" + + @patch("arcade_cli.connect.console") + @patch("arcade_cli.authn.get_valid_access_token", return_value="tok_new") + @patch("arcade_cli.authn.save_credentials_from_whoami") + @patch("arcade_cli.authn.check_existing_login", return_value=False) + def test_not_logged_in_triggers_oauth( + self, + _check: MagicMock, + _save: MagicMock, + _get_token: MagicMock, + _console: MagicMock, + ) -> None: + mock_result = MagicMock() + mock_result.email = "user@example.com" + mock_result.tokens = MagicMock() + mock_result.whoami = MagicMock() + + with patch( + "arcade_cli.authn.perform_oauth_login", + return_value=mock_result, + ): + token = ensure_login() + assert token == "tok_new" + + +# --------------------------------------------------------------------------- +# fetch_available_toolkits +# --------------------------------------------------------------------------- + + +class TestFetchAvailableToolkits: + def test_groups_by_toolkit_name(self) -> None: + tool1 = SimpleNamespace(toolkit=SimpleNamespace(name="github"), name="GithubListPRs") + tool2 = SimpleNamespace(toolkit=SimpleNamespace(name="github"), name="GithubCreateIssue") + tool3 = SimpleNamespace(toolkit=SimpleNamespace(name="slack"), name="SlackSendMessage") + + mock_client = MagicMock() + mock_client.tools.list.return_value = [tool1, tool2, tool3] + + with patch("arcade_cli.utils.get_arcade_client", return_value=mock_client): + result = fetch_available_toolkits("https://api.example.com", skip_cache=True) + + assert "github" in result + assert len(result["github"]) == 2 + assert "slack" in result + assert len(result["slack"]) == 1 + + @patch("arcade_cli.connect.console") + def test_connection_error_returns_empty(self, _console: MagicMock) -> None: + from arcadepy import APIConnectionError + + mock_client = MagicMock() + mock_client.tools.list.side_effect = APIConnectionError(request=MagicMock()) + + with patch("arcade_cli.utils.get_arcade_client", return_value=mock_client): + result = fetch_available_toolkits("https://api.example.com", skip_cache=True) + + assert result == {} + + +# --------------------------------------------------------------------------- +# Cache functions +# --------------------------------------------------------------------------- + + +class TestCache: + def test_write_and_read_cache(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import arcade_cli.connect as mod + + cache_file = tmp_path / "tools.json" + monkeypatch.setattr(mod, "_CACHE_DIR", tmp_path) + monkeypatch.setattr(mod, "_CACHE_FILE", cache_file) + monkeypatch.setattr(mod, "_get_context_key", lambda: "org:proj") + + toolkits = {"github": ["Github.CreateIssue"]} + _write_cache(toolkits) + assert cache_file.exists() + + result = _read_cache() + assert result == toolkits + + def test_read_cache_returns_none_when_missing( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + import arcade_cli.connect as mod + + monkeypatch.setattr(mod, "_CACHE_FILE", tmp_path / "nonexistent.json") + assert _read_cache() is None + + def test_read_cache_invalidates_on_context_change( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + import arcade_cli.connect as mod + + cache_file = tmp_path / "tools.json" + monkeypatch.setattr(mod, "_CACHE_DIR", tmp_path) + monkeypatch.setattr(mod, "_CACHE_FILE", cache_file) + + # Write with one context + monkeypatch.setattr(mod, "_get_context_key", lambda: "org1:proj1") + _write_cache({"github": ["Github.CreateIssue"]}) + + # Read with different context + monkeypatch.setattr(mod, "_get_context_key", lambda: "org2:proj2") + assert _read_cache() is None + + def test_get_context_key_returns_unknown_without_credentials(self) -> None: + # On CI or without credentials, should return "unknown" not raise + with patch( + "arcade_cli.utils.get_org_project_context", + side_effect=Exception("no creds"), + ): + assert _get_context_key() == "unknown" + + +# --------------------------------------------------------------------------- +# find_matching_gateway +# --------------------------------------------------------------------------- + + +class TestFindMatchingGateway: + def test_finds_superset_gateway(self) -> None: + gateways = [ + { + "slug": "my-gw", + "tool_filter": {"allowed_tools": ["Github.CreateIssue", "Github.ListPRs"]}, + } + ] + result = find_matching_gateway(gateways, ["Github.CreateIssue"]) + assert result is not None + assert result["slug"] == "my-gw" + + def test_returns_none_when_no_match(self) -> None: + gateways = [{"slug": "my-gw", "tool_filter": {"allowed_tools": ["Slack.SendMessage"]}}] + result = find_matching_gateway(gateways, ["Github.CreateIssue"]) + assert result is None + + def test_returns_none_for_empty_gateways(self) -> None: + assert find_matching_gateway([], ["Github.CreateIssue"]) is None + + def test_skips_gateway_with_wrong_auth_type(self) -> None: + gateways = [ + { + "slug": "oauth-gw", + "auth_type": "arcade", + "tool_filter": {"allowed_tools": ["Github.CreateIssue"]}, + } + ] + # Looking for arcade_header auth — should not match the OAuth gateway + result = find_matching_gateway(gateways, ["Github.CreateIssue"], auth_type="arcade_header") + assert result is None + + def test_matches_gateway_with_correct_auth_type(self) -> None: + gateways = [ + { + "slug": "apikey-gw", + "auth_type": "arcade_header", + "tool_filter": {"allowed_tools": ["Github.CreateIssue"]}, + } + ] + result = find_matching_gateway(gateways, ["Github.CreateIssue"], auth_type="arcade_header") + assert result is not None + assert result["slug"] == "apikey-gw" + + +# --------------------------------------------------------------------------- +# list_gateways +# --------------------------------------------------------------------------- + + +class TestListGateways: + @patch("arcade_cli.connect.httpx.get") + @patch("arcade_cli.utils.get_org_project_context", return_value=("org1", "proj1")) + def test_returns_items(self, _ctx: MagicMock, mock_get: MagicMock) -> None: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"items": [{"slug": "gw1"}]} + mock_get.return_value = mock_resp + + result = list_gateways("tok") + assert len(result) == 1 + assert result[0]["slug"] == "gw1" + + @patch("arcade_cli.connect.httpx.get") + @patch("arcade_cli.utils.get_org_project_context", return_value=("org1", "proj1")) + def test_returns_empty_on_error(self, _ctx: MagicMock, mock_get: MagicMock) -> None: + mock_resp = MagicMock() + mock_resp.status_code = 401 + mock_get.return_value = mock_resp + + result = list_gateways("tok") + assert result == [] + + +# --------------------------------------------------------------------------- +# create_gateway +# --------------------------------------------------------------------------- + + +class TestCreateGateway: + @patch("arcade_cli.connect.httpx.post") + @patch("arcade_cli.utils.get_org_project_context", return_value=("org1", "proj1")) + def test_returns_gateway_dict(self, _ctx: MagicMock, mock_post: MagicMock) -> None: + mock_resp = MagicMock() + mock_resp.status_code = 201 + mock_resp.json.return_value = {"slug": "my-gw", "id": "gw-123"} + mock_post.return_value = mock_resp + + result = create_gateway("tok", "my-gw", ["Github.CreateIssue"]) + assert result["slug"] == "my-gw" + + @patch("arcade_cli.connect.httpx.post") + @patch("arcade_cli.utils.get_org_project_context", return_value=("org1", "proj1")) + def test_unwraps_items_envelope(self, _ctx: MagicMock, mock_post: MagicMock) -> None: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"items": [{"slug": "gw-abc", "id": "123"}]} + mock_post.return_value = mock_resp + + result = create_gateway("tok", "test", ["Github.CreateIssue"]) + assert result["slug"] == "gw-abc" + + @patch("arcade_cli.connect.httpx.post") + @patch("arcade_cli.utils.get_org_project_context", return_value=("org1", "proj1")) + def test_raises_on_error(self, _ctx: MagicMock, mock_post: MagicMock) -> None: + mock_resp = MagicMock() + mock_resp.status_code = 400 + mock_resp.text = "bad request" + mock_post.return_value = mock_resp + + with pytest.raises(RuntimeError, match="400"): + create_gateway("tok", "test", ["Github.CreateIssue"]) + + @patch("arcade_cli.connect.httpx.post") + @patch("arcade_cli.utils.get_org_project_context", return_value=("org1", "proj1")) + def test_passes_slug_and_auth_type(self, _ctx: MagicMock, mock_post: MagicMock) -> None: + mock_resp = MagicMock() + mock_resp.status_code = 201 + mock_resp.json.return_value = {"slug": "custom"} + mock_post.return_value = mock_resp + + create_gateway("tok", "test", ["T.A"], auth_type="arcade_header", slug="custom") + call_body = mock_post.call_args[1]["json"] + assert call_body["auth_type"] == "arcade_header" + assert call_body["slug"] == "custom" + + +# --------------------------------------------------------------------------- +# _resolve_gateway_slug +# --------------------------------------------------------------------------- + + +class TestResolveGatewaySlug: + @patch("arcade_cli.connect.list_gateways") + def test_matches_by_slug(self, mock_list: MagicMock) -> None: + from arcade_cli.connect import _resolve_gateway_slug + + mock_list.return_value = [{"slug": "pascal_opencode", "name": "opencode"}] + assert _resolve_gateway_slug("pascal_opencode", "tok") == "pascal_opencode" + + @patch("arcade_cli.connect.list_gateways") + def test_matches_by_name(self, mock_list: MagicMock) -> None: + from arcade_cli.connect import _resolve_gateway_slug + + mock_list.return_value = [{"slug": "pascal_opencode", "name": "opencode"}] + assert _resolve_gateway_slug("opencode", "tok") == "pascal_opencode" + + @patch("arcade_cli.connect.list_gateways") + def test_falls_back_to_input(self, mock_list: MagicMock) -> None: + from arcade_cli.connect import _resolve_gateway_slug + + mock_list.return_value = [{"slug": "other", "name": "other"}] + assert _resolve_gateway_slug("unknown-gw", "tok") == "unknown-gw" + + +# --------------------------------------------------------------------------- +# run_connect — tool-only mode +# --------------------------------------------------------------------------- + + +class TestRunConnectToolOnly: + def test_tool_only_creates_gateway(self, tmp_path: Path) -> None: + config_path = tmp_path / "claude.json" + + with ( + patch("arcade_cli.connect.ensure_login", return_value="tok_abc"), + _mock_list_gw(), + patch( + "arcade_cli.connect.create_gateway", + return_value={"slug": "custom-tools", "id": "gw-999"}, + ), + patch("arcade_cli.connect.console"), + patch("arcade_cli.configure.console"), + ): + run_connect( + client="claude", + tools=["Github.CreateIssue", "Slack.SendMessage"], + config_path=config_path, + ) + + config = json.loads(config_path.read_text(encoding="utf-8")) + assert "mcpServers" in config + + +# --------------------------------------------------------------------------- +# Helpers: fresh mocks per test (patch objects are single-use as context managers) +# --------------------------------------------------------------------------- + + +def _mock_list_gw(): # type: ignore[no-untyped-def] + return patch("arcade_cli.connect.list_gateways", return_value=[]) + + +def _mock_resolve_slug(): # type: ignore[no-untyped-def] + return patch("arcade_cli.connect._resolve_gateway_slug", side_effect=lambda gw, *a, **kw: gw) + + +# --------------------------------------------------------------------------- +# run_connect — gateway mode (direct slug) +# --------------------------------------------------------------------------- + + +class TestRunConnectGateway: + def test_gateway_mode_configures_claude(self, tmp_path: Path) -> None: + config_path = tmp_path / "claude.json" + + with ( + patch("arcade_cli.connect.ensure_login", return_value="tok_abc"), + _mock_resolve_slug(), + patch("arcade_cli.connect.console"), + patch("arcade_cli.configure.console"), + ): + run_connect( + client="claude", + gateway="my-production-gw", + config_path=config_path, + ) + + config = json.loads(config_path.read_text(encoding="utf-8")) + entry = config["mcpServers"]["my-production-gw"] + assert entry["url"] == "https://api.arcade.dev/mcp/my-production-gw" + assert "headers" not in entry + + def test_gateway_mode_configures_cursor(self, tmp_path: Path) -> None: + config_path = tmp_path / "cursor.json" + + with ( + patch("arcade_cli.connect.ensure_login", return_value="tok_abc"), + _mock_resolve_slug(), + patch("arcade_cli.connect.console"), + patch("arcade_cli.configure.console"), + ): + run_connect( + client="cursor", + gateway="test-gw", + config_path=config_path, + ) + + config = json.loads(config_path.read_text(encoding="utf-8")) + entry = config["mcpServers"]["test-gw"] + assert entry["type"] == "sse" + assert "api.arcade.dev/mcp/test-gw" in entry["url"] + + def test_gateway_mode_configures_vscode(self, tmp_path: Path) -> None: + config_path = tmp_path / "vscode.json" + + with ( + patch("arcade_cli.connect.ensure_login", return_value="tok_abc"), + _mock_resolve_slug(), + patch("arcade_cli.connect.console"), + patch("arcade_cli.configure.console"), + ): + run_connect( + client="vscode", + gateway="test-gw", + config_path=config_path, + ) + + config = json.loads(config_path.read_text(encoding="utf-8")) + entry = config["servers"]["test-gw"] + assert entry["type"] == "http" + assert "api.arcade.dev/mcp/test-gw" in entry["url"] + + +# --------------------------------------------------------------------------- +# run_connect — toolkit mode (creates gateway) +# --------------------------------------------------------------------------- + + +class TestRunConnectToolkit: + def test_toolkit_creates_gateway_and_configures_client(self, tmp_path: Path) -> None: + config_path = tmp_path / "claude.json" + + with ( + patch("arcade_cli.connect.ensure_login", return_value="tok_abc"), + patch( + "arcade_cli.connect.fetch_available_toolkits", + return_value={"github": ["Github.ListPRs", "Github.CreateIssue"]}, + ), + _mock_list_gw(), + patch( + "arcade_cli.connect.create_gateway", + return_value={"slug": "github", "id": "gw-123"}, + ) as mock_create, + patch("arcade_cli.connect.console"), + patch("arcade_cli.configure.console"), + ): + run_connect( + client="claude", + toolkits=["github"], + config_path=config_path, + ) + + mock_create.assert_called_once() + call_kwargs = mock_create.call_args[1] + assert call_kwargs["name"] == "github" + assert "Github.ListPRs" in call_kwargs["tool_allow_list"] + assert "Github.CreateIssue" in call_kwargs["tool_allow_list"] + + config = json.loads(config_path.read_text(encoding="utf-8")) + entry = config["mcpServers"]["github"] + assert entry["url"] == "https://api.arcade.dev/mcp/github" + assert "headers" not in entry + + def test_multiple_toolkits_creates_combined_gateway(self, tmp_path: Path) -> None: + config_path = tmp_path / "cursor.json" + + with ( + patch("arcade_cli.connect.ensure_login", return_value="tok_abc"), + patch( + "arcade_cli.connect.fetch_available_toolkits", + return_value={ + "github": ["Github.ListPRs"], + "slack": ["Slack.SendMessage"], + }, + ), + _mock_list_gw(), + patch( + "arcade_cli.connect.create_gateway", + return_value={"slug": "github-slack", "id": "gw-456"}, + ), + patch("arcade_cli.connect.console"), + patch("arcade_cli.configure.console"), + ): + run_connect( + client="cursor", + toolkits=["github", "slack"], + config_path=config_path, + ) + + config = json.loads(config_path.read_text(encoding="utf-8")) + entry = config["mcpServers"]["github-slack"] + assert entry["type"] == "sse" + assert "api.arcade.dev/mcp/github-slack" in entry["url"] + + +# --------------------------------------------------------------------------- +# run_connect — --all and interactive modes +# --------------------------------------------------------------------------- + + +class TestRunConnectInteractive: + def test_all_mode_creates_gateway_for_all_toolkits(self, tmp_path: Path) -> None: + config_path = tmp_path / "claude.json" + + with ( + patch("arcade_cli.connect.ensure_login", return_value="tok_abc"), + patch( + "arcade_cli.connect.fetch_available_toolkits", + return_value={ + "github": ["Github.ListPRs"], + "slack": ["Slack.SendMessage"], + }, + ), + _mock_list_gw(), + patch( + "arcade_cli.connect.create_gateway", + return_value={"slug": "github-slack", "id": "gw-789"}, + ) as mock_create, + patch("arcade_cli.connect.console"), + patch("arcade_cli.configure.console"), + ): + run_connect( + client="claude", + all_tools=True, + config_path=config_path, + ) + + call_kwargs = mock_create.call_args[1] + assert len(call_kwargs["tool_allow_list"]) == 2 + + config = json.loads(config_path.read_text(encoding="utf-8")) + assert "mcpServers" in config + + def test_all_mode_no_toolkits_exits(self) -> None: + with ( + patch("arcade_cli.connect.ensure_login", return_value="tok_abc"), + patch("arcade_cli.connect.fetch_available_toolkits", return_value={}), + patch("arcade_cli.connect.console"), + pytest.raises(SystemExit), + ): + run_connect(client="claude", all_tools=True) + + def test_toolkit_not_found_exits(self) -> None: + with ( + patch("arcade_cli.connect.ensure_login", return_value="tok_abc"), + patch("arcade_cli.connect.fetch_available_toolkits", return_value={}), + _mock_list_gw(), + patch("arcade_cli.connect.console"), + pytest.raises(SystemExit), + ): + run_connect(client="claude", toolkits=["nonexistent"]) + + +# --------------------------------------------------------------------------- +# create_project_api_key +# --------------------------------------------------------------------------- + + +class TestCreateProjectApiKey: + @patch("arcade_cli.connect.httpx.post") + @patch("arcade_cli.utils.get_org_project_context", return_value=("org1", "proj1")) + def test_returns_api_key(self, _ctx: MagicMock, mock_post: MagicMock) -> None: + from arcade_cli.connect import create_project_api_key + + mock_resp = MagicMock() + mock_resp.status_code = 201 + mock_resp.json.return_value = {"api_key": "arc_test123"} + mock_post.return_value = mock_resp + + result = create_project_api_key("tok", label="test") + assert result == "arc_test123" + + @patch("arcade_cli.connect.httpx.post") + @patch("arcade_cli.utils.get_org_project_context", return_value=("org1", "proj1")) + def test_raises_on_error(self, _ctx: MagicMock, mock_post: MagicMock) -> None: + from arcade_cli.connect import create_project_api_key + + mock_resp = MagicMock() + mock_resp.status_code = 403 + mock_resp.text = "forbidden" + mock_post.return_value = mock_resp + + with pytest.raises(RuntimeError, match="403"): + create_project_api_key("tok") + + +# --------------------------------------------------------------------------- +# prompt_toolkit_selection +# --------------------------------------------------------------------------- + + +class TestPromptToolkitSelection: + from arcade_cli.connect import prompt_toolkit_selection + + def test_selects_single_toolkit(self, monkeypatch: pytest.MonkeyPatch) -> None: + from arcade_cli.connect import prompt_toolkit_selection + + monkeypatch.setattr("builtins.input", lambda _: "1") + with patch("arcade_cli.connect.console"): + result = prompt_toolkit_selection({"github": ["Github.CreateIssue"]}) + assert result == ["github"] + + def test_selects_multiple(self, monkeypatch: pytest.MonkeyPatch) -> None: + from arcade_cli.connect import prompt_toolkit_selection + + # Bundles come first, then individual toolkits, then "all" + # With no matching bundles, "github" is option 1, "slack" is 2, "all" is 3 + monkeypatch.setattr("builtins.input", lambda _: "1,2") + with patch("arcade_cli.connect.console"): + result = prompt_toolkit_selection({ + "github": ["Github.CreateIssue"], + "slack": ["Slack.Send"], + }) + assert "github" in result + assert "slack" in result + + def test_empty_input_exits(self, monkeypatch: pytest.MonkeyPatch) -> None: + from arcade_cli.connect import prompt_toolkit_selection + + monkeypatch.setattr("builtins.input", lambda _: "") + with patch("arcade_cli.connect.console"), pytest.raises(SystemExit): + prompt_toolkit_selection({"github": ["Github.CreateIssue"]}) + + def test_empty_available_exits(self) -> None: + from arcade_cli.connect import prompt_toolkit_selection + + with patch("arcade_cli.connect.console"), pytest.raises(SystemExit): + prompt_toolkit_selection({}) + + +# --------------------------------------------------------------------------- +# run_connect — gateway reuse and api-key paths +# --------------------------------------------------------------------------- + + +class TestRunConnectAdvanced: + def test_reuses_existing_gateway(self, tmp_path: Path) -> None: + config_path = tmp_path / "claude.json" + + existing_gw = { + "slug": "existing-gw", + "name": "existing", + "tool_filter": {"allowed_tools": ["Github.CreateIssue"]}, + } + + with ( + patch("arcade_cli.connect.ensure_login", return_value="tok_abc"), + patch( + "arcade_cli.connect.fetch_available_toolkits", + return_value={"github": ["Github.CreateIssue"]}, + ), + patch("arcade_cli.connect.list_gateways", return_value=[existing_gw]), + patch("arcade_cli.connect.console"), + patch("arcade_cli.configure.console"), + ): + run_connect( + client="claude", + toolkits=["github"], + config_path=config_path, + ) + + config = json.loads(config_path.read_text(encoding="utf-8")) + entry = config["mcpServers"]["github"] + assert "existing-gw" in entry["url"] + + def test_gateway_with_api_key(self, tmp_path: Path) -> None: + config_path = tmp_path / "claude.json" + + with ( + patch("arcade_cli.connect.ensure_login", return_value="tok_abc"), + _mock_resolve_slug(), + patch("arcade_cli.connect.create_project_api_key", return_value="arc_key123"), + patch("arcade_cli.connect.console"), + patch("arcade_cli.configure.console"), + ): + run_connect( + client="claude", + gateway="my-gw", + use_api_key=True, + config_path=config_path, + ) + + config = json.loads(config_path.read_text(encoding="utf-8")) + entry = config["mcpServers"]["my-gw"] + assert entry["headers"]["Authorization"] == "Bearer arc_key123" + + def test_toolkit_with_custom_slug(self, tmp_path: Path) -> None: + config_path = tmp_path / "claude.json" + + with ( + patch("arcade_cli.connect.ensure_login", return_value="tok_abc"), + patch( + "arcade_cli.connect.fetch_available_toolkits", + return_value={"github": ["Github.CreateIssue"]}, + ), + _mock_list_gw(), + patch( + "arcade_cli.connect.create_gateway", + return_value={"slug": "my-custom", "id": "gw-1"}, + ), + patch("arcade_cli.connect.console"), + patch("arcade_cli.configure.console"), + ): + run_connect( + client="claude", + toolkits=["github"], + gateway_slug="my-custom", + config_path=config_path, + ) + + config = json.loads(config_path.read_text(encoding="utf-8")) + # Display name should be the slug when --slug is given + assert "my-custom" in config["mcpServers"] + + def test_tool_with_toolkit_combo(self, tmp_path: Path) -> None: + """--server github --tool Slack.SendMessage merges both.""" + config_path = tmp_path / "claude.json" + + with ( + patch("arcade_cli.connect.ensure_login", return_value="tok_abc"), + patch( + "arcade_cli.connect.fetch_available_toolkits", + return_value={"github": ["Github.CreateIssue"]}, + ), + _mock_list_gw(), + patch( + "arcade_cli.connect.create_gateway", + return_value={"slug": "combo", "id": "gw-2"}, + ) as mock_create, + patch("arcade_cli.connect.console"), + patch("arcade_cli.configure.console"), + ): + run_connect( + client="claude", + toolkits=["github"], + tools=["Slack.SendMessage"], + config_path=config_path, + ) + + call_kwargs = mock_create.call_args[1] + assert "Github.CreateIssue" in call_kwargs["tool_allow_list"] + assert "Slack.SendMessage" in call_kwargs["tool_allow_list"] diff --git a/pyproject.toml b/pyproject.toml index e63635e1..48d78fe1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arcade-mcp" -version = "1.13.3" +version = "1.14.0" description = "Arcade.dev - Tool Calling platform for Agents" readme = "README.md" license = { file = "LICENSE" }