<!-- CURSOR_SUMMARY --> > [!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. > > <sup>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).</sup> <!-- /CURSOR_SUMMARY -->
2071 lines
75 KiB
Python
2071 lines
75 KiB
Python
"""Tests for get_tool_secrets() and gateway configuration in arcade configure."""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import types
|
|
from io import StringIO
|
|
from pathlib import Path
|
|
|
|
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_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,
|
|
get_toolkit_http_config,
|
|
get_toolkit_stdio_config,
|
|
)
|
|
|
|
|
|
def _write_entrypoint(tmp_path: Path) -> Path:
|
|
entrypoint = tmp_path / "server.py"
|
|
entrypoint.write_text("print('ok')\n", encoding="utf-8")
|
|
return entrypoint
|
|
|
|
|
|
def _load_json(path: Path) -> dict:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def _assert_stdio_entry(entry: dict) -> None:
|
|
assert "command" in entry
|
|
assert "args" in entry
|
|
assert any(str(arg).endswith("server.py") for arg in entry["args"])
|
|
assert "env" in entry
|
|
|
|
|
|
def test_get_tool_secrets_loads_from_env_file(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Should load secrets from .env file."""
|
|
env_file = tmp_path / ".env"
|
|
env_file.write_text("SECRET_ONE=value1\nSECRET_TWO=value2")
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
secrets = get_tool_secrets()
|
|
assert secrets.get("SECRET_ONE") == "value1"
|
|
assert secrets.get("SECRET_TWO") == "value2"
|
|
|
|
|
|
def test_get_tool_secrets_returns_empty_when_no_env(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Should return empty dict when no .env exists."""
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
assert get_tool_secrets() == {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _format_path_for_display()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_format_path_for_display_windows_quotes() -> None:
|
|
path = Path(r"C:\Users\A User\My Server\mcp.json")
|
|
assert (
|
|
_format_path_for_display(path, platform_system="Windows")
|
|
== '"C:\\Users\\A User\\My Server\\mcp.json"'
|
|
)
|
|
|
|
|
|
def test_format_path_for_display_no_spaces_unchanged() -> None:
|
|
"""Paths without spaces should be returned as-is."""
|
|
path = Path(r"C:\Users\Alice\mcp.json")
|
|
result = _format_path_for_display(path, platform_system="Windows")
|
|
assert result == str(path)
|
|
assert '"' not in result
|
|
|
|
|
|
def test_format_path_for_display_posix_escapes() -> None:
|
|
# Use str directly to avoid Windows Path normalization converting / to \
|
|
import sys
|
|
|
|
if sys.platform == "win32":
|
|
# On Windows, Path("/tmp/with space/mcp.json") uses backslashes.
|
|
# The function should still escape spaces.
|
|
path = Path("/tmp/with space/mcp.json")
|
|
result = _format_path_for_display(path, platform_system="Linux")
|
|
assert "\\ " in result # spaces are escaped
|
|
else:
|
|
path = Path("/tmp/with space/mcp.json")
|
|
assert (
|
|
_format_path_for_display(path, platform_system="Linux") == "/tmp/with\\ space/mcp.json"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _resolve_windows_appdata()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_resolve_windows_appdata_delegates_to_platformdirs(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""_resolve_windows_appdata returns whatever platformdirs resolves."""
|
|
monkeypatch.delenv("APPDATA", raising=False)
|
|
monkeypatch.delenv("LOCALAPPDATA", raising=False)
|
|
monkeypatch.delenv("USERPROFILE", raising=False)
|
|
|
|
fake_platformdirs = types.ModuleType("platformdirs")
|
|
fake_platformdirs.user_data_dir = lambda *args, **kwargs: r"C:\Users\Alice\AppData\Roaming"
|
|
monkeypatch.setitem(sys.modules, "platformdirs", fake_platformdirs)
|
|
|
|
assert _resolve_windows_appdata() == Path(r"C:\Users\Alice\AppData\Roaming")
|
|
|
|
|
|
def test_resolve_windows_appdata_handles_older_platformdirs(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Falls back to positional args when platformdirs raises TypeError.
|
|
|
|
The positional signature is user_data_dir(appname, appauthor, version, roaming).
|
|
The fallback call must pass roaming=True as the *fourth* positional arg, not
|
|
the third (which would be ``version``).
|
|
"""
|
|
received_args: list[tuple] = []
|
|
|
|
def strict_user_data_dir(*args: object, **kwargs: object) -> str:
|
|
if kwargs:
|
|
raise TypeError("keyword args not supported")
|
|
received_args.append(args)
|
|
return r"C:\Users\Bob\AppData\Roaming"
|
|
|
|
fake_platformdirs = types.ModuleType("platformdirs")
|
|
fake_platformdirs.user_data_dir = strict_user_data_dir
|
|
monkeypatch.setitem(sys.modules, "platformdirs", fake_platformdirs)
|
|
|
|
result = _resolve_windows_appdata()
|
|
assert result == Path(r"C:\Users\Bob\AppData\Roaming")
|
|
|
|
# First call raises TypeError (has kwargs), second call uses positional args.
|
|
# Verify the fallback used the correct signature: (appname, appauthor, version, roaming)
|
|
assert len(received_args) == 1, "Fallback must make exactly one positional call"
|
|
fallback_args = received_args[0]
|
|
# args: (None, False, None, True) — roaming is the 4th positional arg
|
|
assert len(fallback_args) == 4, (
|
|
f"Expected 4 positional args, got {len(fallback_args)}: {fallback_args}"
|
|
)
|
|
assert fallback_args[3] is True, f"4th arg (roaming) must be True, got {fallback_args[3]}"
|
|
assert fallback_args[2] is None, f"3rd arg (version) must be None, got {fallback_args[2]}"
|
|
|
|
|
|
def test_get_cursor_config_path_windows_prefers_existing_candidate(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
import arcade_cli.configure as configure_mod
|
|
|
|
appdata_path = tmp_path / "AppData" / "Roaming" / "Cursor" / "mcp.json"
|
|
home_path = tmp_path / ".cursor" / "mcp.json"
|
|
home_path.parent.mkdir(parents=True, exist_ok=True)
|
|
home_path.write_text("{}", encoding="utf-8")
|
|
|
|
monkeypatch.setattr(configure_mod.platform, "system", lambda: "Windows")
|
|
monkeypatch.setattr(
|
|
configure_mod,
|
|
"_get_windows_cursor_config_paths",
|
|
lambda: [appdata_path, home_path],
|
|
)
|
|
|
|
assert configure_mod.get_cursor_config_path() == home_path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _warn_overwrite()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_warn_overwrite_prints_when_entry_exists() -> None:
|
|
"""Should print a yellow warning when the server entry already exists."""
|
|
from arcade_cli.console import Console
|
|
|
|
buf = StringIO()
|
|
test_console = Console(file=buf, force_terminal=False)
|
|
|
|
import arcade_cli.configure as configure_mod
|
|
|
|
orig = configure_mod.console
|
|
configure_mod.console = test_console
|
|
try:
|
|
config = {"mcpServers": {"demo": {"command": "old"}}}
|
|
_warn_overwrite(config, "mcpServers", "demo", Path("/fake/cursor.json"))
|
|
finally:
|
|
configure_mod.console = orig
|
|
|
|
output = buf.getvalue()
|
|
assert "demo" in output
|
|
assert "already exists" in output
|
|
|
|
|
|
def test_warn_overwrite_silent_when_no_entry() -> None:
|
|
"""Should NOT print anything when the server entry doesn't exist."""
|
|
from arcade_cli.console import Console
|
|
|
|
buf = StringIO()
|
|
test_console = Console(file=buf, force_terminal=True)
|
|
|
|
# Temporarily monkey-patch the module-level console used by _warn_overwrite.
|
|
import arcade_cli.configure as configure_mod
|
|
|
|
orig = configure_mod.console
|
|
configure_mod.console = test_console
|
|
try:
|
|
config: dict = {"mcpServers": {}}
|
|
_warn_overwrite(config, "mcpServers", "new_server", Path("/fake/mcp.json"))
|
|
finally:
|
|
configure_mod.console = orig
|
|
|
|
assert buf.getvalue() == "", "No output expected when entry doesn't exist"
|
|
|
|
|
|
def test_warn_overwrite_message_content() -> None:
|
|
"""Verify the warning message mentions the server name."""
|
|
from arcade_cli.console import Console
|
|
|
|
buf = StringIO()
|
|
test_console = Console(file=buf, force_terminal=False)
|
|
|
|
import arcade_cli.configure as configure_mod
|
|
|
|
orig = configure_mod.console
|
|
configure_mod.console = test_console
|
|
try:
|
|
config = {"servers": {"my_srv": {"command": "old"}}}
|
|
_warn_overwrite(config, "servers", "my_srv", Path("/fake/vscode.json"))
|
|
finally:
|
|
configure_mod.console = orig
|
|
|
|
output = buf.getvalue()
|
|
assert "my_srv" in output
|
|
assert "already exists" in output
|
|
assert "--name" in output
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# UTF-8 config I/O
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_config_written_as_utf8(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Config files must be written with UTF-8 encoding, including non-ASCII paths."""
|
|
monkeypatch.chdir(tmp_path)
|
|
_write_entrypoint(tmp_path)
|
|
config_path = tmp_path / "config.json"
|
|
|
|
configure_client(
|
|
client="cursor",
|
|
entrypoint_file="server.py",
|
|
server_name="demo",
|
|
transport="stdio",
|
|
host="local",
|
|
port=8000,
|
|
config_path=config_path,
|
|
)
|
|
|
|
# Read the file as raw bytes and verify UTF-8 BOM is absent and content
|
|
# decodes cleanly as UTF-8.
|
|
raw = config_path.read_bytes()
|
|
assert not raw.startswith(b"\xef\xbb\xbf"), "UTF-8 BOM should not be present"
|
|
decoded = raw.decode("utf-8") # Should not raise
|
|
data = json.loads(decoded)
|
|
assert "mcpServers" in data
|
|
assert "demo" in data["mcpServers"]
|
|
|
|
|
|
def test_config_roundtrip_preserves_unicode(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Write a config with Unicode, then overwrite and verify it still decodes."""
|
|
monkeypatch.chdir(tmp_path)
|
|
_write_entrypoint(tmp_path)
|
|
config_path = tmp_path / "config.json"
|
|
|
|
# Seed with Unicode content
|
|
config_path.write_text(
|
|
json.dumps({"mcpServers": {"caf\u00e9": {"command": "old"}}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
configure_client(
|
|
client="cursor",
|
|
entrypoint_file="server.py",
|
|
server_name="demo",
|
|
transport="stdio",
|
|
host="local",
|
|
port=8000,
|
|
config_path=config_path,
|
|
)
|
|
|
|
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
# Original Unicode entry should be preserved alongside the new one.
|
|
assert "caf\u00e9" in data["mcpServers"]
|
|
assert "demo" in data["mcpServers"]
|
|
|
|
|
|
def test_cursor_config_stdio_and_http(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_write_entrypoint(tmp_path)
|
|
config_path = tmp_path / "cursor.json"
|
|
|
|
configure_client(
|
|
client="cursor",
|
|
entrypoint_file="server.py",
|
|
server_name="demo",
|
|
transport="stdio",
|
|
host="local",
|
|
port=8000,
|
|
config_path=config_path,
|
|
)
|
|
config = _load_json(config_path)
|
|
entry = config["mcpServers"]["demo"]
|
|
_assert_stdio_entry(entry)
|
|
|
|
configure_client(
|
|
client="cursor",
|
|
entrypoint_file="server.py",
|
|
server_name="demo",
|
|
transport="http",
|
|
host="local",
|
|
port=8123,
|
|
config_path=config_path,
|
|
)
|
|
config = _load_json(config_path)
|
|
entry = config["mcpServers"]["demo"]
|
|
assert entry["type"] == "stream"
|
|
assert entry["url"] == "http://localhost:8123/mcp"
|
|
|
|
|
|
def test_cursor_config_stdio_uses_absolute_uv_path(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
import arcade_cli.configure as configure_mod
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
_write_entrypoint(tmp_path)
|
|
config_path = tmp_path / "cursor.json"
|
|
monkeypatch.setattr(
|
|
configure_mod.shutil,
|
|
"which",
|
|
lambda executable: r"C:\Tools\uv.exe" if executable == "uv" else None,
|
|
)
|
|
|
|
configure_client(
|
|
client="cursor",
|
|
entrypoint_file="server.py",
|
|
server_name="demo",
|
|
transport="stdio",
|
|
host="local",
|
|
port=8000,
|
|
config_path=config_path,
|
|
)
|
|
|
|
config = _load_json(config_path)
|
|
assert config["mcpServers"]["demo"]["command"] == r"C:\Tools\uv.exe"
|
|
|
|
|
|
def test_cursor_windows_writes_compatibility_paths(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
import arcade_cli.configure as configure_mod
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
_write_entrypoint(tmp_path)
|
|
|
|
appdata_path = tmp_path / "AppData" / "Roaming" / "Cursor" / "mcp.json"
|
|
home_path = tmp_path / ".cursor" / "mcp.json"
|
|
appdata_path.parent.mkdir(parents=True, exist_ok=True)
|
|
home_path.parent.mkdir(parents=True, exist_ok=True)
|
|
appdata_path.write_text(
|
|
json.dumps({"mcpServers": {"appdata_only": {"command": "x"}}}),
|
|
encoding="utf-8",
|
|
)
|
|
home_path.write_text(
|
|
json.dumps({"mcpServers": {"home_only": {"command": "y"}}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
monkeypatch.setattr(configure_mod.platform, "system", lambda: "Windows")
|
|
monkeypatch.setattr(configure_mod, "get_cursor_config_path", lambda: appdata_path)
|
|
monkeypatch.setattr(
|
|
configure_mod,
|
|
"_get_windows_cursor_config_paths",
|
|
lambda: [appdata_path, home_path],
|
|
)
|
|
|
|
configure_client(
|
|
client="cursor",
|
|
entrypoint_file="server.py",
|
|
server_name="demo",
|
|
transport="stdio",
|
|
host="local",
|
|
port=8000,
|
|
)
|
|
|
|
appdata_config = _load_json(appdata_path)
|
|
home_config = _load_json(home_path)
|
|
assert "demo" in appdata_config["mcpServers"]
|
|
assert "demo" in home_config["mcpServers"]
|
|
assert "appdata_only" in appdata_config["mcpServers"]
|
|
assert "home_only" in home_config["mcpServers"]
|
|
|
|
|
|
def test_cursor_windows_explicit_config_does_not_write_compatibility_paths(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
import arcade_cli.configure as configure_mod
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
_write_entrypoint(tmp_path)
|
|
|
|
explicit_path = tmp_path / "custom" / "cursor.json"
|
|
appdata_path = tmp_path / "AppData" / "Roaming" / "Cursor" / "mcp.json"
|
|
home_path = tmp_path / ".cursor" / "mcp.json"
|
|
|
|
monkeypatch.setattr(configure_mod.platform, "system", lambda: "Windows")
|
|
monkeypatch.setattr(configure_mod, "get_cursor_config_path", lambda: appdata_path)
|
|
monkeypatch.setattr(
|
|
configure_mod,
|
|
"_get_windows_cursor_config_paths",
|
|
lambda: [appdata_path, home_path],
|
|
)
|
|
|
|
configure_client(
|
|
client="cursor",
|
|
entrypoint_file="server.py",
|
|
server_name="demo",
|
|
transport="stdio",
|
|
host="local",
|
|
port=8000,
|
|
config_path=explicit_path,
|
|
)
|
|
|
|
assert explicit_path.exists()
|
|
assert not appdata_path.exists()
|
|
assert not home_path.exists()
|
|
|
|
|
|
def test_vscode_config_stdio_and_http(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_write_entrypoint(tmp_path)
|
|
config_path = tmp_path / "vscode.json"
|
|
|
|
configure_client(
|
|
client="vscode",
|
|
entrypoint_file="server.py",
|
|
server_name="demo",
|
|
transport="stdio",
|
|
host="local",
|
|
port=8000,
|
|
config_path=config_path,
|
|
)
|
|
config = _load_json(config_path)
|
|
entry = config["servers"]["demo"]
|
|
_assert_stdio_entry(entry)
|
|
|
|
configure_client(
|
|
client="vscode",
|
|
entrypoint_file="server.py",
|
|
server_name="demo",
|
|
transport="http",
|
|
host="local",
|
|
port=8123,
|
|
config_path=config_path,
|
|
)
|
|
config = _load_json(config_path)
|
|
entry = config["servers"]["demo"]
|
|
assert entry["type"] == "http"
|
|
assert entry["url"] == "http://localhost:8123/mcp"
|
|
|
|
|
|
def test_claude_config_stdio_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_write_entrypoint(tmp_path)
|
|
config_path = tmp_path / "claude.json"
|
|
|
|
configure_client(
|
|
client="claude",
|
|
entrypoint_file="server.py",
|
|
server_name="demo",
|
|
transport="stdio",
|
|
host="local",
|
|
port=8000,
|
|
config_path=config_path,
|
|
)
|
|
config = _load_json(config_path)
|
|
entry = config["mcpServers"]["demo"]
|
|
_assert_stdio_entry(entry)
|
|
|
|
with pytest.raises(ValueError, match="Claude Desktop only supports stdio"):
|
|
configure_client(
|
|
client="claude",
|
|
entrypoint_file="server.py",
|
|
server_name="demo",
|
|
transport="http",
|
|
host="local",
|
|
port=8000,
|
|
config_path=config_path,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# configure_*_arcade() — gateway configuration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConfigureClaudeCodeArcade:
|
|
def test_writes_http_config(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "claude.json"
|
|
configure_claude_code_arcade(
|
|
server_name="my-gw",
|
|
gateway_url="https://api.arcade.dev/mcp/my-gw",
|
|
auth_token="tok_abc",
|
|
config_path=config_path,
|
|
)
|
|
config = _load_json(config_path)
|
|
entry = config["mcpServers"]["my-gw"]
|
|
assert entry["type"] == "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({
|
|
"projects": {"/some/path": {"mcpServers": {}}},
|
|
"mcpServers": {"existing": {"type": "http", "url": "https://old"}},
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
configure_claude_code_arcade(
|
|
server_name="new-gw",
|
|
gateway_url="https://api.arcade.dev/mcp/new-gw",
|
|
auth_token="tok",
|
|
config_path=config_path,
|
|
)
|
|
config = _load_json(config_path)
|
|
assert "existing" in config["mcpServers"]
|
|
assert "new-gw" in config["mcpServers"]
|
|
assert "projects" in config
|
|
|
|
|
|
class TestConfigureCursorArcade:
|
|
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",
|
|
gateway_url="https://api.arcade.dev/mcp/my-gw",
|
|
auth_token="tok_abc",
|
|
config_path=config_path,
|
|
)
|
|
config = _load_json(config_path)
|
|
entry = config["mcpServers"]["my-gw"]
|
|
assert "type" not in entry
|
|
assert entry["url"] == "https://api.arcade.dev/mcp/my-gw"
|
|
assert entry["headers"]["Authorization"] == "Bearer tok_abc"
|
|
|
|
|
|
class TestConfigureVscodeArcade:
|
|
def test_writes_http_config(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "vscode.json"
|
|
configure_vscode_arcade(
|
|
server_name="my-gw",
|
|
gateway_url="https://api.arcade.dev/mcp/my-gw",
|
|
auth_token="tok_abc",
|
|
config_path=config_path,
|
|
)
|
|
config = _load_json(config_path)
|
|
entry = config["servers"]["my-gw"]
|
|
assert entry["type"] == "http"
|
|
assert entry["url"] == "https://api.arcade.dev/mcp/my-gw"
|
|
assert entry["headers"]["Authorization"] == "Bearer tok_abc"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# configure_client_gateway() — dispatcher
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConfigureClientGateway:
|
|
@pytest.mark.parametrize(
|
|
"client,section",
|
|
[
|
|
("claude-code", "mcpServers"),
|
|
("cursor", "mcpServers"),
|
|
("vscode", "servers"),
|
|
("windsurf", "mcpServers"),
|
|
("amazonq", "mcpServers"),
|
|
],
|
|
)
|
|
def test_dispatches_to_correct_client(self, tmp_path: Path, client: str, section: str) -> None:
|
|
config_path = tmp_path / f"{client}.json"
|
|
configure_client_gateway(
|
|
client=client,
|
|
server_name="test-gw",
|
|
gateway_url="https://api.arcade.dev/mcp/test-gw",
|
|
auth_token="tok",
|
|
config_path=config_path,
|
|
)
|
|
config = _load_json(config_path)
|
|
assert "test-gw" in config[section]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# configure_client_toolkit() — toolkit stdio config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConfigureClientToolkit:
|
|
def test_claude_toolkit_stdio(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "claude.json"
|
|
configure_client_toolkit(
|
|
client="claude",
|
|
server_name="arcade-github",
|
|
tool_packages=["github"],
|
|
config_path=config_path,
|
|
transport="stdio",
|
|
)
|
|
config = _load_json(config_path)
|
|
entry = config["mcpServers"]["arcade-github"]
|
|
assert "command" in entry
|
|
assert "--tool-package" in entry["args"]
|
|
assert "github" in entry["args"]
|
|
|
|
def test_claude_toolkit_http(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "claude.json"
|
|
configure_client_toolkit(
|
|
client="claude",
|
|
server_name="arcade-github",
|
|
tool_packages=["github"],
|
|
config_path=config_path,
|
|
transport="http",
|
|
port=8000,
|
|
)
|
|
config = _load_json(config_path)
|
|
entry = config["mcpServers"]["arcade-github"]
|
|
assert entry["url"] == "http://localhost:8000/mcp"
|
|
assert "command" not in entry
|
|
|
|
def test_cursor_toolkit_http(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "cursor.json"
|
|
configure_client_toolkit(
|
|
client="cursor",
|
|
server_name="arcade-github",
|
|
tool_packages=["github"],
|
|
config_path=config_path,
|
|
transport="http",
|
|
port=9000,
|
|
)
|
|
config = _load_json(config_path)
|
|
entry = config["mcpServers"]["arcade-github"]
|
|
assert entry["type"] == "sse"
|
|
assert entry["url"] == "http://localhost:9000/mcp"
|
|
|
|
def test_vscode_toolkit_stdio(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "vscode.json"
|
|
configure_client_toolkit(
|
|
client="vscode",
|
|
server_name="arcade-tools",
|
|
tool_packages=["github", "slack"],
|
|
config_path=config_path,
|
|
transport="stdio",
|
|
)
|
|
config = _load_json(config_path)
|
|
entry = config["servers"]["arcade-tools"]
|
|
assert "command" in entry
|
|
args_str = " ".join(str(a) for a in entry["args"])
|
|
assert "github" in args_str
|
|
assert "slack" in args_str
|
|
|
|
def test_vscode_toolkit_http(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "vscode.json"
|
|
configure_client_toolkit(
|
|
client="vscode",
|
|
server_name="arcade-tools",
|
|
tool_packages=["github", "slack"],
|
|
config_path=config_path,
|
|
transport="http",
|
|
)
|
|
config = _load_json(config_path)
|
|
entry = config["servers"]["arcade-tools"]
|
|
assert entry["type"] == "http"
|
|
assert entry["url"] == "http://localhost:8000/mcp"
|
|
|
|
def test_windsurf_toolkit_stdio(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "windsurf.json"
|
|
configure_client_toolkit(
|
|
client="windsurf",
|
|
server_name="arcade-github",
|
|
tool_packages=["github"],
|
|
config_path=config_path,
|
|
)
|
|
config = _load_json(config_path)
|
|
entry = config["mcpServers"]["arcade-github"]
|
|
assert "command" in entry
|
|
assert "--tool-package" in entry["args"]
|
|
|
|
def test_amazonq_toolkit_stdio(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "amazonq.json"
|
|
configure_client_toolkit(
|
|
client="amazonq",
|
|
server_name="arcade-github",
|
|
tool_packages=["github"],
|
|
config_path=config_path,
|
|
)
|
|
config = _load_json(config_path)
|
|
entry = config["mcpServers"]["arcade-github"]
|
|
assert "command" in entry
|
|
assert "--tool-package" in entry["args"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_toolkit_stdio_config()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetToolkitStdioConfig:
|
|
def test_uses_uv_when_available(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
import arcade_cli.configure as configure_mod
|
|
|
|
monkeypatch.setattr(
|
|
configure_mod.shutil, "which", lambda exe: "/usr/bin/uv" if exe == "uv" else None
|
|
)
|
|
config = get_toolkit_stdio_config(["github"], "arcade-github")
|
|
assert config["command"] == "/usr/bin/uv"
|
|
assert "tool" in config["args"]
|
|
assert "run" in config["args"]
|
|
assert "--tool-package" in config["args"]
|
|
assert "github" in config["args"]
|
|
|
|
def test_falls_back_to_python(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
import arcade_cli.configure as configure_mod
|
|
|
|
monkeypatch.setattr(configure_mod.shutil, "which", lambda exe: None)
|
|
config = get_toolkit_stdio_config(["github"], "arcade-github")
|
|
assert "python" in config["command"].lower() or config["command"].endswith("python3")
|
|
assert "--tool-package" in config["args"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_toolkit_http_config()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetToolkitHttpConfig:
|
|
def test_claude_config(self) -> None:
|
|
config = get_toolkit_http_config("claude", ["github"])
|
|
assert config["url"] == "http://localhost:8000/mcp"
|
|
assert "type" not in config
|
|
|
|
def test_cursor_config(self) -> None:
|
|
config = get_toolkit_http_config("cursor", ["github"])
|
|
assert config["type"] == "sse"
|
|
assert config["url"] == "http://localhost:8000/mcp"
|
|
|
|
def test_vscode_config(self) -> None:
|
|
config = get_toolkit_http_config("vscode", ["github"])
|
|
assert config["type"] == "http"
|
|
assert config["url"] == "http://localhost:8000/mcp"
|
|
|
|
def test_custom_port(self) -> None:
|
|
config = get_toolkit_http_config("claude", ["github"], port=9000)
|
|
assert config["url"] == "http://localhost:9000/mcp"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# New clients: Windsurf, Amazon Q, Zed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConfigureWindsurfArcade:
|
|
def test_writes_mcpservers_config(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "windsurf.json"
|
|
configure_windsurf_arcade(
|
|
server_name="my-gw",
|
|
gateway_url="https://api.arcade.dev/mcp/my-gw",
|
|
config_path=config_path,
|
|
)
|
|
config = _load_json(config_path)
|
|
entry = config["mcpServers"]["my-gw"]
|
|
assert entry["url"] == "https://api.arcade.dev/mcp/my-gw"
|
|
assert "headers" not in entry
|
|
|
|
def test_with_api_key(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "windsurf.json"
|
|
configure_windsurf_arcade(
|
|
server_name="my-gw",
|
|
gateway_url="https://api.arcade.dev/mcp/my-gw",
|
|
auth_token="arc_test",
|
|
config_path=config_path,
|
|
)
|
|
config = _load_json(config_path)
|
|
assert config["mcpServers"]["my-gw"]["headers"]["Authorization"] == "Bearer arc_test"
|
|
|
|
|
|
class TestConfigureAmazonqArcade:
|
|
def test_writes_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",
|
|
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["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 <path>.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
|