diff --git a/libs/arcade-cli/arcade_cli/configure.py b/libs/arcade-cli/arcade_cli/configure.py index 72fb9ccd..90b70c4e 100644 --- a/libs/arcade-cli/arcade_cli/configure.py +++ b/libs/arcade-cli/arcade_cli/configure.py @@ -1,12 +1,15 @@ """Connect command for configuring MCP clients.""" +import contextlib import json import logging import os import platform import re import shutil +import stat import subprocess +import tempfile from pathlib import Path import typer @@ -117,6 +120,79 @@ def _warn_overwrite(config: dict, section: str, server_name: str, config_path: P ) +def _backup_path(path: Path) -> Path: + """Return the ``.bak`` sibling used to back up ``path``. + + We append ``.bak`` to the full filename rather than replacing the + extension so ``.claude.json`` → ``.claude.json.bak`` (not ``.claude.bak``). + """ + return path.parent / f"{path.name}.bak" + + +def _write_backup_if_exists(path: Path) -> Path | None: + """If ``path`` exists, copy its current contents to ``.bak``. + + Returns the backup path (or ``None`` if no backup was made). Overwrites any + previous ``.bak`` — we keep exactly one backup, the one from immediately + before this write. The backup is created at mode 0600 regardless of the + source's permissions, because these files may contain bearer tokens. + """ + if not path.exists(): + return None + bak = _backup_path(path) + shutil.copyfile(path, bak) + if os.name != "nt": + os.chmod(bak, stat.S_IRUSR | stat.S_IWUSR) # 0600 + return bak + + +def _atomic_write_text(path: Path, content: str) -> None: + """Write ``content`` to ``path`` atomically, preserving a ``.bak`` backup. + + A crash mid-write to a user config file (e.g. ``~/.claude.json``, which + also holds project state and OAuth data) would corrupt unrelated content. + ``tempfile + os.replace`` guarantees that either the old file remains or + the new file is fully present — never a half-written file. On top of that, + we write the previous file contents to ``.bak`` *before* the rename + so the user always has a local copy of the last-known-good config. + + Permissions: ``tempfile.mkstemp`` creates the temp file at mode 0600, so + the final file ends up at 0600. That is strictly better for files that + hold bearer tokens; if the target already existed with more permissive + bits, we intentionally tighten them (and the ``.bak`` too). + """ + path.parent.mkdir(parents=True, exist_ok=True) + _write_backup_if_exists(path) + fd, tmp_str = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)) + tmp_path = Path(tmp_str) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + f.flush() + # Some filesystems (network mounts, certain tmpfs variants) don't + # support fsync; the subsequent os.replace is still atomic. + with contextlib.suppress(OSError): + os.fsync(f.fileno()) + os.replace(tmp_path, path) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise + + +def _atomic_write_json(path: Path, data: dict) -> None: + """Serialize ``data`` as JSON (indent=2) and atomically write to ``path``.""" + _atomic_write_text(path, json.dumps(data, indent=2)) + + +def get_claude_code_config_path() -> Path: + """Get the Claude Code configuration file path. + + Claude Code (the CLI / IDE extension) stores its config at ``~/.claude.json`` + with a top-level ``mcpServers`` map for user-scope MCP servers. + """ + return Path.home() / ".claude.json" + + def get_claude_config_path() -> Path: """Get the Claude Desktop configuration file path.""" system = platform.system() @@ -209,6 +285,26 @@ def get_amazonq_config_path() -> Path: return Path.home() / ".aws" / "amazonq" / "mcp.json" +def get_codex_config_path() -> Path: + """Get the Codex CLI (OpenAI) configuration file path.""" + return Path.home() / ".codex" / "config.toml" + + +def get_opencode_config_path() -> Path: + """Get the OpenCode configuration file path (user-scope). + + Honors ``XDG_CONFIG_HOME`` when set; otherwise defaults to ``~/.config``. + """ + xdg = os.environ.get("XDG_CONFIG_HOME") + base = Path(xdg) if xdg else Path.home() / ".config" + return base / "opencode" / "opencode.json" + + +def get_gemini_config_path() -> Path: + """Get the Gemini CLI (Google) configuration file path (user-scope).""" + return Path.home() / ".gemini" / "settings.json" + + def is_uv_installed() -> bool: """Check if uv is installed and available in PATH.""" return shutil.which("uv") is not None @@ -318,8 +414,7 @@ def configure_claude_local( config["mcpServers"][server_name] = get_stdio_config(entrypoint_file, server_name) # Write updated config - with open(config_path, "w", encoding="utf-8") as f: - json.dump(config, f, indent=2) + _atomic_write_json(config_path, config) console.print( f"✅ Configured Claude Desktop by adding local MCP server '{server_name}' to the configuration", @@ -370,8 +465,7 @@ def _configure_mcpservers_arcade( 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) + _atomic_write_json(config_path, config) console.print(f"[green]Configured {display_name} with Arcade gateway '{server_name}'[/green]") console.print(f" Gateway URL: {gateway_url}", style="dim") @@ -379,20 +473,44 @@ def _configure_mcpservers_arcade( console.print(f" Restart {display_name} for changes to take effect.", style="yellow") -def configure_claude_arcade( +def configure_claude_code_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", - ) + """Configure Claude Code to connect to an Arcade Cloud MCP gateway. + + Writes to ``~/.claude.json`` (user-scope). The file contains many other + Claude Code settings — everything outside ``mcpServers`` is preserved. + """ + resolved_path = config_path or get_claude_code_config_path() + if not resolved_path.is_absolute(): + resolved_path = Path.cwd() / resolved_path + + resolved_path.parent.mkdir(parents=True, exist_ok=True) + + config: dict = {} + if resolved_path.exists(): + with open(resolved_path, encoding="utf-8") as f: + config = json.load(f) + + if "mcpServers" not in config: + config["mcpServers"] = {} + + _warn_overwrite(config, "mcpServers", server_name, resolved_path) + + entry: dict = {"type": "http", "url": gateway_url} + if auth_token: + entry["headers"] = {"Authorization": f"Bearer {auth_token}"} + config["mcpServers"][server_name] = entry + + _atomic_write_json(resolved_path, config) + + console.print(f"[green]Configured Claude 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(resolved_path)}", style="dim") + console.print(" Restart Claude Code for changes to take effect.", style="yellow") def configure_cursor_local( @@ -449,8 +567,7 @@ def configure_cursor_local( config["mcpServers"][server_name] = server_config # Write updated config - with open(config_path, "w", encoding="utf-8") as f: - json.dump(config, f, indent=2) + _atomic_write_json(config_path, config) primary_config_path = resolved_target_paths[0] @@ -498,7 +615,10 @@ def configure_cursor_arcade( 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} + # Cursor's docs don't show a "type" field for remote entries — a bare + # ``url`` (plus optional ``headers``) is the documented shape. Writing + # "type": "sse" on an HTTP gateway would mislabel the transport. + server_config: dict = {"url": gateway_url} if auth_token: server_config["headers"] = {"Authorization": f"Bearer {auth_token}"} @@ -518,8 +638,7 @@ def configure_cursor_arcade( config["mcpServers"][server_name] = server_config - with open(target, "w", encoding="utf-8") as f: - json.dump(config, f, indent=2) + _atomic_write_json(target, config) primary_config_path = resolved_target_paths[0] console.print(f"[green]Configured Cursor with Arcade gateway '{server_name}'[/green]") @@ -579,8 +698,7 @@ def configure_vscode_local( ) # Write updated config - with open(config_path, "w", encoding="utf-8") as f: - json.dump(config, f, indent=2) + _atomic_write_json(config_path, config) console.print( f"✅ Configured VS Code by adding local MCP server '{server_name}' to the configuration", @@ -633,8 +751,7 @@ def configure_vscode_arcade( 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) + _atomic_write_json(config_path, config) console.print(f"[green]Configured VS Code with Arcade gateway '{server_name}'[/green]") console.print(f" Gateway URL: {gateway_url}", style="dim") @@ -648,7 +765,11 @@ def configure_windsurf_arcade( auth_token: str | None = None, config_path: Path | None = None, ) -> None: - """Configure Windsurf to connect to an Arcade Cloud MCP gateway.""" + """Configure Windsurf to connect to an Arcade Cloud MCP gateway. + + Windsurf's docs show remote HTTP servers as ``{"serverUrl": ..., "headers": ...}`` + (``url`` is also accepted as an alias). No ``type`` field is required. + """ _configure_mcpservers_arcade( server_name, gateway_url, auth_token, config_path or get_windsurf_config_path(), "Windsurf" ) @@ -660,10 +781,191 @@ def configure_amazonq_arcade( 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" + """Configure Amazon Q Developer to connect to an Arcade Cloud MCP gateway. + + Amazon Q requires an explicit ``"type": "http"`` on remote entries — without + it the CLI treats the entry as a malformed stdio server. See + https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-mcp-config-CLI.html + """ + resolved_path = config_path or get_amazonq_config_path() + if not resolved_path.is_absolute(): + resolved_path = Path.cwd() / resolved_path + + resolved_path.parent.mkdir(parents=True, exist_ok=True) + + config: dict = {} + if resolved_path.exists(): + with open(resolved_path, encoding="utf-8") as f: + config = json.load(f) + + if "mcpServers" not in config: + config["mcpServers"] = {} + + _warn_overwrite(config, "mcpServers", server_name, resolved_path) + + entry: dict = {"type": "http", "url": gateway_url} + if auth_token: + entry["headers"] = {"Authorization": f"Bearer {auth_token}"} + config["mcpServers"][server_name] = entry + + _atomic_write_json(resolved_path, config) + + console.print(f"[green]Configured Amazon Q with Arcade gateway '{server_name}'[/green]") + console.print(f" Gateway URL: {gateway_url}", style="dim") + console.print(f" Config file: {_format_path_for_display(resolved_path)}", style="dim") + console.print(" Restart Amazon Q for changes to take effect.", style="yellow") + + +def _toml_str(value: str) -> str: + """Escape a string for a TOML basic string literal.""" + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + +def _upsert_codex_mcp_server(text: str, server_name: str, entries: dict[str, str]) -> str: + """Insert or replace a ``[mcp_servers.]`` section in Codex's ``config.toml``. + + Preserves all other content (including comments, formatting, and other + table sections). If the section already exists it is replaced in place; + otherwise it is appended at the end of the file. + + ``entries`` maps TOML keys to string values. Only string-typed values are + supported — Codex's ``mcp_servers`` schema accepts ``url`` and + ``bearer_token`` / ``bearer_token_env_var`` as strings, which is all we + need here. + """ + body_lines = [f"{key} = {_toml_str(value)}" for key, value in entries.items()] + new_section = f"[mcp_servers.{server_name}]\n" + "\n".join(body_lines) + "\n" + + # Match the header line and any following body lines up until the next + # table header (``[...]``). Safe assumption: nothing inside the body + # starts a new table, since TOML table headers always begin at column 0. + pattern = re.compile( + rf"^\[mcp_servers\.{re.escape(server_name)}\][^\n]*\n(?:(?!^\[)[^\n]*\n)*", + re.MULTILINE, ) + match = pattern.search(text) + if match: + return text[: match.start()] + new_section + text[match.end() :] + + if text and not text.endswith("\n"): + text += "\n" + if text and not text.endswith("\n\n"): + text += "\n" + return text + new_section + + +def configure_codex_arcade( + server_name: str, + gateway_url: str, + auth_token: str | None = None, + config_path: Path | None = None, +) -> None: + """Configure Codex CLI to connect to an Arcade Cloud MCP gateway. + + Writes a ``[mcp_servers.]`` section to ``~/.codex/config.toml``. + Codex supports streamable HTTP natively via the ``url`` key and an inline + ``bearer_token`` for auth. + """ + resolved_path = config_path or get_codex_config_path() + if not resolved_path.is_absolute(): + resolved_path = Path.cwd() / resolved_path + + resolved_path.parent.mkdir(parents=True, exist_ok=True) + + existing = resolved_path.read_text(encoding="utf-8") if resolved_path.exists() else "" + + entries: dict[str, str] = {"url": gateway_url} + if auth_token: + entries["bearer_token"] = auth_token + + updated = _upsert_codex_mcp_server(existing, server_name, entries) + _atomic_write_text(resolved_path, updated) + + console.print(f"[green]Configured Codex CLI with Arcade gateway '{server_name}'[/green]") + console.print(f" Gateway URL: {gateway_url}", style="dim") + console.print(f" Config file: {_format_path_for_display(resolved_path)}", style="dim") + console.print(" Restart Codex for changes to take effect.", style="yellow") + + +def configure_opencode_arcade( + server_name: str, + gateway_url: str, + auth_token: str | None = None, + config_path: Path | None = None, +) -> None: + """Configure OpenCode to connect to an Arcade Cloud MCP gateway. + + Writes to the ``mcp`` map in ``~/.config/opencode/opencode.json`` using the + ``{"type": "remote", "url": ...}`` shape. + """ + resolved_path = config_path or get_opencode_config_path() + if not resolved_path.is_absolute(): + resolved_path = Path.cwd() / resolved_path + + resolved_path.parent.mkdir(parents=True, exist_ok=True) + + config: dict = {} + if resolved_path.exists(): + with open(resolved_path, encoding="utf-8") as f: + config = json.load(f) + + if "mcp" not in config: + config["mcp"] = {} + + _warn_overwrite(config, "mcp", server_name, resolved_path) + + entry: dict = {"type": "remote", "url": gateway_url, "enabled": True} + if auth_token: + entry["headers"] = {"Authorization": f"Bearer {auth_token}"} + config["mcp"][server_name] = entry + + _atomic_write_json(resolved_path, config) + + console.print(f"[green]Configured OpenCode with Arcade gateway '{server_name}'[/green]") + console.print(f" Gateway URL: {gateway_url}", style="dim") + console.print(f" Config file: {_format_path_for_display(resolved_path)}", style="dim") + console.print(" Restart OpenCode for changes to take effect.", style="yellow") + + +def configure_gemini_arcade( + server_name: str, + gateway_url: str, + auth_token: str | None = None, + config_path: Path | None = None, +) -> None: + """Configure Gemini CLI to connect to an Arcade Cloud MCP gateway. + + Writes to ``~/.gemini/settings.json`` using the ``mcpServers`` map with + the ``httpUrl`` key (Gemini CLI's field name for streamable HTTP servers). + """ + resolved_path = config_path or get_gemini_config_path() + if not resolved_path.is_absolute(): + resolved_path = Path.cwd() / resolved_path + + resolved_path.parent.mkdir(parents=True, exist_ok=True) + + config: dict = {} + if resolved_path.exists(): + with open(resolved_path, encoding="utf-8") as f: + config = json.load(f) + + if "mcpServers" not in config: + config["mcpServers"] = {} + + _warn_overwrite(config, "mcpServers", server_name, resolved_path) + + entry: dict = {"httpUrl": gateway_url} + if auth_token: + entry["headers"] = {"Authorization": f"Bearer {auth_token}"} + config["mcpServers"][server_name] = entry + + _atomic_write_json(resolved_path, config) + + console.print(f"[green]Configured Gemini CLI with Arcade gateway '{server_name}'[/green]") + console.print(f" Gateway URL: {gateway_url}", style="dim") + console.print(f" Config file: {_format_path_for_display(resolved_path)}", style="dim") + console.print(" Restart Gemini CLI for changes to take effect.", style="yellow") def get_toolkit_stdio_config(tool_packages: list[str], server_name: str) -> dict: @@ -733,11 +1035,14 @@ def configure_client_gateway( """ client_lower = client.lower() dispatch = { - "claude": configure_claude_arcade, + "claude-code": configure_claude_code_arcade, "cursor": configure_cursor_arcade, "vscode": configure_vscode_arcade, "windsurf": configure_windsurf_arcade, "amazonq": configure_amazonq_arcade, + "codex": configure_codex_arcade, + "opencode": configure_opencode_arcade, + "gemini": configure_gemini_arcade, } func = dispatch.get(client_lower) if not func: @@ -784,8 +1089,7 @@ def configure_client_toolkit( 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) + _atomic_write_json(_config_path, config) console.print( f"[green]Configured Claude Desktop with Arcade toolkits: {', '.join(tool_packages)}[/green]" @@ -817,8 +1121,7 @@ def configure_client_toolkit( 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) + _atomic_write_json(target, config) console.print( f"[green]Configured Cursor with Arcade toolkits: {', '.join(tool_packages)}[/green]" @@ -847,8 +1150,7 @@ def configure_client_toolkit( 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) + _atomic_write_json(_config_path, config) console.print( f"[green]Configured VS Code with Arcade toolkits: {', '.join(tool_packages)}[/green]" @@ -874,8 +1176,7 @@ def configure_client_toolkit( 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) + _atomic_write_json(_config_path, config) console.print( f"[green]Configured {display} with Arcade toolkits: {', '.join(tool_packages)}[/green]" @@ -931,7 +1232,7 @@ def configure_client( 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]", + "Example: [bold]arcade connect claude-code --gateway my-gateway[/bold]", style="yellow", ) return diff --git a/libs/arcade-cli/arcade_cli/connect.py b/libs/arcade-cli/arcade_cli/connect.py index fae09bb4..6099367e 100644 --- a/libs/arcade-cli/arcade_cli/connect.py +++ b/libs/arcade-cli/arcade_cli/connect.py @@ -416,46 +416,6 @@ def create_gateway( 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 # --------------------------------------------------------------------------- @@ -552,7 +512,6 @@ def run_connect( 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, @@ -575,12 +534,7 @@ def run_connect( # --- 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) + _configure_gateway(client, slug, config_path, name=gateway) return # --- Toolkit / tool → gateway mode --- @@ -615,7 +569,7 @@ def run_connect( if not available: console.print( "No toolkits found in your account. You can specify toolkits manually:\n" - " [bold]arcade connect claude --toolkit github[/bold]", + " [bold]arcade connect claude-code --toolkit github[/bold]", style="yellow", ) raise SystemExit(1) @@ -669,7 +623,7 @@ def run_connect( raise SystemExit(1) # Check if an existing gateway already covers these tools - auth_type = "arcade_header" if use_api_key else "arcade" + auth_type = "arcade" console.print("Checking existing gateways...", style="dim") existing_gateways = list_gateways(access_token, debug=debug) existing = find_matching_gateway( @@ -708,13 +662,6 @@ def run_connect( 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 @@ -722,7 +669,7 @@ def run_connect( 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) + _configure_gateway(client, slug, config_path, name=display_name) # Print examples examples = get_toolkit_examples(selected_toolkits) @@ -765,7 +712,6 @@ 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. @@ -786,13 +732,10 @@ def _configure_gateway( client=client, server_name=server_name, gateway_url=gateway_url, - auth_token=api_key, + auth_token=None, 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") + 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 93e6ce9d..b1c063d4 100644 --- a/libs/arcade-cli/arcade_cli/main.py +++ b/libs/arcade-cli/arcade_cli/main.py @@ -793,7 +793,16 @@ def connect( ..., help="MCP client to connect to the remote gateway", click_type=click.Choice( - ["claude", "cursor", "vscode", "windsurf", "amazonq"], + [ + "claude-code", + "cursor", + "vscode", + "windsurf", + "amazonq", + "codex", + "opencode", + "gemini", + ], case_sensitive=False, ), show_choices=True, @@ -831,11 +840,6 @@ def connect( "-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", @@ -852,17 +856,15 @@ def connect( 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). + Gateways use OAuth; the MCP client handles the auth flow. To configure a local server on your filesystem instead, use 'arcade configure'. Examples:\n - arcade connect claude --server github\n + arcade connect claude-code --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 + arcade connect claude-code --tool Github.CreateIssue --tool Linear.UpdateIssue\n + arcade connect claude-code --gateway my-existing-gw\n """ from arcade_cli.connect import PRESET_BUNDLES, run_connect @@ -884,7 +886,6 @@ def connect( 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, diff --git a/libs/tests/cli/test_configure.py b/libs/tests/cli/test_configure.py index fc7ba9b3..cb5e6f8c 100644 --- a/libs/tests/cli/test_configure.py +++ b/libs/tests/cli/test_configure.py @@ -1,6 +1,7 @@ """Tests for get_tool_secrets() and gateway configuration in arcade configure.""" import json +import os import sys import types from io import StringIO @@ -10,13 +11,17 @@ import pytest from arcade_cli.configure import ( _format_path_for_display, _resolve_windows_appdata, + _upsert_codex_mcp_server, _warn_overwrite, configure_amazonq_arcade, - configure_claude_arcade, + configure_claude_code_arcade, configure_client, configure_client_gateway, configure_client_toolkit, + configure_codex_arcade, configure_cursor_arcade, + configure_gemini_arcade, + configure_opencode_arcade, configure_vscode_arcade, configure_windsurf_arcade, get_tool_secrets, @@ -524,10 +529,10 @@ def test_claude_config_stdio_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatc # --------------------------------------------------------------------------- -class TestConfigureClaudeArcade: - def test_writes_gateway_url_and_headers(self, tmp_path: Path) -> None: +class TestConfigureClaudeCodeArcade: + def test_writes_http_config(self, tmp_path: Path) -> None: config_path = tmp_path / "claude.json" - configure_claude_arcade( + configure_claude_code_arcade( server_name="my-gw", gateway_url="https://api.arcade.dev/mcp/my-gw", auth_token="tok_abc", @@ -535,16 +540,20 @@ class TestConfigureClaudeArcade: ) config = _load_json(config_path) entry = config["mcpServers"]["my-gw"] + assert entry["type"] == "http" 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"}}}), + json.dumps({ + "projects": {"/some/path": {"mcpServers": {}}}, + "mcpServers": {"existing": {"type": "http", "url": "https://old"}}, + }), encoding="utf-8", ) - configure_claude_arcade( + configure_claude_code_arcade( server_name="new-gw", gateway_url="https://api.arcade.dev/mcp/new-gw", auth_token="tok", @@ -553,10 +562,13 @@ class TestConfigureClaudeArcade: config = _load_json(config_path) assert "existing" in config["mcpServers"] assert "new-gw" in config["mcpServers"] + assert "projects" in config class TestConfigureCursorArcade: - def test_writes_sse_config(self, tmp_path: Path) -> None: + def test_writes_documented_shape(self, tmp_path: Path) -> None: + """Per cursor.com/docs/context/mcp, a remote entry is just + ``{"url": ..., "headers": ...}`` — no "type" field.""" config_path = tmp_path / "cursor.json" configure_cursor_arcade( server_name="my-gw", @@ -566,7 +578,7 @@ class TestConfigureCursorArcade: ) config = _load_json(config_path) entry = config["mcpServers"]["my-gw"] - assert entry["type"] == "sse" + assert "type" not in entry assert entry["url"] == "https://api.arcade.dev/mcp/my-gw" assert entry["headers"]["Authorization"] == "Bearer tok_abc" @@ -596,7 +608,7 @@ class TestConfigureClientGateway: @pytest.mark.parametrize( "client,section", [ - ("claude", "mcpServers"), + ("claude-code", "mcpServers"), ("cursor", "mcpServers"), ("vscode", "servers"), ("windsurf", "mcpServers"), @@ -809,7 +821,10 @@ class TestConfigureWindsurfArcade: class TestConfigureAmazonqArcade: - def test_writes_mcpservers_config(self, tmp_path: Path) -> None: + def test_writes_documented_http_shape(self, tmp_path: Path) -> None: + """Amazon Q CLI docs require "type": "http" on remote entries. See + docs.aws.amazon.com/amazonq/.../command-line-mcp-config-CLI.html + """ config_path = tmp_path / "amazonq.json" configure_amazonq_arcade( server_name="my-gw", @@ -818,4 +833,1239 @@ class TestConfigureAmazonqArcade: ) config = _load_json(config_path) entry = config["mcpServers"]["my-gw"] + assert entry["type"] == "http" assert entry["url"] == "https://api.arcade.dev/mcp/my-gw" + + def test_writes_auth_headers(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", + auth_token="arc_test", + config_path=config_path, + ) + entry = _load_json(config_path)["mcpServers"]["my-gw"] + assert entry["type"] == "http" + assert entry["headers"]["Authorization"] == "Bearer arc_test" + + +# --------------------------------------------------------------------------- +# Codex CLI +# --------------------------------------------------------------------------- + + +class TestUpsertCodexMcpServer: + def test_appends_when_missing(self) -> None: + result = _upsert_codex_mcp_server("", "arcade", {"url": "https://example.com/mcp"}) + assert result == '[mcp_servers.arcade]\nurl = "https://example.com/mcp"\n' + + def test_preserves_other_content(self) -> None: + existing = "# user config\nmodel = \"gpt-5\"\n\n[mcp_servers.other]\nurl = \"https://other\"\n" + result = _upsert_codex_mcp_server( + existing, "arcade", {"url": "https://arcade", "bearer_token": "tok"} + ) + # Original content is preserved verbatim + assert "# user config" in result + assert 'model = "gpt-5"' in result + assert "[mcp_servers.other]" in result + assert 'url = "https://other"' in result + # New section added at the end + assert "[mcp_servers.arcade]" in result + assert 'url = "https://arcade"' in result + assert 'bearer_token = "tok"' in result + + def test_replaces_existing_section(self) -> None: + existing = ( + "[mcp_servers.arcade]\n" + 'url = "https://old"\n' + 'bearer_token = "old_tok"\n' + "\n" + "[mcp_servers.other]\n" + 'url = "https://other"\n' + ) + result = _upsert_codex_mcp_server(existing, "arcade", {"url": "https://new"}) + # Old url/bearer_token are gone; new url is present + assert 'url = "https://old"' not in result + assert 'bearer_token = "old_tok"' not in result + assert 'url = "https://new"' in result + # Other section is untouched + assert "[mcp_servers.other]" in result + assert 'url = "https://other"' in result + + def test_escapes_special_characters_in_values(self) -> None: + result = _upsert_codex_mcp_server( + "", "arcade", {"url": 'https://example.com/"weird"\\path'} + ) + # Backslashes and quotes must be escaped per TOML basic-string rules + assert r'url = "https://example.com/\"weird\"\\path"' in result + + +class TestConfigureCodexArcade: + def test_writes_url_only(self, tmp_path: Path) -> None: + config_path = tmp_path / "codex_config.toml" + configure_codex_arcade( + server_name="my-gw", + gateway_url="https://api.arcade.dev/mcp/my-gw", + config_path=config_path, + ) + content = config_path.read_text(encoding="utf-8") + assert "[mcp_servers.my-gw]" in content + assert 'url = "https://api.arcade.dev/mcp/my-gw"' in content + assert "bearer_token" not in content + + def test_writes_bearer_token_when_auth_token_given(self, tmp_path: Path) -> None: + config_path = tmp_path / "codex_config.toml" + configure_codex_arcade( + server_name="my-gw", + gateway_url="https://api.arcade.dev/mcp/my-gw", + auth_token="arc_abc", + config_path=config_path, + ) + content = config_path.read_text(encoding="utf-8") + assert 'bearer_token = "arc_abc"' in content + + def test_preserves_existing_config(self, tmp_path: Path) -> None: + config_path = tmp_path / "codex_config.toml" + config_path.write_text( + 'model = "gpt-5"\n\n[mcp_servers.keep]\nurl = "https://keep"\n', + encoding="utf-8", + ) + configure_codex_arcade( + server_name="new-gw", + gateway_url="https://new", + config_path=config_path, + ) + content = config_path.read_text(encoding="utf-8") + assert 'model = "gpt-5"' in content + assert "[mcp_servers.keep]" in content + assert 'url = "https://keep"' in content + assert "[mcp_servers.new-gw]" in content + + +# --------------------------------------------------------------------------- +# OpenCode +# --------------------------------------------------------------------------- + + +class TestConfigureOpencodeArcade: + def test_writes_remote_mcp_entry(self, tmp_path: Path) -> None: + config_path = tmp_path / "opencode.json" + configure_opencode_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["mcp"]["my-gw"] + assert entry["type"] == "remote" + assert entry["url"] == "https://api.arcade.dev/mcp/my-gw" + assert entry["enabled"] is True + assert "headers" not in entry + + def test_with_auth_token(self, tmp_path: Path) -> None: + config_path = tmp_path / "opencode.json" + configure_opencode_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["mcp"]["my-gw"]["headers"]["Authorization"] == "Bearer arc_test" + + def test_preserves_existing_entries(self, tmp_path: Path) -> None: + config_path = tmp_path / "opencode.json" + config_path.write_text( + json.dumps({ + "$schema": "https://opencode.ai/config.json", + "mcp": {"existing": {"type": "remote", "url": "https://old"}}, + "theme": "dark", + }), + encoding="utf-8", + ) + configure_opencode_arcade( + server_name="new-gw", + gateway_url="https://new", + config_path=config_path, + ) + config = _load_json(config_path) + assert "existing" in config["mcp"] + assert "new-gw" in config["mcp"] + assert config["$schema"] == "https://opencode.ai/config.json" + assert config["theme"] == "dark" + + +# --------------------------------------------------------------------------- +# Gemini CLI +# --------------------------------------------------------------------------- + + +class TestConfigureGeminiArcade: + def test_writes_httpurl_entry(self, tmp_path: Path) -> None: + config_path = tmp_path / "gemini.json" + configure_gemini_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["httpUrl"] == "https://api.arcade.dev/mcp/my-gw" + # Gemini CLI uses httpUrl (not url) for streamable HTTP + assert "url" not in entry + assert "headers" not in entry + + def test_with_auth_token(self, tmp_path: Path) -> None: + config_path = tmp_path / "gemini.json" + configure_gemini_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" + + def test_preserves_existing_entries(self, tmp_path: Path) -> None: + config_path = tmp_path / "gemini.json" + config_path.write_text( + json.dumps({ + "mcpServers": {"keep": {"httpUrl": "https://keep"}}, + "theme": "Default", + }), + encoding="utf-8", + ) + configure_gemini_arcade( + server_name="new-gw", + gateway_url="https://new", + config_path=config_path, + ) + config = _load_json(config_path) + assert "keep" in config["mcpServers"] + assert "new-gw" in config["mcpServers"] + assert config["theme"] == "Default" + + +# --------------------------------------------------------------------------- +# configure_client_gateway dispatch for new clients +# --------------------------------------------------------------------------- + + +class TestConfigureClientGatewayNewClients: + def test_dispatches_to_codex(self, tmp_path: Path) -> None: + config_path = tmp_path / "codex_config.toml" + configure_client_gateway( + client="codex", + server_name="my-gw", + gateway_url="https://api.arcade.dev/mcp/my-gw", + config_path=config_path, + ) + content = config_path.read_text(encoding="utf-8") + assert "[mcp_servers.my-gw]" in content + + def test_dispatches_to_opencode(self, tmp_path: Path) -> None: + config_path = tmp_path / "opencode.json" + configure_client_gateway( + client="opencode", + server_name="my-gw", + gateway_url="https://api.arcade.dev/mcp/my-gw", + config_path=config_path, + ) + assert "my-gw" in _load_json(config_path)["mcp"] + + def test_dispatches_to_gemini(self, tmp_path: Path) -> None: + config_path = tmp_path / "gemini.json" + configure_client_gateway( + client="gemini", + server_name="my-gw", + gateway_url="https://api.arcade.dev/mcp/my-gw", + config_path=config_path, + ) + assert "my-gw" in _load_json(config_path)["mcpServers"] + + +# --------------------------------------------------------------------------- +# Preservation contract: configure_*_arcade must never delete or mutate any +# pre-existing entry in the user's config. It may only add or replace the +# single entry keyed by server_name. These tests compare the full original +# config to the post-write config to prove no unrelated data was lost. +# --------------------------------------------------------------------------- + + +def _rich_claude_config() -> dict: + """Config that mimics the real ~/.claude.json: many top-level keys, + projects map with per-project mcpServers, and a user-scope mcpServers + with a pre-existing entry we must not disturb.""" + return { + "numStartups": 42, + "userID": "abc-123", + "hasCompletedOnboarding": True, + "mcpServers": { + "keep-me": {"type": "http", "url": "https://keep.example.com/mcp"}, + "also-keep": { + "type": "http", + "url": "https://also.example.com/mcp", + "headers": {"Authorization": "Bearer OLD_TOKEN"}, + }, + }, + "projects": { + "/Users/me/project-a": { + "allowedTools": ["Read", "Write"], + "mcpServers": { + "project-scoped": {"type": "http", "url": "https://proj.example"}, + }, + "hasTrustDialogAccepted": True, + }, + "/Users/me/project-b": { + "mcpContextUris": [], + "lastCost": 0.12, + }, + }, + "oauthAccount": {"email": "user@example.com"}, + "cachedDynamicConfigs": {"featureFlag": True}, + } + + +def _rich_opencode_config() -> dict: + return { + "$schema": "https://opencode.ai/config.json", + "theme": "github-dark", + "model": "claude-3-5-sonnet", + "mcp": { + "keep-me": { + "type": "remote", + "url": "https://keep.example.com", + "enabled": True, + }, + "stdio-server": { + "type": "local", + "command": ["node", "server.js"], + }, + }, + "provider": {"anthropic": {"apiKey": "{env:ANTHROPIC_API_KEY}"}}, + "experimental": {"something": True}, + } + + +def _rich_gemini_config() -> dict: + return { + "theme": "Default", + "selectedAuthType": "oauth-personal", + "mcpServers": { + "keep-me": {"httpUrl": "https://keep.example.com/mcp"}, + "with-auth": { + "httpUrl": "https://auth.example.com/mcp", + "headers": {"Authorization": "Bearer OLD"}, + "timeout": 10000, + }, + }, + "contextFileName": "GEMINI.md", + "fileFiltering": {"respectGitIgnore": True}, + } + + +def _rich_codex_toml() -> str: + """A realistic Codex config.toml with comments, top-level keys, other + server sections, and an unrelated table. All of this must survive.""" + return ( + "# User preferences for Codex\n" + 'model = "gpt-5"\n' + 'model_provider = "openai"\n' + "approval_policy = \"on-request\"\n" + "\n" + "[model_providers.openai]\n" + 'name = "OpenAI"\n' + 'base_url = "https://api.openai.com/v1"\n' + "\n" + "[mcp_servers.keep-me]\n" + 'url = "https://keep.example.com/mcp"\n' + 'bearer_token = "KEEP_TOKEN"\n' + "\n" + "# Another server, don't touch\n" + "[mcp_servers.also-keep]\n" + 'url = "https://also.example.com/mcp"\n' + "\n" + "[shell_environment_policy]\n" + 'inherit = "core"\n' + ) + + +def _assert_only_added(original: dict, updated: dict, parent_key: str, server_name: str) -> None: + """Assert that ``updated`` equals ``original`` except for a single new or + replaced entry at ``updated[parent_key][server_name]``. Every other key + and nested value at every depth must be byte-for-byte identical.""" + # Top-level keys: same set + assert set(updated.keys()) == set(original.keys()), ( + f"top-level keys changed: added {set(updated) - set(original)}, " + f"removed {set(original) - set(updated)}" + ) + # Every top-level key except parent_key is deeply equal + for key in original: + if key == parent_key: + continue + assert updated[key] == original[key], f"key '{key}' was modified" + # Inside parent_key, every server except server_name is preserved exactly + for name, entry in original[parent_key].items(): + if name == server_name: + continue + assert name in updated[parent_key], f"existing server '{name}' was deleted" + assert updated[parent_key][name] == entry, f"existing server '{name}' was mutated" + + +class TestClaudeCodePreservesEverything: + def test_preserves_full_original_config(self, tmp_path: Path) -> None: + config_path = tmp_path / "claude.json" + original = _rich_claude_config() + config_path.write_text(json.dumps(original), encoding="utf-8") + + configure_claude_code_arcade( + server_name="new-gw", + gateway_url="https://new.example/mcp", + auth_token="new_tok", + config_path=config_path, + ) + + updated = _load_json(config_path) + _assert_only_added(original, updated, "mcpServers", "new-gw") + # New entry has the expected shape + assert updated["mcpServers"]["new-gw"] == { + "type": "http", + "url": "https://new.example/mcp", + "headers": {"Authorization": "Bearer new_tok"}, + } + + def test_repeated_writes_accumulate(self, tmp_path: Path) -> None: + """Running connect twice with different names keeps both entries.""" + config_path = tmp_path / "claude.json" + configure_claude_code_arcade( + server_name="first", gateway_url="https://first/mcp", config_path=config_path + ) + configure_claude_code_arcade( + server_name="second", gateway_url="https://second/mcp", config_path=config_path + ) + servers = _load_json(config_path)["mcpServers"] + assert set(servers) == {"first", "second"} + assert servers["first"]["url"] == "https://first/mcp" + assert servers["second"]["url"] == "https://second/mcp" + + def test_replacing_same_name_leaves_others_intact(self, tmp_path: Path) -> None: + config_path = tmp_path / "claude.json" + original = _rich_claude_config() + config_path.write_text(json.dumps(original), encoding="utf-8") + + # Write the same server name twice — should only replace that one entry. + configure_claude_code_arcade( + server_name="keep-me", gateway_url="https://replacement/mcp", config_path=config_path + ) + updated = _load_json(config_path) + # keep-me was replaced + assert updated["mcpServers"]["keep-me"] == { + "type": "http", + "url": "https://replacement/mcp", + } + # Everything else survived + assert updated["mcpServers"]["also-keep"] == original["mcpServers"]["also-keep"] + assert updated["projects"] == original["projects"] + assert updated["oauthAccount"] == original["oauthAccount"] + + +class TestOpencodePreservesEverything: + def test_preserves_full_original_config(self, tmp_path: Path) -> None: + config_path = tmp_path / "opencode.json" + original = _rich_opencode_config() + config_path.write_text(json.dumps(original), encoding="utf-8") + + configure_opencode_arcade( + server_name="new-gw", + gateway_url="https://new.example/mcp", + auth_token="new_tok", + config_path=config_path, + ) + + updated = _load_json(config_path) + _assert_only_added(original, updated, "mcp", "new-gw") + assert updated["mcp"]["new-gw"] == { + "type": "remote", + "url": "https://new.example/mcp", + "enabled": True, + "headers": {"Authorization": "Bearer new_tok"}, + } + + def test_repeated_writes_accumulate(self, tmp_path: Path) -> None: + config_path = tmp_path / "opencode.json" + configure_opencode_arcade( + server_name="first", gateway_url="https://first", config_path=config_path + ) + configure_opencode_arcade( + server_name="second", gateway_url="https://second", config_path=config_path + ) + entries = _load_json(config_path)["mcp"] + assert set(entries) == {"first", "second"} + + +class TestGeminiPreservesEverything: + def test_preserves_full_original_config(self, tmp_path: Path) -> None: + config_path = tmp_path / "gemini.json" + original = _rich_gemini_config() + config_path.write_text(json.dumps(original), encoding="utf-8") + + configure_gemini_arcade( + server_name="new-gw", + gateway_url="https://new.example/mcp", + auth_token="new_tok", + config_path=config_path, + ) + + updated = _load_json(config_path) + _assert_only_added(original, updated, "mcpServers", "new-gw") + assert updated["mcpServers"]["new-gw"] == { + "httpUrl": "https://new.example/mcp", + "headers": {"Authorization": "Bearer new_tok"}, + } + + def test_repeated_writes_accumulate(self, tmp_path: Path) -> None: + config_path = tmp_path / "gemini.json" + configure_gemini_arcade( + server_name="first", gateway_url="https://first", config_path=config_path + ) + configure_gemini_arcade( + server_name="second", gateway_url="https://second", config_path=config_path + ) + entries = _load_json(config_path)["mcpServers"] + assert set(entries) == {"first", "second"} + + +class TestCodexPreservesEverything: + def test_preserves_full_original_toml(self, tmp_path: Path) -> None: + """Every line from the original TOML (comments, other tables, other + mcp_servers sections) must still be present after writing a new + ``[mcp_servers.new-gw]`` section.""" + config_path = tmp_path / "codex_config.toml" + original = _rich_codex_toml() + config_path.write_text(original, encoding="utf-8") + + configure_codex_arcade( + server_name="new-gw", + gateway_url="https://new.example/mcp", + auth_token="new_tok", + config_path=config_path, + ) + + updated = config_path.read_text(encoding="utf-8") + # Every non-empty line from the original must appear verbatim. + for line in original.splitlines(): + if line == "": + continue + assert line in updated, f"Line lost from Codex config: {line!r}" + # And the new section was added. + assert "[mcp_servers.new-gw]" in updated + assert 'url = "https://new.example/mcp"' in updated + assert 'bearer_token = "new_tok"' in updated + + def test_replacing_existing_leaves_sibling_sections_intact(self, tmp_path: Path) -> None: + """Replacing ``[mcp_servers.keep-me]`` must not disturb + ``[mcp_servers.also-keep]`` or unrelated tables.""" + config_path = tmp_path / "codex_config.toml" + original = _rich_codex_toml() + config_path.write_text(original, encoding="utf-8") + + configure_codex_arcade( + server_name="keep-me", + gateway_url="https://replacement/mcp", + config_path=config_path, + ) + + updated = config_path.read_text(encoding="utf-8") + # The old URL and token for keep-me are gone (replaced). + assert 'url = "https://keep.example.com/mcp"' not in updated + assert "KEEP_TOKEN" not in updated + # The replacement is present. + assert 'url = "https://replacement/mcp"' in updated + # Sibling section survives intact. + assert "[mcp_servers.also-keep]" in updated + assert 'url = "https://also.example.com/mcp"' in updated + # Non-mcp tables and top-level keys survive. + assert "[model_providers.openai]" in updated + assert 'model = "gpt-5"' in updated + assert "[shell_environment_policy]" in updated + assert "# User preferences for Codex" in updated + + def test_repeated_writes_accumulate(self, tmp_path: Path) -> None: + config_path = tmp_path / "codex_config.toml" + configure_codex_arcade( + server_name="first", gateway_url="https://first", config_path=config_path + ) + configure_codex_arcade( + server_name="second", gateway_url="https://second", config_path=config_path + ) + content = config_path.read_text(encoding="utf-8") + assert "[mcp_servers.first]" in content + assert "[mcp_servers.second]" in content + assert 'url = "https://first"' in content + assert 'url = "https://second"' in content + + +# --------------------------------------------------------------------------- +# Cross-client preservation + correctness matrix +# +# The tests above cover Claude Code, OpenCode, Gemini, and Codex in depth. +# The block below is a parametrized guarantee that every JSON-based connect +# target (including Cursor, VS Code, Windsurf, Amazon Q) follows the same +# preservation contract, produces valid JSON, and places the bearer token +# only in the expected Authorization header. +# --------------------------------------------------------------------------- + + +# Each row maps a client to the JSON shape that each client's official docs +# specify for a remote HTTP MCP server entry. The tests below pin this shape +# so any future accidental drift (e.g. adding a bogus "type" field) will fail. +_JSON_CLIENT_MATRIX: list[tuple[str, str, str, dict]] = [ + # (client, parent_key, url_field, extra_expected_fields) + # Claude Code: https://code.claude.com/docs/en/mcp → {type, url, headers} + ("claude-code", "mcpServers", "url", {"type": "http"}), + # Cursor: https://cursor.com/docs/context/mcp → {url, headers} (no type) + ("cursor", "mcpServers", "url", {}), + # VS Code: code.visualstudio.com/docs/copilot/chat/mcp-servers → {type, url, headers} + ("vscode", "servers", "url", {"type": "http"}), + # Windsurf: {url/serverUrl, headers} (no type) + ("windsurf", "mcpServers", "url", {}), + # Amazon Q: docs.aws.amazon.com/.../command-line-mcp-config-CLI.html → {type, url, headers} + ("amazonq", "mcpServers", "url", {"type": "http"}), + # OpenCode: opencode.ai/docs/mcp-servers → {type: "remote", url, enabled, headers} + ("opencode", "mcp", "url", {"type": "remote", "enabled": True}), + # Gemini CLI: geminicli.com/docs/tools/mcp-server → {httpUrl, headers} (no type) + ("gemini", "mcpServers", "httpUrl", {}), +] + + +def _seed_rich_config(parent_key: str, path: Path) -> dict: + """Write a realistic existing config that contains: + - top-level keys unrelated to MCP + - an existing server under ``parent_key`` with auth headers + - an unrelated nested section (e.g. projects/provider) with its own data + Returns the dict so tests can compare against it. + """ + existing: dict = { + "preferences": {"theme": "dark", "fontSize": 13}, + "telemetry": {"enabled": False}, + parent_key: { + "keep-me": { + "url": "https://keep.example/mcp", + "headers": {"Authorization": "Bearer OLD_KEEP"}, + }, + "local-stdio": {"command": "/usr/bin/node", "args": ["server.js"]}, + }, + "unrelated_top_level": [1, 2, 3, {"nested": True}], + } + path.write_text(json.dumps(existing), encoding="utf-8") + return existing + + +@pytest.mark.parametrize( + "client,parent_key,url_field,extra", + _JSON_CLIENT_MATRIX, +) +class TestConnectPreservationMatrix: + """Verify every JSON-based client preserves unrelated data and produces + a correct, minimal entry for the target server.""" + + def test_writes_correct_shape_on_empty_file( + self, + tmp_path: Path, + client: str, + parent_key: str, + url_field: str, + extra: dict, + ) -> None: + config_path = tmp_path / f"{client}.json" + configure_client_gateway( + client=client, + server_name="new-gw", + gateway_url="https://api.arcade.dev/mcp/new-gw", + auth_token="SECRET_TOKEN_ABC", + config_path=config_path, + ) + + config = _load_json(config_path) + assert list(config.keys()) == [parent_key] + entry = config[parent_key]["new-gw"] + + # Expected fields (url/httpUrl + any extras like type/enabled) + assert entry[url_field] == "https://api.arcade.dev/mcp/new-gw" + for k, v in extra.items(): + assert entry[k] == v, f"{client}: expected {k}={v!r}, got {entry.get(k)!r}" + + # Auth token is present *only* in the Authorization header + assert entry["headers"] == {"Authorization": "Bearer SECRET_TOKEN_ABC"} + + def test_preserves_full_original_config( + self, + tmp_path: Path, + client: str, + parent_key: str, + url_field: str, + extra: dict, + ) -> None: + config_path = tmp_path / f"{client}.json" + original = _seed_rich_config(parent_key, config_path) + + configure_client_gateway( + client=client, + server_name="new-gw", + gateway_url="https://api.arcade.dev/mcp/new-gw", + auth_token="NEW_TOKEN", + config_path=config_path, + ) + + updated = _load_json(config_path) + _assert_only_added(original, updated, parent_key, "new-gw") + # The pre-existing entry's auth header is untouched. + assert updated[parent_key]["keep-me"]["headers"]["Authorization"] == "Bearer OLD_KEEP" + + def test_replacing_same_name_leaves_siblings_intact( + self, + tmp_path: Path, + client: str, + parent_key: str, + url_field: str, + extra: dict, + ) -> None: + config_path = tmp_path / f"{client}.json" + original = _seed_rich_config(parent_key, config_path) + + configure_client_gateway( + client=client, + server_name="keep-me", + gateway_url="https://replacement/mcp", + auth_token="REPLACEMENT_TOK", + config_path=config_path, + ) + + updated = _load_json(config_path) + # The replaced entry reflects the new data + assert updated[parent_key]["keep-me"][url_field] == "https://replacement/mcp" + assert ( + updated[parent_key]["keep-me"]["headers"]["Authorization"] + == "Bearer REPLACEMENT_TOK" + ) + # Sibling entry and unrelated top-level data untouched + assert updated[parent_key]["local-stdio"] == original[parent_key]["local-stdio"] + for key in ("preferences", "telemetry", "unrelated_top_level"): + assert updated[key] == original[key] + + def test_omitting_auth_token_omits_headers( + self, + tmp_path: Path, + client: str, + parent_key: str, + url_field: str, + extra: dict, + ) -> None: + """Without an auth token the config must not contain a headers field + (so MCP clients that support OAuth can negotiate it themselves).""" + config_path = tmp_path / f"{client}.json" + configure_client_gateway( + client=client, + server_name="new-gw", + gateway_url="https://api.arcade.dev/mcp/new-gw", + auth_token=None, + config_path=config_path, + ) + entry = _load_json(config_path)[parent_key]["new-gw"] + assert "headers" not in entry + + def test_output_is_valid_parseable_json( + self, + tmp_path: Path, + client: str, + parent_key: str, + url_field: str, + extra: dict, + ) -> None: + """Output must be decodable as UTF-8 JSON with no BOM, and the + token must not have been truncated/duplicated into foreign keys.""" + config_path = tmp_path / f"{client}.json" + configure_client_gateway( + client=client, + server_name="new-gw", + gateway_url="https://api.arcade.dev/mcp/new-gw", + auth_token="TOKENX", + config_path=config_path, + ) + raw = config_path.read_bytes() + assert not raw.startswith(b"\xef\xbb\xbf"), "UTF-8 BOM should not be present" + text = raw.decode("utf-8") + json.loads(text) # must parse + # The token must appear exactly once — inside the Authorization header. + assert text.count("TOKENX") == 1 + + +def test_codex_connect_preserves_existing_toml_and_isolates_bearer(tmp_path: Path) -> None: + """Codex uses TOML; verify the same guarantees: preserves all unrelated + content, bearer token appears only once in the new section.""" + config_path = tmp_path / "codex_config.toml" + original = ( + '# user preferences\nmodel = "gpt-5"\n\n' + "[shell_environment_policy]\n" + 'inherit = "core"\n\n' + "[mcp_servers.keep-me]\n" + 'url = "https://keep.example/mcp"\n' + 'bearer_token = "OLD_KEEP_TOKEN"\n' + ) + config_path.write_text(original, encoding="utf-8") + + configure_codex_arcade( + server_name="new-gw", + gateway_url="https://api.arcade.dev/mcp/new-gw", + auth_token="NEW_CODEX_TOKEN", + config_path=config_path, + ) + content = config_path.read_text(encoding="utf-8") + # All original lines present + for line in original.splitlines(): + if line: + assert line in content, f"codex: dropped line {line!r}" + # The new token appears exactly once, and only in the new section + assert content.count("NEW_CODEX_TOKEN") == 1 + # The pre-existing token is untouched + assert "OLD_KEEP_TOKEN" in content + + +@pytest.mark.parametrize( + "client", + ["claude-code", "cursor", "vscode", "windsurf", "amazonq", "opencode", "gemini"], +) +def test_gateway_url_written_verbatim_no_injection(tmp_path: Path, client: str) -> None: + """The gateway URL is written verbatim into a JSON value. Because we + json.dump it, any characters that would break JSON are automatically + escaped, so we can't forge a second key by injection.""" + config_path = tmp_path / f"{client}.json" + # A URL containing characters that, naively concatenated, could break JSON: + sneaky_url = 'https://api.arcade.dev/mcp/x","injected":"yes' + configure_client_gateway( + client=client, + server_name="gw", + gateway_url=sneaky_url, + config_path=config_path, + ) + config = _load_json(config_path) + # No stray top-level key; no injection at the entry level either. + assert "injected" not in config + # Find the parent key (varies by client) and assert the URL round-trips. + parent_key = next(iter(k for k in config if isinstance(config[k], dict) and "gw" in config[k])) + entry = config[parent_key]["gw"] + assert "injected" not in entry + url_field = "httpUrl" if client == "gemini" else "url" + assert entry[url_field] == sneaky_url + + +@pytest.mark.parametrize( + "client,parent_key", + [ + ("claude-code", "mcpServers"), + ("cursor", "mcpServers"), + ("windsurf", "mcpServers"), + ("amazonq", "mcpServers"), + ("opencode", "mcp"), + ("gemini", "mcpServers"), + ], +) +def test_malformed_existing_json_raises_cleanly( + tmp_path: Path, client: str, parent_key: str +) -> None: + """If an existing config file is corrupted, the connect command must + fail fast rather than silently overwriting the user's data. + VS Code has a custom wrapper message (tested separately); other clients + let json.JSONDecodeError surface, but in both cases the file is NOT + overwritten with our new content.""" + config_path = tmp_path / f"{client}.json" + config_path.write_text("{not valid json", encoding="utf-8") + before = config_path.read_bytes() + + with pytest.raises((json.JSONDecodeError, ValueError)): + configure_client_gateway( + client=client, + server_name="new-gw", + gateway_url="https://api.arcade.dev/mcp/new-gw", + auth_token="tok", + config_path=config_path, + ) + + # The file must be unchanged; we did not silently clobber it. + assert config_path.read_bytes() == before + + +def test_vscode_malformed_existing_json_raises_with_helpful_message(tmp_path: Path) -> None: + config_path = tmp_path / "vscode.json" + config_path.write_text("{not valid json", encoding="utf-8") + with pytest.raises(ValueError, match="invalid JSON"): + configure_vscode_arcade( + server_name="new-gw", + gateway_url="https://example/mcp", + config_path=config_path, + ) + + +def test_bearer_token_never_appears_in_log_output(tmp_path: Path) -> None: + """The configure_*_arcade functions print status messages; none of them + should echo the secret bearer token back to stdout/stderr.""" + from arcade_cli.console import console as arcade_console + + with arcade_console.capture() as captured: + for client in ( + "claude-code", + "cursor", + "vscode", + "windsurf", + "amazonq", + "opencode", + "gemini", + "codex", + ): + ext = "toml" if client == "codex" else "json" + config_path = tmp_path / f"{client}.{ext}" + configure_client_gateway( + client=client, + server_name="gw", + gateway_url="https://api.arcade.dev/mcp/gw", + auth_token="SUPER_SECRET_TOKEN_123", + config_path=config_path, + ) + + out = captured.get() + assert "SUPER_SECRET_TOKEN_123" not in out, ( + f"bearer token leaked to user-facing output:\n{out}" + ) + + +@pytest.mark.parametrize( + "client,parent_key,expected_entry", + [ + ( + "claude-code", + "mcpServers", + { + "type": "http", + "url": "https://api.arcade.dev/mcp/gw", + "headers": {"Authorization": "Bearer TOK"}, + }, + ), + ( + "cursor", + "mcpServers", + { + "url": "https://api.arcade.dev/mcp/gw", + "headers": {"Authorization": "Bearer TOK"}, + }, + ), + ( + "vscode", + "servers", + { + "type": "http", + "url": "https://api.arcade.dev/mcp/gw", + "headers": {"Authorization": "Bearer TOK"}, + }, + ), + ( + "windsurf", + "mcpServers", + { + "url": "https://api.arcade.dev/mcp/gw", + "headers": {"Authorization": "Bearer TOK"}, + }, + ), + ( + "amazonq", + "mcpServers", + { + "type": "http", + "url": "https://api.arcade.dev/mcp/gw", + "headers": {"Authorization": "Bearer TOK"}, + }, + ), + ( + "opencode", + "mcp", + { + "type": "remote", + "url": "https://api.arcade.dev/mcp/gw", + "enabled": True, + "headers": {"Authorization": "Bearer TOK"}, + }, + ), + ( + "gemini", + "mcpServers", + { + "httpUrl": "https://api.arcade.dev/mcp/gw", + "headers": {"Authorization": "Bearer TOK"}, + }, + ), + ], +) +def test_connect_entry_matches_documented_client_shape_exactly( + tmp_path: Path, client: str, parent_key: str, expected_entry: dict +) -> None: + """Pin the exact entry shape the connect command writes for each client, + reflecting what each client's official MCP docs show. This guards against + accidental drift (e.g. adding a "type" field that the docs don't list).""" + config_path = tmp_path / f"{client}.json" + configure_client_gateway( + client=client, + server_name="gw", + gateway_url="https://api.arcade.dev/mcp/gw", + auth_token="TOK", + config_path=config_path, + ) + entry = _load_json(config_path)[parent_key]["gw"] + assert entry == expected_entry, ( + f"{client}: shape drift from documented format.\nexpected={expected_entry}\ngot={entry}" + ) + + +def test_dispatcher_rejects_unknown_client(tmp_path: Path) -> None: + """Guard against typos silently doing nothing: unknown client names + must raise a typer.BadParameter (not be silently ignored).""" + import typer + + with pytest.raises(typer.BadParameter, match="Unknown client"): + configure_client_gateway( + client="not-a-real-client", + server_name="x", + gateway_url="https://example", + config_path=tmp_path / "x.json", + ) + + +# --------------------------------------------------------------------------- +# Atomic-write guarantees +# +# Writes to files like ``~/.claude.json`` must not corrupt pre-existing, +# unrelated state if the process crashes mid-write. We verify this by +# forcing the JSON serializer to raise after the temp file is staged but +# before it would have been renamed into place. The original file must +# survive byte-for-byte and no partial temp files must be left on disk. +# --------------------------------------------------------------------------- + + +from arcade_cli.configure import _atomic_write_json, _atomic_write_text, _backup_path + + +def test_atomic_write_text_leaves_original_on_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + target = tmp_path / "config.json" + target.write_text('{"original": "data"}', encoding="utf-8") + before = target.read_bytes() + + import arcade_cli.configure as cfg + + def boom(_fd: int) -> None: + raise RuntimeError("simulated crash mid-write") + + # monkeypatch.setattr auto-reverts at teardown, unlike a bare assignment + # that would leak os.fsync patching into unrelated tests. + monkeypatch.setattr(cfg.os, "fsync", boom) + + with pytest.raises(RuntimeError, match="simulated crash"): + _atomic_write_text(target, '{"new": "incomplete"') + + # Original file is byte-for-byte unchanged. + assert target.read_bytes() == before + # No stray temp (.tmp) files left behind. + assert not any(p.name.endswith(".tmp") for p in tmp_path.iterdir()) + # A .bak may exist (created before the write attempted): if so it matches + # the original contents exactly, so the user has not lost anything. + bak = _backup_path(target) + if bak.exists(): + assert bak.read_bytes() == before + + +def test_atomic_write_json_leaves_original_on_serialization_failure(tmp_path: Path) -> None: + target = tmp_path / "claude.json" + target.write_text('{"important": "state"}', encoding="utf-8") + before = target.read_bytes() + parent_listing_before = sorted(p.name for p in tmp_path.iterdir()) + + class Unserializable: + pass + + with pytest.raises(TypeError): + # Objects that json can't serialize raise TypeError inside json.dumps, + # which happens *before* any bytes are written to disk. + _atomic_write_json(target, {"bad": Unserializable()}) # type: ignore[dict-item] + + assert target.read_bytes() == before + assert sorted(p.name for p in tmp_path.iterdir()) == parent_listing_before + + +def test_atomic_write_produces_valid_output_on_success(tmp_path: Path) -> None: + target = tmp_path / "out.json" + _atomic_write_json(target, {"a": 1, "nested": {"b": "two"}}) + assert json.loads(target.read_text(encoding="utf-8")) == { + "a": 1, + "nested": {"b": "two"}, + } + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits only") +def test_atomic_write_sets_restrictive_permissions(tmp_path: Path) -> None: + """Config files hold bearer tokens — they should not be world-readable.""" + target = tmp_path / "secret.json" + _atomic_write_json(target, {"authorization": "Bearer TOKEN"}) + mode = target.stat().st_mode & 0o777 + # 0600 at most; any group/other bits would leak the token. + assert mode & 0o077 == 0, f"expected group/other bits clear, got {oct(mode)}" + + +# --------------------------------------------------------------------------- +# Backup behavior: every write that replaces an existing config must first +# stash the previous contents to .bak so the user can recover if the +# new config turns out to be wrong. +# --------------------------------------------------------------------------- + + +def test_backup_not_created_on_first_write(tmp_path: Path) -> None: + target = tmp_path / "fresh.json" + _atomic_write_json(target, {"hello": "world"}) + assert target.exists() + assert not _backup_path(target).exists(), "no .bak should be created on first write" + + +def test_backup_contains_previous_contents(tmp_path: Path) -> None: + target = tmp_path / "claude.json" + original = '{"existing": "state"}' + target.write_text(original, encoding="utf-8") + + _atomic_write_json(target, {"new": "state"}) + + bak = _backup_path(target) + assert bak.exists() + assert bak.read_text(encoding="utf-8") == original + assert json.loads(target.read_text(encoding="utf-8")) == {"new": "state"} + + +def test_backup_overwrites_previous_backup(tmp_path: Path) -> None: + """We keep exactly one backup — the most recent one.""" + target = tmp_path / "claude.json" + target.write_text('{"v": 1}', encoding="utf-8") + _atomic_write_json(target, {"v": 2}) # .bak now holds v=1 + _atomic_write_json(target, {"v": 3}) # .bak should now hold v=2 + + bak = _backup_path(target) + assert json.loads(bak.read_text(encoding="utf-8")) == {"v": 2} + assert json.loads(target.read_text(encoding="utf-8")) == {"v": 3} + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits only") +def test_backup_has_restrictive_permissions(tmp_path: Path) -> None: + """The backup may contain bearer tokens too — it must not be world-readable + even if the source file was e.g. 0644 from a pre-atomic-write era.""" + target = tmp_path / "claude.json" + target.write_text('{"headers": {"Authorization": "Bearer OLD"}}', encoding="utf-8") + os.chmod(target, 0o644) # simulate pre-fix lax permissions + + _atomic_write_json(target, {"headers": {"Authorization": "Bearer NEW"}}) + + bak = _backup_path(target) + assert bak.exists() + mode = bak.stat().st_mode & 0o777 + assert mode & 0o077 == 0, f"backup has group/other bits set: {oct(mode)}" + + +def test_backup_path_preserves_full_filename(tmp_path: Path) -> None: + """`.claude.json` must become `.claude.json.bak`, not `.claude.bak`.""" + assert _backup_path(Path("/tmp/.claude.json")).name == ".claude.json.bak" + assert _backup_path(Path("/tmp/config.toml")).name == "config.toml.bak" + assert _backup_path(Path("/tmp/mcp.json")).name == "mcp.json.bak" + + +@pytest.mark.parametrize( + "client,parent_key,ext", + [ + ("claude-code", "mcpServers", "json"), + ("cursor", "mcpServers", "json"), + ("vscode", "servers", "json"), + ("windsurf", "mcpServers", "json"), + ("amazonq", "mcpServers", "json"), + ("opencode", "mcp", "json"), + ("gemini", "mcpServers", "json"), + ("codex", None, "toml"), + ], +) +def test_connect_creates_bak_of_prior_config( + tmp_path: Path, client: str, parent_key: str | None, ext: str +) -> None: + """End-to-end: every connect client must leave a .bak of the previous + config so the user can restore if the update broke something.""" + config_path = tmp_path / f"{client}.{ext}" + if ext == "json": + assert parent_key is not None + original_bytes = json.dumps({ + parent_key: {"keep": {"url": "https://keep"}}, + "unrelated": "preserve-me", + }).encode("utf-8") + else: # codex TOML + original_bytes = b'model = "gpt-5"\n\n[mcp_servers.keep]\nurl = "https://keep"\n' + config_path.write_bytes(original_bytes) + + configure_client_gateway( + client=client, + server_name="new-gw", + gateway_url="https://api.arcade.dev/mcp/new-gw", + auth_token="tok", + config_path=config_path, + ) + + bak = _backup_path(config_path) + assert bak.exists(), f"{client}: .bak was not created" + assert bak.read_bytes() == original_bytes, ( + f"{client}: .bak does not match pre-write content" + ) + + +@pytest.mark.parametrize( + "client,parent_key", + [ + ("claude-code", "mcpServers"), + ("cursor", "mcpServers"), + ("vscode", "servers"), + ("windsurf", "mcpServers"), + ("amazonq", "mcpServers"), + ("opencode", "mcp"), + ("gemini", "mcpServers"), + ], +) +def test_configure_is_atomic_on_serialization_failure( + tmp_path: Path, client: str, parent_key: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """End-to-end: if the connect command fails mid-write, the original + config file must be untouched and no leftover temp files must remain.""" + config_path = tmp_path / f"{client}.json" + original_bytes = json.dumps({ + parent_key: {"keep-me": {"url": "https://keep"}}, + "unrelated": "data", + }).encode("utf-8") + config_path.write_bytes(original_bytes) + listing_before = sorted(p.name for p in tmp_path.iterdir()) + + # Force _atomic_write_json to blow up *after* the dict has been built. + import arcade_cli.configure as cfg + + def boom(*_args: object, **_kwargs: object) -> None: + raise RuntimeError("simulated disk error") + + monkeypatch.setattr(cfg, "_atomic_write_json", boom) + + with pytest.raises(RuntimeError, match="simulated disk error"): + configure_client_gateway( + client=client, + server_name="new-gw", + gateway_url="https://api.arcade.dev/mcp/new-gw", + auth_token="tok", + config_path=config_path, + ) + + assert config_path.read_bytes() == original_bytes + assert sorted(p.name for p in tmp_path.iterdir()) == listing_before diff --git a/libs/tests/cli/test_connect.py b/libs/tests/cli/test_connect.py index e4614c4c..01111d39 100644 --- a/libs/tests/cli/test_connect.py +++ b/libs/tests/cli/test_connect.py @@ -359,7 +359,7 @@ class TestRunConnectToolOnly: patch("arcade_cli.configure.console"), ): run_connect( - client="claude", + client="claude-code", tools=["Github.CreateIssue", "Slack.SendMessage"], config_path=config_path, ) @@ -397,7 +397,7 @@ class TestRunConnectGateway: patch("arcade_cli.configure.console"), ): run_connect( - client="claude", + client="claude-code", gateway="my-production-gw", config_path=config_path, ) @@ -424,7 +424,7 @@ class TestRunConnectGateway: config = json.loads(config_path.read_text(encoding="utf-8")) entry = config["mcpServers"]["test-gw"] - assert entry["type"] == "sse" + assert "type" not in entry # cursor docs show no "type" field assert "api.arcade.dev/mcp/test-gw" in entry["url"] def test_gateway_mode_configures_vscode(self, tmp_path: Path) -> None: @@ -472,7 +472,7 @@ class TestRunConnectToolkit: patch("arcade_cli.configure.console"), ): run_connect( - client="claude", + client="claude-code", toolkits=["github"], config_path=config_path, ) @@ -516,7 +516,7 @@ class TestRunConnectToolkit: config = json.loads(config_path.read_text(encoding="utf-8")) entry = config["mcpServers"]["github-slack"] - assert entry["type"] == "sse" + assert "type" not in entry # cursor docs show no "type" field assert "api.arcade.dev/mcp/github-slack" in entry["url"] @@ -547,7 +547,7 @@ class TestRunConnectInteractive: patch("arcade_cli.configure.console"), ): run_connect( - client="claude", + client="claude-code", all_tools=True, config_path=config_path, ) @@ -565,7 +565,7 @@ class TestRunConnectInteractive: patch("arcade_cli.connect.console"), pytest.raises(SystemExit), ): - run_connect(client="claude", all_tools=True) + run_connect(client="claude-code", all_tools=True) def test_toolkit_not_found_exits(self) -> None: with ( @@ -575,40 +575,7 @@ class TestRunConnectInteractive: 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") + run_connect(client="claude-code", toolkits=["nonexistent"]) # --------------------------------------------------------------------------- @@ -681,7 +648,7 @@ class TestRunConnectAdvanced: patch("arcade_cli.configure.console"), ): run_connect( - client="claude", + client="claude-code", toolkits=["github"], config_path=config_path, ) @@ -690,27 +657,6 @@ class TestRunConnectAdvanced: 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" @@ -729,7 +675,7 @@ class TestRunConnectAdvanced: patch("arcade_cli.configure.console"), ): run_connect( - client="claude", + client="claude-code", toolkits=["github"], gateway_slug="my-custom", config_path=config_path, @@ -758,7 +704,7 @@ class TestRunConnectAdvanced: patch("arcade_cli.configure.console"), ): run_connect( - client="claude", + client="claude-code", toolkits=["github"], tools=["Slack.SendMessage"], config_path=config_path,