"""Tests for get_tool_secrets() in arcade configure.""" import json 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, _warn_overwrite, configure_client, get_tool_secrets, ) 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, )