From 40e05af27c9e4450fcec2bcc23a686fb0c3455aa Mon Sep 17 00:00:00 2001
From: Pascal Matthiesen <434505+pmdroid@users.noreply.github.com>
Date: Fri, 24 Apr 2026 10:31:28 -0700
Subject: [PATCH] fix: claude, provide more options and remove apikey auth
(#825)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
> [!NOTE]
> **Medium Risk**
> Medium risk because it changes how `arcade connect` authenticates
(removes API-key flow) and rewrites user config files via new
atomic/backup logic across multiple clients/formats (JSON/TOML).
Mis-shaped entries or write/permission issues could break client
integrations despite added tests.
>
> **Overview**
> `arcade connect` is **OAuth-only** now: the `--api-key` flag and
project API-key creation flow were removed, and connect always writes
gateway configs without bearer tokens.
>
> Client support was expanded and corrected: Claude is now targeted as
`claude-code` (writing to `~/.claude.json`), and new gateway config
writers were added for `codex` (TOML upsert in `~/.codex/config.toml`),
`opencode`, and `gemini`, while Cursor’s remote entry format was changed
to match docs (no `type`).
>
> All config updates now use **atomic writes with a single `.bak`
backup** and (on POSIX) tighten permissions to protect tokens; extensive
tests were added to pin each client’s documented config shape and ensure
unrelated existing config content is preserved and not corrupted on
failures.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
19784e9311a00ed5dcedc7f27373ee9b0b842cf8. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
libs/arcade-cli/arcade_cli/configure.py | 373 ++++++-
libs/arcade-cli/arcade_cli/connect.py | 69 +-
libs/arcade-cli/arcade_cli/main.py | 27 +-
libs/tests/cli/test_configure.py | 1270 ++++++++++++++++++++++-
libs/tests/cli/test_connect.py | 76 +-
5 files changed, 1628 insertions(+), 187 deletions(-)
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,