## PR Description This PR tackles 3 things: 1. At tool execution runtime, blocks local HTTP servers from executing tools that have `requires_auth` or `requires_secrets` 2. Make `stdio` the default transport in various locations 3. Improve the `arcade configure` CLI command <img width="1408" height="1194" alt="image" src="https://github.com/user-attachments/assets/badf1b55-ec7d-4741-89f5-4b5fee294890" /> <img width="3034" height="906" alt="image" src="https://github.com/user-attachments/assets/aea528c5-4ea6-4eed-b5d7-f946626e58a7" /> --------- Co-authored-by: Evan Tahler <evantahler@gmail.com>
362 lines
13 KiB
Python
362 lines
13 KiB
Python
"""Connect command for configuring MCP clients."""
|
|
|
|
import json
|
|
import os
|
|
import platform
|
|
import re
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import typer
|
|
from dotenv import dotenv_values
|
|
from rich.console import Console
|
|
|
|
console = Console()
|
|
|
|
|
|
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 Path(os.environ["APPDATA"]) / "Claude" / "claude_desktop_config.json"
|
|
else: # Linux
|
|
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":
|
|
return Path(os.environ["APPDATA"]) / "Cursor" / "mcp.json"
|
|
else: # Linux
|
|
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 Path(os.environ["APPDATA"]) / "Code" / "User" / "mcp.json"
|
|
else: # Linux
|
|
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:
|
|
"""Only useful for stdio servers, because HTTP servers load in envvars at runtime"""
|
|
# TODO: Allow for a custom .env file to be used
|
|
env_path = Path.cwd() / ".env"
|
|
if env_path.exists():
|
|
return dotenv_values(env_path)
|
|
return {}
|
|
|
|
|
|
def find_python_interpreter() -> Path:
|
|
# Find the Python interpreter in the virtual environment
|
|
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
|
|
|
|
if is_uv_installed():
|
|
return {
|
|
"command": "uv",
|
|
"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) as f:
|
|
config = json.load(f)
|
|
|
|
# Add or update MCP servers configuration
|
|
if "mcpServers" not in config:
|
|
config["mcpServers"] = {}
|
|
|
|
# Claude Desktop uses stdio transport
|
|
config["mcpServers"][server_name] = get_stdio_config(entrypoint_file, server_name)
|
|
|
|
# Write updated config
|
|
with open(config_path, "w") 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 = config_path.as_posix().replace(" ", "\\ ")
|
|
console.print(f" MCP client config file: {config_file_path}", style="dim")
|
|
console.print(f" Server file: {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",
|
|
}
|
|
|
|
config_path = config_path or get_cursor_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) as f:
|
|
config = json.load(f)
|
|
|
|
# Add or update MCP servers configuration
|
|
if "mcpServers" not in config:
|
|
config["mcpServers"] = {}
|
|
|
|
config["mcpServers"][server_name] = (
|
|
get_stdio_config(entrypoint_file, server_name)
|
|
if transport == "stdio"
|
|
else http_config(server_name, port)
|
|
)
|
|
|
|
# Write updated config
|
|
with open(config_path, "w") as f:
|
|
json.dump(config, f, indent=2)
|
|
|
|
console.print(
|
|
f"✅ Configured Cursor by adding local MCP server '{server_name}' to the configuration",
|
|
style="green",
|
|
)
|
|
config_file_path = config_path.as_posix().replace(" ", "\\ ")
|
|
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 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) as f:
|
|
try:
|
|
config = json.load(f)
|
|
except json.JSONDecodeError as e:
|
|
raise ValueError(
|
|
f"\n\tFailed to load MCP configuration file at {config_path.as_posix()} "
|
|
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"] = {}
|
|
|
|
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") 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 = config_path.as_posix().replace(" ", "\\ ")
|
|
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 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."
|
|
)
|