arcade-mcp/libs/arcade-cli/arcade_cli/configure.py
Eric Gustin 4a737b9710
Improve .env discovery (#737)
Resolves TOO-201

Documentation PR for this is here:
https://github.com/ArcadeAI/docs/pull/626


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes how environment variables/secrets are discovered and loaded,
which can subtly alter runtime behavior depending on directory structure
and existing env vars; bounded traversal and added tests reduce but
don’t eliminate this risk.
> 
> **Overview**
> **Improves `.env` discovery across the MCP server and CLI.** Adds
`find_env_file()` (bounded by the nearest `pyproject.toml` by default)
and switches settings loading, `arcade deploy`, `arcade configure` stdio
env injection, and provider API-key resolution to use it.
> 
> Updates dev reload to also watch the discovered `.env` even when it
lives outside the current working directory, adjusts `deploy --secrets
all` to only run when a `.env` was found, and moves the minimal
scaffold’s `.env.example` to the project root with updated
tests/integration checks. Version bumps align examples and top-level
deps with `arcade-mcp-server` `1.17.4` and `arcade-mcp` `1.11.2`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
40cff1738c14674ce01f09fd325ece9c874cd072. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 23:20:28 -08:00

557 lines
20 KiB
Python

"""Connect command for configuring MCP clients."""
import json
import logging
import os
import platform
import re
import shutil
import subprocess
from pathlib import Path
import typer
from arcade_mcp_server.settings import find_env_file
from dotenv import dotenv_values
from arcade_cli.console import console
logger = logging.getLogger(__name__)
def is_wsl() -> bool:
"""Check if running in Windows Subsystem for Linux."""
# Check for WSL environment variable
if os.environ.get("WSL_DISTRO_NAME"):
return True
# Check /proc/version for WSL indicators
try:
with open("/proc/version", encoding="utf-8") as f:
version_info = f.read().lower()
return "microsoft" in version_info or "wsl" in version_info
except (FileNotFoundError, PermissionError):
return False
def get_windows_username() -> str | None:
"""Get the Windows username when running in WSL."""
try:
# Try to get username from Windows environment via cmd.exe
# Note: cmd.exe is safe to use here as it's a Windows system binary available in WSL
result = subprocess.run(
["cmd.exe", "/c", "echo", "%USERNAME%"], # noqa: S607
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
username = result.stdout.strip()
# Remove any carriage returns
username = username.replace("\r", "")
if username and username != "%USERNAME%":
return username
except (subprocess.SubprocessError, FileNotFoundError, subprocess.TimeoutExpired):
pass
return None
def _resolve_windows_appdata() -> Path:
"""Resolve the Windows roaming AppData directory via ``platformdirs``.
``platformdirs`` is the de-facto standard Python library for resolving
OS-specific user directories. On Windows it reads the ``APPDATA``
environment variable (and the Windows registry as a fallback), so a
single call covers every real-world scenario.
"""
from platformdirs import user_data_dir
try:
result = user_data_dir(appname=None, appauthor=False, roaming=True)
except TypeError:
# Older platformdirs versions require positional args only.
# Signature: user_data_dir(appname, appauthor, version, roaming)
logger.debug("platformdirs raised TypeError; retrying with positional args")
result = user_data_dir(None, False, None, True)
return Path(result)
def _dedupe_paths(paths: list[Path]) -> list[Path]:
"""Return paths in order, removing duplicates (case-insensitive on Windows)."""
deduped: list[Path] = []
seen: set[str] = set()
for path in paths:
key = os.path.normcase(str(path))
if key in seen:
continue
seen.add(key)
deduped.append(path)
return deduped
def _get_windows_cursor_config_paths() -> list[Path]:
"""Return known Windows Cursor config locations (primary first)."""
return _dedupe_paths([
_resolve_windows_appdata() / "Cursor" / "mcp.json",
Path.home() / ".cursor" / "mcp.json",
])
def _format_path_for_display(path: Path, platform_system: str | None = None) -> str:
path_str = str(path)
if " " in path_str:
system = platform_system or platform.system()
if system == "Windows":
return f'"{path_str}"'
return path_str.replace(" ", "\\ ")
return path_str
def _warn_overwrite(config: dict, section: str, server_name: str, config_path: Path) -> None:
if section in config and server_name in config[section]:
config_display = _format_path_for_display(config_path)
console.print(
f"[yellow]Warning: MCP server '{server_name}' already exists in {config_display}. "
"This will overwrite the existing entry. Use --name to keep both.[/yellow]"
)
def get_claude_config_path() -> Path:
"""Get the Claude Desktop configuration file path."""
system = platform.system()
if system == "Darwin": # macOS
return (
Path.home()
/ "Library"
/ "Application Support"
/ "Claude"
/ "claude_desktop_config.json"
)
elif system == "Windows":
return _resolve_windows_appdata() / "Claude" / "claude_desktop_config.json"
else: # Linux
# Check if we're in WSL - if so, use Windows path
if is_wsl():
username = get_windows_username()
if username:
# Use the Windows AppData path accessible via WSL mount
return Path(
f"/mnt/c/Users/{username}/AppData/Roaming/Claude/claude_desktop_config.json"
)
else:
console.print(
"[yellow]Warning: Running in WSL but couldn't determine Windows username. "
"Using Linux path instead. Claude Desktop may not detect this configuration.[/yellow]"
)
return Path.home() / ".config" / "Claude" / "claude_desktop_config.json"
def get_cursor_config_path() -> Path:
"""Get the Cursor configuration file path."""
system = platform.system()
if system == "Darwin": # macOS
return Path.home() / ".cursor" / "mcp.json"
elif system == "Windows":
candidates = _get_windows_cursor_config_paths()
for path in candidates:
if path.exists():
return path
return candidates[0]
else: # Linux
# Check if we're in WSL - if so, use Windows path
if is_wsl():
username = get_windows_username()
if username:
# Use the Windows AppData path accessible via WSL mount
return Path(f"/mnt/c/Users/{username}/AppData/Roaming/Cursor/mcp.json")
else:
console.print(
"[yellow]Warning: Running in WSL but couldn't determine Windows username. "
"Using Linux path instead. Cursor may not detect this configuration.[/yellow]"
)
return Path.home() / ".config" / "Cursor" / "mcp.json"
def get_vscode_config_path() -> Path:
"""Get the VS Code configuration file path."""
# Paths to global 'Default User' MCP configuration file
system = platform.system()
if system == "Darwin": # macOS
return Path.home() / "Library" / "Application Support" / "Code" / "User" / "mcp.json"
elif system == "Windows":
return _resolve_windows_appdata() / "Code" / "User" / "mcp.json"
else: # Linux
# Check if we're in WSL - if so, use Windows path
if is_wsl():
username = get_windows_username()
if username:
# Use the Windows AppData path accessible via WSL mount
return Path(f"/mnt/c/Users/{username}/AppData/Roaming/Code/User/mcp.json")
else:
console.print(
"[yellow]Warning: Running in WSL but couldn't determine Windows username. "
"Using Linux path instead. VS Code may not detect this configuration.[/yellow]"
)
return Path.home() / ".config" / "Code" / "User" / "mcp.json"
def is_uv_installed() -> bool:
"""Check if uv is installed and available in PATH."""
return shutil.which("uv") is not None
def get_tool_secrets() -> dict:
"""Get tool secrets from .env file for stdio servers.
Discovers .env file by traversing upward from the current directory
through parent directories until a .env file is found.
Returns:
Dictionary of environment variables from the .env file, or empty dict if not found.
"""
env_path = find_env_file()
if env_path is not None:
return dotenv_values(env_path)
return {}
def find_python_interpreter() -> Path:
"""
Find the Python interpreter in the virtual environment.
NOTE: This function assumes it is called from the project root directory (where .venv lives).
Currently, callers like `arcade deploy` enforce this by requiring pyproject.toml in cwd.
If this requirement is relaxed in the future, this function should be updated to:
1. Accept a project_root parameter, OR
2. Honor VIRTUAL_ENV / UV_PROJECT_ENVIRONMENT env vars, OR
3. Search upward from cwd to find pyproject.toml and resolve .venv relative to that
"""
venv_python = None
# Check for .venv first (uv default)
if (Path.cwd() / ".venv").exists():
system = platform.system()
if system == "Windows":
venv_python = Path.cwd() / ".venv" / "Scripts" / "python.exe"
else:
venv_python = Path.cwd() / ".venv" / "bin" / "python"
# Fall back to system python if no venv found
if not venv_python or not venv_python.exists():
console.print("[yellow]Warning: No .venv found, using system python[/yellow]")
import sys
venv_python = Path(sys.executable)
return venv_python
def get_stdio_config(entrypoint_file: str, server_name: str) -> dict:
"""Get the appropriate stdio configuration based on whether uv is installed."""
server_file = Path.cwd() / entrypoint_file
uv_executable = shutil.which("uv")
if uv_executable:
return {
# Use the absolute uv path so GUI clients can launch reliably even
# when they were started with a different PATH than the shell.
"command": uv_executable,
"args": [
"run",
"--directory",
str(Path.cwd()),
"python",
entrypoint_file,
],
"env": get_tool_secrets(),
}
else:
console.print(
"[yellow]Warning: uv is not installed. Install uv for the best experience with arcade configure CLI command.[/yellow]"
)
venv_python = find_python_interpreter()
return {
"command": str(venv_python),
"args": [str(server_file)],
"env": get_tool_secrets(),
}
def configure_claude_local(
entrypoint_file: str, server_name: str, port: int = 8000, config_path: Path | None = None
) -> None:
"""Configure Claude Desktop to add a local MCP server to the configuration."""
config_path = config_path or get_claude_config_path()
# Handle both absolute and relative config paths
if config_path and not config_path.is_absolute():
config_path = Path.cwd() / config_path
config_path.parent.mkdir(parents=True, exist_ok=True)
# Load existing config or create new one
config = {}
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
# Add or update MCP servers configuration
if "mcpServers" not in config:
config["mcpServers"] = {}
_warn_overwrite(config, "mcpServers", server_name, config_path)
# Claude Desktop uses stdio transport
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)
console.print(
f"✅ Configured Claude Desktop by adding local MCP server '{server_name}' to the configuration",
style="green",
)
config_file_path = _format_path_for_display(config_path)
console.print(f" MCP client config file: {config_file_path}", style="dim")
console.print(
f" Server file: {_format_path_for_display(Path.cwd() / entrypoint_file)}",
style="dim",
)
if is_uv_installed():
console.print(" Using uv to run server", style="dim")
else:
console.print(f" Python interpreter: {find_python_interpreter()}", style="dim")
console.print(" Restart Claude Desktop for changes to take effect.", style="yellow")
def configure_claude_arcade(
server_name: str, transport: str, config_path: Path | None = None
) -> None:
"""Configure Claude Desktop to add an Arcade Cloud MCP server to the configuration."""
# This would connect to the Arcade Cloud to get the server URL
# For now, this is a placeholder
console.print("[red]Connecting to Arcade Cloud servers not yet implemented[/red]")
def configure_cursor_local(
entrypoint_file: str,
server_name: str,
transport: str,
port: int = 8000,
config_path: Path | None = None,
) -> None:
"""Configure Cursor to add a local MCP server to the configuration."""
def http_config(server_name: str, port: int = 8000) -> dict:
return {
"name": server_name,
"type": "stream", # Cursor prefers stream
"url": f"http://localhost:{port}/mcp",
}
if config_path is not None:
target_paths = [config_path]
elif platform.system() == "Windows":
primary_path = get_cursor_config_path()
target_paths = _dedupe_paths([primary_path, *_get_windows_cursor_config_paths()])
else:
target_paths = [get_cursor_config_path()]
# Handle both absolute and relative config paths.
resolved_target_paths: list[Path] = []
for path in target_paths:
resolved_target_paths.append(path if path.is_absolute() else Path.cwd() / path)
server_config = (
get_stdio_config(entrypoint_file, server_name)
if transport == "stdio"
else http_config(server_name, port)
)
for idx, config_path in enumerate(resolved_target_paths):
config_path.parent.mkdir(parents=True, exist_ok=True)
# Load existing config or create new one
config = {}
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
# Add or update MCP servers configuration
if "mcpServers" not in config:
config["mcpServers"] = {}
if idx == 0:
_warn_overwrite(config, "mcpServers", server_name, config_path)
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)
primary_config_path = resolved_target_paths[0]
console.print(
f"✅ Configured Cursor by adding local MCP server '{server_name}' to the configuration",
style="green",
)
config_file_path = _format_path_for_display(primary_config_path)
console.print(f" MCP client config file: {config_file_path}", style="dim")
compatibility_paths = resolved_target_paths[1:]
if compatibility_paths:
compatibility_display = ", ".join(
_format_path_for_display(path) for path in compatibility_paths
)
console.print(
f" Also updated compatibility config file(s): {compatibility_display}",
style="dim",
)
if transport == "http":
console.print(f" MCP Server URL: http://localhost:{port}/mcp", style="dim")
elif transport == "stdio":
if is_uv_installed():
console.print(" Using uv to run server", style="dim")
else:
console.print(f" Python interpreter: {find_python_interpreter()}", style="dim")
console.print(" Restart Cursor for changes to take effect.", style="yellow")
def configure_cursor_arcade(
server_name: str, transport: str, config_path: Path | None = None
) -> None:
"""Configure Cursor to add an Arcade Cloud MCP server to the configuration."""
console.print("[red]Connecting to Arcade Cloud servers not yet implemented[/red]")
def configure_vscode_local(
entrypoint_file: str,
server_name: str,
transport: str,
port: int = 8000,
config_path: Path | None = None,
) -> None:
"""Configure VS Code to add a local MCP server to the configuration."""
def http_config(port: int = 8000) -> dict:
return {
"type": "http",
"url": f"http://localhost:{port}/mcp",
}
config_path = config_path or get_vscode_config_path()
# Handle both absolute and relative config paths
if config_path and not config_path.is_absolute():
config_path = Path.cwd() / config_path
config_path.parent.mkdir(parents=True, exist_ok=True)
# Load existing config or create new one
config = {}
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
try:
config = json.load(f)
except json.JSONDecodeError as e:
raise ValueError(
f"\n\tFailed to load MCP configuration file at {_format_path_for_display(config_path)} "
f"\n\tThe file contains invalid JSON: {e}. "
"\n\tPlease check the file format or delete it to create a new configuration."
)
# Add or update MCP servers configuration
if "servers" not in config:
config["servers"] = {}
_warn_overwrite(config, "servers", server_name, config_path)
config["servers"][server_name] = (
get_stdio_config(entrypoint_file, server_name)
if transport == "stdio"
else http_config(port)
)
# Write updated config
with open(config_path, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2)
console.print(
f"✅ Configured VS Code by adding local MCP server '{server_name}' to the configuration",
style="green",
)
config_file_path = _format_path_for_display(config_path)
console.print(f" MCP client config file: {config_file_path}", style="dim")
if transport == "http":
console.print(f" MCP Server URL: http://localhost:{port}/mcp", style="dim")
elif transport == "stdio":
if is_uv_installed():
console.print(" Using uv to run server", style="dim")
else:
console.print(f" Python interpreter: {find_python_interpreter()}", style="dim")
console.print(" Restart VS Code for changes to take effect.", style="yellow")
def configure_vscode_arcade(server_name: str, transport: str, path: Path | None = None) -> None:
"""Configure VS Code to add an Arcade Cloud MCP server to the configuration."""
console.print("[red]Connecting to Arcade Cloud servers not yet implemented[/red]")
def configure_client(
client: str,
entrypoint_file: str,
server_name: str | None = None,
transport: str = "stdio",
host: str = "local",
port: int = 8000,
config_path: Path | None = None,
) -> None:
"""
Configure an MCP client to connect to a server.
Args:
client: The MCP client to configure (claude, cursor, vscode)
entrypoint_file: The name of the Python file in the current directory that runs the server. This file must run the server when invoked directly. Only used for stdio servers.
server_name: Name of the server to add to the configuration
transport: The transport to use for the MCP server configuration
host: The host of the server to configure (local or arcade)
port: Port for local HTTP servers (default: 8000)
config_path: Custom path to the MCP client configuration file
"""
if not server_name:
# Use the name of the current directory as the server name
server_name = Path.cwd().name
if transport == "stdio":
if not bool(re.match(r"^[a-zA-Z0-9_-]+\.py$", entrypoint_file)):
raise ValueError(f"Entrypoint file '{entrypoint_file}' is not a valid Python file name")
if not (Path.cwd() / entrypoint_file).exists():
raise ValueError(f"Entrypoint file '{entrypoint_file}' is not in the current directory")
client_lower = client.lower()
if client_lower == "claude":
if transport != "stdio":
raise ValueError("Claude Desktop only supports stdio transport via configuration file")
if host == "local":
configure_claude_local(entrypoint_file, server_name, port, config_path)
else:
configure_claude_arcade(server_name, transport, config_path)
elif client_lower == "cursor":
if host == "local":
configure_cursor_local(entrypoint_file, server_name, transport, port, config_path)
else:
configure_cursor_arcade(server_name, transport, config_path)
elif client_lower == "vscode":
if host == "local":
configure_vscode_local(entrypoint_file, server_name, transport, port, config_path)
else:
configure_vscode_arcade(server_name, transport, config_path)
else:
raise typer.BadParameter(
f"Unknown client: {client}. Supported clients: claude, cursor, vscode."
)