fix: claude, provide more options and remove apikey auth (#825)
<!-- 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 -->
This commit is contained in:
parent
8f5d0ff54e
commit
40e05af27c
5 changed files with 1628 additions and 187 deletions
|
|
@ -1,12 +1,15 @@
|
|||
"""Connect command for configuring MCP clients."""
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
|
@ -117,6 +120,79 @@ def _warn_overwrite(config: dict, section: str, server_name: str, config_path: P
|
|||
)
|
||||
|
||||
|
||||
def _backup_path(path: Path) -> Path:
|
||||
"""Return the ``.bak`` sibling used to back up ``path``.
|
||||
|
||||
We append ``.bak`` to the full filename rather than replacing the
|
||||
extension so ``.claude.json`` → ``.claude.json.bak`` (not ``.claude.bak``).
|
||||
"""
|
||||
return path.parent / f"{path.name}.bak"
|
||||
|
||||
|
||||
def _write_backup_if_exists(path: Path) -> Path | None:
|
||||
"""If ``path`` exists, copy its current contents to ``<path>.bak``.
|
||||
|
||||
Returns the backup path (or ``None`` if no backup was made). Overwrites any
|
||||
previous ``.bak`` — we keep exactly one backup, the one from immediately
|
||||
before this write. The backup is created at mode 0600 regardless of the
|
||||
source's permissions, because these files may contain bearer tokens.
|
||||
"""
|
||||
if not path.exists():
|
||||
return None
|
||||
bak = _backup_path(path)
|
||||
shutil.copyfile(path, bak)
|
||||
if os.name != "nt":
|
||||
os.chmod(bak, stat.S_IRUSR | stat.S_IWUSR) # 0600
|
||||
return bak
|
||||
|
||||
|
||||
def _atomic_write_text(path: Path, content: str) -> None:
|
||||
"""Write ``content`` to ``path`` atomically, preserving a ``.bak`` backup.
|
||||
|
||||
A crash mid-write to a user config file (e.g. ``~/.claude.json``, which
|
||||
also holds project state and OAuth data) would corrupt unrelated content.
|
||||
``tempfile + os.replace`` guarantees that either the old file remains or
|
||||
the new file is fully present — never a half-written file. On top of that,
|
||||
we write the previous file contents to ``<path>.bak`` *before* the rename
|
||||
so the user always has a local copy of the last-known-good config.
|
||||
|
||||
Permissions: ``tempfile.mkstemp`` creates the temp file at mode 0600, so
|
||||
the final file ends up at 0600. That is strictly better for files that
|
||||
hold bearer tokens; if the target already existed with more permissive
|
||||
bits, we intentionally tighten them (and the ``.bak`` too).
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
_write_backup_if_exists(path)
|
||||
fd, tmp_str = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent))
|
||||
tmp_path = Path(tmp_str)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
f.flush()
|
||||
# Some filesystems (network mounts, certain tmpfs variants) don't
|
||||
# support fsync; the subsequent os.replace is still atomic.
|
||||
with contextlib.suppress(OSError):
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, path)
|
||||
except BaseException:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _atomic_write_json(path: Path, data: dict) -> None:
|
||||
"""Serialize ``data`` as JSON (indent=2) and atomically write to ``path``."""
|
||||
_atomic_write_text(path, json.dumps(data, indent=2))
|
||||
|
||||
|
||||
def get_claude_code_config_path() -> Path:
|
||||
"""Get the Claude Code configuration file path.
|
||||
|
||||
Claude Code (the CLI / IDE extension) stores its config at ``~/.claude.json``
|
||||
with a top-level ``mcpServers`` map for user-scope MCP servers.
|
||||
"""
|
||||
return Path.home() / ".claude.json"
|
||||
|
||||
|
||||
def get_claude_config_path() -> Path:
|
||||
"""Get the Claude Desktop configuration file path."""
|
||||
system = platform.system()
|
||||
|
|
@ -209,6 +285,26 @@ def get_amazonq_config_path() -> Path:
|
|||
return Path.home() / ".aws" / "amazonq" / "mcp.json"
|
||||
|
||||
|
||||
def get_codex_config_path() -> Path:
|
||||
"""Get the Codex CLI (OpenAI) configuration file path."""
|
||||
return Path.home() / ".codex" / "config.toml"
|
||||
|
||||
|
||||
def get_opencode_config_path() -> Path:
|
||||
"""Get the OpenCode configuration file path (user-scope).
|
||||
|
||||
Honors ``XDG_CONFIG_HOME`` when set; otherwise defaults to ``~/.config``.
|
||||
"""
|
||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||
base = Path(xdg) if xdg else Path.home() / ".config"
|
||||
return base / "opencode" / "opencode.json"
|
||||
|
||||
|
||||
def get_gemini_config_path() -> Path:
|
||||
"""Get the Gemini CLI (Google) configuration file path (user-scope)."""
|
||||
return Path.home() / ".gemini" / "settings.json"
|
||||
|
||||
|
||||
def is_uv_installed() -> bool:
|
||||
"""Check if uv is installed and available in PATH."""
|
||||
return shutil.which("uv") is not None
|
||||
|
|
@ -318,8 +414,7 @@ def configure_claude_local(
|
|||
config["mcpServers"][server_name] = get_stdio_config(entrypoint_file, server_name)
|
||||
|
||||
# Write updated config
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
_atomic_write_json(config_path, config)
|
||||
|
||||
console.print(
|
||||
f"✅ Configured Claude Desktop by adding local MCP server '{server_name}' to the configuration",
|
||||
|
|
@ -370,8 +465,7 @@ def _configure_mcpservers_arcade(
|
|||
entry["headers"] = {"Authorization": f"Bearer {auth_token}"}
|
||||
config["mcpServers"][server_name] = entry
|
||||
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
_atomic_write_json(config_path, config)
|
||||
|
||||
console.print(f"[green]Configured {display_name} with Arcade gateway '{server_name}'[/green]")
|
||||
console.print(f" Gateway URL: {gateway_url}", style="dim")
|
||||
|
|
@ -379,20 +473,44 @@ def _configure_mcpservers_arcade(
|
|||
console.print(f" Restart {display_name} for changes to take effect.", style="yellow")
|
||||
|
||||
|
||||
def configure_claude_arcade(
|
||||
def configure_claude_code_arcade(
|
||||
server_name: str,
|
||||
gateway_url: str,
|
||||
auth_token: str | None = None,
|
||||
config_path: Path | None = None,
|
||||
) -> None:
|
||||
"""Configure Claude Desktop to connect to an Arcade Cloud MCP gateway."""
|
||||
_configure_mcpservers_arcade(
|
||||
server_name,
|
||||
gateway_url,
|
||||
auth_token,
|
||||
config_path or get_claude_config_path(),
|
||||
"Claude Desktop",
|
||||
)
|
||||
"""Configure Claude Code to connect to an Arcade Cloud MCP gateway.
|
||||
|
||||
Writes to ``~/.claude.json`` (user-scope). The file contains many other
|
||||
Claude Code settings — everything outside ``mcpServers`` is preserved.
|
||||
"""
|
||||
resolved_path = config_path or get_claude_code_config_path()
|
||||
if not resolved_path.is_absolute():
|
||||
resolved_path = Path.cwd() / resolved_path
|
||||
|
||||
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config: dict = {}
|
||||
if resolved_path.exists():
|
||||
with open(resolved_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
if "mcpServers" not in config:
|
||||
config["mcpServers"] = {}
|
||||
|
||||
_warn_overwrite(config, "mcpServers", server_name, resolved_path)
|
||||
|
||||
entry: dict = {"type": "http", "url": gateway_url}
|
||||
if auth_token:
|
||||
entry["headers"] = {"Authorization": f"Bearer {auth_token}"}
|
||||
config["mcpServers"][server_name] = entry
|
||||
|
||||
_atomic_write_json(resolved_path, config)
|
||||
|
||||
console.print(f"[green]Configured Claude Code with Arcade gateway '{server_name}'[/green]")
|
||||
console.print(f" Gateway URL: {gateway_url}", style="dim")
|
||||
console.print(f" Config file: {_format_path_for_display(resolved_path)}", style="dim")
|
||||
console.print(" Restart Claude Code for changes to take effect.", style="yellow")
|
||||
|
||||
|
||||
def configure_cursor_local(
|
||||
|
|
@ -449,8 +567,7 @@ def configure_cursor_local(
|
|||
config["mcpServers"][server_name] = server_config
|
||||
|
||||
# Write updated config
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
_atomic_write_json(config_path, config)
|
||||
|
||||
primary_config_path = resolved_target_paths[0]
|
||||
|
||||
|
|
@ -498,7 +615,10 @@ def configure_cursor_arcade(
|
|||
for path in target_paths:
|
||||
resolved_target_paths.append(path if path.is_absolute() else Path.cwd() / path)
|
||||
|
||||
server_config: dict = {"type": "sse", "url": gateway_url}
|
||||
# Cursor's docs don't show a "type" field for remote entries — a bare
|
||||
# ``url`` (plus optional ``headers``) is the documented shape. Writing
|
||||
# "type": "sse" on an HTTP gateway would mislabel the transport.
|
||||
server_config: dict = {"url": gateway_url}
|
||||
if auth_token:
|
||||
server_config["headers"] = {"Authorization": f"Bearer {auth_token}"}
|
||||
|
||||
|
|
@ -518,8 +638,7 @@ def configure_cursor_arcade(
|
|||
|
||||
config["mcpServers"][server_name] = server_config
|
||||
|
||||
with open(target, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
_atomic_write_json(target, config)
|
||||
|
||||
primary_config_path = resolved_target_paths[0]
|
||||
console.print(f"[green]Configured Cursor with Arcade gateway '{server_name}'[/green]")
|
||||
|
|
@ -579,8 +698,7 @@ def configure_vscode_local(
|
|||
)
|
||||
|
||||
# Write updated config
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
_atomic_write_json(config_path, config)
|
||||
|
||||
console.print(
|
||||
f"✅ Configured VS Code by adding local MCP server '{server_name}' to the configuration",
|
||||
|
|
@ -633,8 +751,7 @@ def configure_vscode_arcade(
|
|||
entry["headers"] = {"Authorization": f"Bearer {auth_token}"}
|
||||
config["servers"][server_name] = entry
|
||||
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
_atomic_write_json(config_path, config)
|
||||
|
||||
console.print(f"[green]Configured VS Code with Arcade gateway '{server_name}'[/green]")
|
||||
console.print(f" Gateway URL: {gateway_url}", style="dim")
|
||||
|
|
@ -648,7 +765,11 @@ def configure_windsurf_arcade(
|
|||
auth_token: str | None = None,
|
||||
config_path: Path | None = None,
|
||||
) -> None:
|
||||
"""Configure Windsurf to connect to an Arcade Cloud MCP gateway."""
|
||||
"""Configure Windsurf to connect to an Arcade Cloud MCP gateway.
|
||||
|
||||
Windsurf's docs show remote HTTP servers as ``{"serverUrl": ..., "headers": ...}``
|
||||
(``url`` is also accepted as an alias). No ``type`` field is required.
|
||||
"""
|
||||
_configure_mcpservers_arcade(
|
||||
server_name, gateway_url, auth_token, config_path or get_windsurf_config_path(), "Windsurf"
|
||||
)
|
||||
|
|
@ -660,10 +781,191 @@ def configure_amazonq_arcade(
|
|||
auth_token: str | None = None,
|
||||
config_path: Path | None = None,
|
||||
) -> None:
|
||||
"""Configure Amazon Q Developer to connect to an Arcade Cloud MCP gateway."""
|
||||
_configure_mcpservers_arcade(
|
||||
server_name, gateway_url, auth_token, config_path or get_amazonq_config_path(), "Amazon Q"
|
||||
"""Configure Amazon Q Developer to connect to an Arcade Cloud MCP gateway.
|
||||
|
||||
Amazon Q requires an explicit ``"type": "http"`` on remote entries — without
|
||||
it the CLI treats the entry as a malformed stdio server. See
|
||||
https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-mcp-config-CLI.html
|
||||
"""
|
||||
resolved_path = config_path or get_amazonq_config_path()
|
||||
if not resolved_path.is_absolute():
|
||||
resolved_path = Path.cwd() / resolved_path
|
||||
|
||||
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config: dict = {}
|
||||
if resolved_path.exists():
|
||||
with open(resolved_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
if "mcpServers" not in config:
|
||||
config["mcpServers"] = {}
|
||||
|
||||
_warn_overwrite(config, "mcpServers", server_name, resolved_path)
|
||||
|
||||
entry: dict = {"type": "http", "url": gateway_url}
|
||||
if auth_token:
|
||||
entry["headers"] = {"Authorization": f"Bearer {auth_token}"}
|
||||
config["mcpServers"][server_name] = entry
|
||||
|
||||
_atomic_write_json(resolved_path, config)
|
||||
|
||||
console.print(f"[green]Configured Amazon Q with Arcade gateway '{server_name}'[/green]")
|
||||
console.print(f" Gateway URL: {gateway_url}", style="dim")
|
||||
console.print(f" Config file: {_format_path_for_display(resolved_path)}", style="dim")
|
||||
console.print(" Restart Amazon Q for changes to take effect.", style="yellow")
|
||||
|
||||
|
||||
def _toml_str(value: str) -> str:
|
||||
"""Escape a string for a TOML basic string literal."""
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def _upsert_codex_mcp_server(text: str, server_name: str, entries: dict[str, str]) -> str:
|
||||
"""Insert or replace a ``[mcp_servers.<name>]`` section in Codex's ``config.toml``.
|
||||
|
||||
Preserves all other content (including comments, formatting, and other
|
||||
table sections). If the section already exists it is replaced in place;
|
||||
otherwise it is appended at the end of the file.
|
||||
|
||||
``entries`` maps TOML keys to string values. Only string-typed values are
|
||||
supported — Codex's ``mcp_servers`` schema accepts ``url`` and
|
||||
``bearer_token`` / ``bearer_token_env_var`` as strings, which is all we
|
||||
need here.
|
||||
"""
|
||||
body_lines = [f"{key} = {_toml_str(value)}" for key, value in entries.items()]
|
||||
new_section = f"[mcp_servers.{server_name}]\n" + "\n".join(body_lines) + "\n"
|
||||
|
||||
# Match the header line and any following body lines up until the next
|
||||
# table header (``[...]``). Safe assumption: nothing inside the body
|
||||
# starts a new table, since TOML table headers always begin at column 0.
|
||||
pattern = re.compile(
|
||||
rf"^\[mcp_servers\.{re.escape(server_name)}\][^\n]*\n(?:(?!^\[)[^\n]*\n)*",
|
||||
re.MULTILINE,
|
||||
)
|
||||
match = pattern.search(text)
|
||||
if match:
|
||||
return text[: match.start()] + new_section + text[match.end() :]
|
||||
|
||||
if text and not text.endswith("\n"):
|
||||
text += "\n"
|
||||
if text and not text.endswith("\n\n"):
|
||||
text += "\n"
|
||||
return text + new_section
|
||||
|
||||
|
||||
def configure_codex_arcade(
|
||||
server_name: str,
|
||||
gateway_url: str,
|
||||
auth_token: str | None = None,
|
||||
config_path: Path | None = None,
|
||||
) -> None:
|
||||
"""Configure Codex CLI to connect to an Arcade Cloud MCP gateway.
|
||||
|
||||
Writes a ``[mcp_servers.<name>]`` section to ``~/.codex/config.toml``.
|
||||
Codex supports streamable HTTP natively via the ``url`` key and an inline
|
||||
``bearer_token`` for auth.
|
||||
"""
|
||||
resolved_path = config_path or get_codex_config_path()
|
||||
if not resolved_path.is_absolute():
|
||||
resolved_path = Path.cwd() / resolved_path
|
||||
|
||||
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
existing = resolved_path.read_text(encoding="utf-8") if resolved_path.exists() else ""
|
||||
|
||||
entries: dict[str, str] = {"url": gateway_url}
|
||||
if auth_token:
|
||||
entries["bearer_token"] = auth_token
|
||||
|
||||
updated = _upsert_codex_mcp_server(existing, server_name, entries)
|
||||
_atomic_write_text(resolved_path, updated)
|
||||
|
||||
console.print(f"[green]Configured Codex CLI with Arcade gateway '{server_name}'[/green]")
|
||||
console.print(f" Gateway URL: {gateway_url}", style="dim")
|
||||
console.print(f" Config file: {_format_path_for_display(resolved_path)}", style="dim")
|
||||
console.print(" Restart Codex for changes to take effect.", style="yellow")
|
||||
|
||||
|
||||
def configure_opencode_arcade(
|
||||
server_name: str,
|
||||
gateway_url: str,
|
||||
auth_token: str | None = None,
|
||||
config_path: Path | None = None,
|
||||
) -> None:
|
||||
"""Configure OpenCode to connect to an Arcade Cloud MCP gateway.
|
||||
|
||||
Writes to the ``mcp`` map in ``~/.config/opencode/opencode.json`` using the
|
||||
``{"type": "remote", "url": ...}`` shape.
|
||||
"""
|
||||
resolved_path = config_path or get_opencode_config_path()
|
||||
if not resolved_path.is_absolute():
|
||||
resolved_path = Path.cwd() / resolved_path
|
||||
|
||||
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config: dict = {}
|
||||
if resolved_path.exists():
|
||||
with open(resolved_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
if "mcp" not in config:
|
||||
config["mcp"] = {}
|
||||
|
||||
_warn_overwrite(config, "mcp", server_name, resolved_path)
|
||||
|
||||
entry: dict = {"type": "remote", "url": gateway_url, "enabled": True}
|
||||
if auth_token:
|
||||
entry["headers"] = {"Authorization": f"Bearer {auth_token}"}
|
||||
config["mcp"][server_name] = entry
|
||||
|
||||
_atomic_write_json(resolved_path, config)
|
||||
|
||||
console.print(f"[green]Configured OpenCode with Arcade gateway '{server_name}'[/green]")
|
||||
console.print(f" Gateway URL: {gateway_url}", style="dim")
|
||||
console.print(f" Config file: {_format_path_for_display(resolved_path)}", style="dim")
|
||||
console.print(" Restart OpenCode for changes to take effect.", style="yellow")
|
||||
|
||||
|
||||
def configure_gemini_arcade(
|
||||
server_name: str,
|
||||
gateway_url: str,
|
||||
auth_token: str | None = None,
|
||||
config_path: Path | None = None,
|
||||
) -> None:
|
||||
"""Configure Gemini CLI to connect to an Arcade Cloud MCP gateway.
|
||||
|
||||
Writes to ``~/.gemini/settings.json`` using the ``mcpServers`` map with
|
||||
the ``httpUrl`` key (Gemini CLI's field name for streamable HTTP servers).
|
||||
"""
|
||||
resolved_path = config_path or get_gemini_config_path()
|
||||
if not resolved_path.is_absolute():
|
||||
resolved_path = Path.cwd() / resolved_path
|
||||
|
||||
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config: dict = {}
|
||||
if resolved_path.exists():
|
||||
with open(resolved_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
if "mcpServers" not in config:
|
||||
config["mcpServers"] = {}
|
||||
|
||||
_warn_overwrite(config, "mcpServers", server_name, resolved_path)
|
||||
|
||||
entry: dict = {"httpUrl": gateway_url}
|
||||
if auth_token:
|
||||
entry["headers"] = {"Authorization": f"Bearer {auth_token}"}
|
||||
config["mcpServers"][server_name] = entry
|
||||
|
||||
_atomic_write_json(resolved_path, config)
|
||||
|
||||
console.print(f"[green]Configured Gemini CLI with Arcade gateway '{server_name}'[/green]")
|
||||
console.print(f" Gateway URL: {gateway_url}", style="dim")
|
||||
console.print(f" Config file: {_format_path_for_display(resolved_path)}", style="dim")
|
||||
console.print(" Restart Gemini CLI for changes to take effect.", style="yellow")
|
||||
|
||||
|
||||
def get_toolkit_stdio_config(tool_packages: list[str], server_name: str) -> dict:
|
||||
|
|
@ -733,11 +1035,14 @@ def configure_client_gateway(
|
|||
"""
|
||||
client_lower = client.lower()
|
||||
dispatch = {
|
||||
"claude": configure_claude_arcade,
|
||||
"claude-code": configure_claude_code_arcade,
|
||||
"cursor": configure_cursor_arcade,
|
||||
"vscode": configure_vscode_arcade,
|
||||
"windsurf": configure_windsurf_arcade,
|
||||
"amazonq": configure_amazonq_arcade,
|
||||
"codex": configure_codex_arcade,
|
||||
"opencode": configure_opencode_arcade,
|
||||
"gemini": configure_gemini_arcade,
|
||||
}
|
||||
func = dispatch.get(client_lower)
|
||||
if not func:
|
||||
|
|
@ -784,8 +1089,7 @@ def configure_client_toolkit(
|
|||
config["mcpServers"] = {}
|
||||
_warn_overwrite(config, "mcpServers", server_name, _config_path)
|
||||
config["mcpServers"][server_name] = server_config
|
||||
with open(_config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
_atomic_write_json(_config_path, config)
|
||||
|
||||
console.print(
|
||||
f"[green]Configured Claude Desktop with Arcade toolkits: {', '.join(tool_packages)}[/green]"
|
||||
|
|
@ -817,8 +1121,7 @@ def configure_client_toolkit(
|
|||
if idx == 0:
|
||||
_warn_overwrite(config, "mcpServers", server_name, target)
|
||||
config["mcpServers"][server_name] = server_config
|
||||
with open(target, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
_atomic_write_json(target, config)
|
||||
|
||||
console.print(
|
||||
f"[green]Configured Cursor with Arcade toolkits: {', '.join(tool_packages)}[/green]"
|
||||
|
|
@ -847,8 +1150,7 @@ def configure_client_toolkit(
|
|||
config["servers"] = {}
|
||||
_warn_overwrite(config, "servers", server_name, _config_path)
|
||||
config["servers"][server_name] = server_config
|
||||
with open(_config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
_atomic_write_json(_config_path, config)
|
||||
|
||||
console.print(
|
||||
f"[green]Configured VS Code with Arcade toolkits: {', '.join(tool_packages)}[/green]"
|
||||
|
|
@ -874,8 +1176,7 @@ def configure_client_toolkit(
|
|||
config["mcpServers"] = {}
|
||||
_warn_overwrite(config, "mcpServers", server_name, _config_path)
|
||||
config["mcpServers"][server_name] = server_config
|
||||
with open(_config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
_atomic_write_json(_config_path, config)
|
||||
|
||||
console.print(
|
||||
f"[green]Configured {display} with Arcade toolkits: {', '.join(tool_packages)}[/green]"
|
||||
|
|
@ -931,7 +1232,7 @@ def configure_client(
|
|||
if host == "arcade":
|
||||
console.print(
|
||||
"Use [bold]arcade connect[/bold] to connect to Arcade Cloud gateways.\n"
|
||||
"Example: [bold]arcade connect claude --gateway my-gateway[/bold]",
|
||||
"Example: [bold]arcade connect claude-code --gateway my-gateway[/bold]",
|
||||
style="yellow",
|
||||
)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -416,46 +416,6 @@ def create_gateway(
|
|||
return data
|
||||
|
||||
|
||||
def create_project_api_key(
|
||||
access_token: str,
|
||||
label: str = "quickstart",
|
||||
debug: bool = False,
|
||||
) -> str:
|
||||
"""Create a project API key via the Coordinator.
|
||||
|
||||
Calls ``POST /v1/projects/{project_id}/api_keys``.
|
||||
|
||||
Returns the raw API key string (``arc_...``).
|
||||
"""
|
||||
from arcade_cli.utils import get_org_project_context
|
||||
|
||||
_, project_id = get_org_project_context()
|
||||
coordinator_url = f"https://{PROD_COORDINATOR_HOST}"
|
||||
endpoint = f"{coordinator_url}/v1/projects/{project_id}/api_keys"
|
||||
|
||||
if debug:
|
||||
console.print(f" [dim]POST {endpoint}[/dim]")
|
||||
|
||||
resp = httpx.post(
|
||||
endpoint,
|
||||
json={"label": label},
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if debug:
|
||||
console.print(f" [dim]Response: {resp.status_code}[/dim]")
|
||||
console.print(f" [dim]{resp.text[:300]}[/dim]")
|
||||
|
||||
if resp.status_code not in (200, 201):
|
||||
raise RuntimeError(f"Failed to create API key ({resp.status_code}): {resp.text}")
|
||||
|
||||
return resp.json()["api_key"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive selection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -552,7 +512,6 @@ def run_connect(
|
|||
tools: list[str] | None = None,
|
||||
gateway: str | None = None,
|
||||
all_tools: bool = False,
|
||||
use_api_key: bool = False,
|
||||
gateway_slug: str | None = None,
|
||||
config_path: Path | None = None,
|
||||
debug: bool = False,
|
||||
|
|
@ -575,12 +534,7 @@ def run_connect(
|
|||
# --- Direct gateway mode (existing gateway) ---
|
||||
# Resolve the input: user may pass a name ("opencode") or slug ("pascal_opencode")
|
||||
slug = _resolve_gateway_slug(gateway, access_token, debug=debug)
|
||||
api_key: str | None = None
|
||||
if use_api_key:
|
||||
console.print("Creating project API key...", style="dim")
|
||||
api_key = create_project_api_key(access_token, label=f"connect-{slug}", debug=debug)
|
||||
console.print(" API key created.", style="green")
|
||||
_configure_gateway(client, slug, config_path, api_key=api_key, name=gateway)
|
||||
_configure_gateway(client, slug, config_path, name=gateway)
|
||||
return
|
||||
|
||||
# --- Toolkit / tool → gateway mode ---
|
||||
|
|
@ -615,7 +569,7 @@ def run_connect(
|
|||
if not available:
|
||||
console.print(
|
||||
"No toolkits found in your account. You can specify toolkits manually:\n"
|
||||
" [bold]arcade connect claude --toolkit github[/bold]",
|
||||
" [bold]arcade connect claude-code --toolkit github[/bold]",
|
||||
style="yellow",
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
|
@ -669,7 +623,7 @@ def run_connect(
|
|||
raise SystemExit(1)
|
||||
|
||||
# Check if an existing gateway already covers these tools
|
||||
auth_type = "arcade_header" if use_api_key else "arcade"
|
||||
auth_type = "arcade"
|
||||
console.print("Checking existing gateways...", style="dim")
|
||||
existing_gateways = list_gateways(access_token, debug=debug)
|
||||
existing = find_matching_gateway(
|
||||
|
|
@ -708,13 +662,6 @@ def run_connect(
|
|||
console.print(f" [dim]Gateway response: id={gw.get('id')}, slug={slug}[/dim]")
|
||||
console.print(f" Gateway created: [bold]{slug}[/bold]\n", style="green")
|
||||
|
||||
# For API-key auth, create a project key and include it in the config
|
||||
api_key: str | None = None
|
||||
if use_api_key:
|
||||
console.print("Creating project API key...", style="dim")
|
||||
api_key = create_project_api_key(access_token, label=f"connect-{slug}", debug=debug)
|
||||
console.print(" API key created.", style="green")
|
||||
|
||||
# Config key: prefer --slug if given, otherwise derive from toolkit names
|
||||
if gateway_slug:
|
||||
display_name = gateway_slug
|
||||
|
|
@ -722,7 +669,7 @@ def run_connect(
|
|||
display_name = selected_toolkits[0].lower()
|
||||
else:
|
||||
display_name = "-".join(sorted({tk.lower() for tk in selected_toolkits}))
|
||||
_configure_gateway(client, slug, config_path, api_key=api_key, name=display_name)
|
||||
_configure_gateway(client, slug, config_path, name=display_name)
|
||||
|
||||
# Print examples
|
||||
examples = get_toolkit_examples(selected_toolkits)
|
||||
|
|
@ -765,7 +712,6 @@ def _configure_gateway(
|
|||
client: str,
|
||||
slug: str,
|
||||
config_path: Path | None,
|
||||
api_key: str | None = None,
|
||||
name: str | None = None,
|
||||
) -> None:
|
||||
"""Configure the MCP client to connect to a gateway by slug.
|
||||
|
|
@ -786,13 +732,10 @@ def _configure_gateway(
|
|||
client=client,
|
||||
server_name=server_name,
|
||||
gateway_url=gateway_url,
|
||||
auth_token=api_key,
|
||||
auth_token=None,
|
||||
config_path=config_path,
|
||||
)
|
||||
|
||||
console.print("\n[bold green]Setup complete![/bold green]")
|
||||
console.print(f" Gateway URL: {gateway_url}", style="dim")
|
||||
if api_key:
|
||||
console.print(" Auth: API key (included in config)", style="dim")
|
||||
else:
|
||||
console.print(" Auth: OAuth (handled by your MCP client)", style="dim")
|
||||
console.print(" Auth: OAuth (handled by your MCP client)", style="dim")
|
||||
|
|
|
|||
|
|
@ -793,7 +793,16 @@ def connect(
|
|||
...,
|
||||
help="MCP client to connect to the remote gateway",
|
||||
click_type=click.Choice(
|
||||
["claude", "cursor", "vscode", "windsurf", "amazonq"],
|
||||
[
|
||||
"claude-code",
|
||||
"cursor",
|
||||
"vscode",
|
||||
"windsurf",
|
||||
"amazonq",
|
||||
"codex",
|
||||
"opencode",
|
||||
"gemini",
|
||||
],
|
||||
case_sensitive=False,
|
||||
),
|
||||
show_choices=True,
|
||||
|
|
@ -831,11 +840,6 @@ def connect(
|
|||
"-s",
|
||||
help="Custom slug for the created gateway (only with --server/--tool/--preset).",
|
||||
),
|
||||
api_key: bool = typer.Option(
|
||||
False,
|
||||
"--api-key",
|
||||
help="Use API-key auth instead of OAuth. Creates a project API key and includes it in the client config.",
|
||||
),
|
||||
config_path: Optional[Path] = typer.Option(
|
||||
None,
|
||||
"--config",
|
||||
|
|
@ -852,17 +856,15 @@ def connect(
|
|||
creates an Arcade Cloud gateway for the selected toolkits, and writes your
|
||||
MCP client config, all in one step.
|
||||
|
||||
By default gateways use OAuth (the MCP client handles the auth flow).
|
||||
Pass --api-key to use API-key auth instead (creates a key automatically).
|
||||
Gateways use OAuth; the MCP client handles the auth flow.
|
||||
|
||||
To configure a local server on your filesystem instead, use 'arcade configure'.
|
||||
|
||||
Examples:\n
|
||||
arcade connect claude --server github\n
|
||||
arcade connect claude-code --server github\n
|
||||
arcade connect cursor --preset productivity\n
|
||||
arcade connect claude --tool Github.CreateIssue --tool Linear.UpdateIssue\n
|
||||
arcade connect claude --gateway my-existing-gw\n
|
||||
arcade connect vscode --all --api-key\n
|
||||
arcade connect claude-code --tool Github.CreateIssue --tool Linear.UpdateIssue\n
|
||||
arcade connect claude-code --gateway my-existing-gw\n
|
||||
"""
|
||||
from arcade_cli.connect import PRESET_BUNDLES, run_connect
|
||||
|
||||
|
|
@ -884,7 +886,6 @@ def connect(
|
|||
tools=list(tool) if tool else None,
|
||||
gateway=gateway,
|
||||
all_tools=all_tools,
|
||||
use_api_key=api_key,
|
||||
gateway_slug=slug,
|
||||
config_path=config_path,
|
||||
debug=debug,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -359,7 +359,7 @@ class TestRunConnectToolOnly:
|
|||
patch("arcade_cli.configure.console"),
|
||||
):
|
||||
run_connect(
|
||||
client="claude",
|
||||
client="claude-code",
|
||||
tools=["Github.CreateIssue", "Slack.SendMessage"],
|
||||
config_path=config_path,
|
||||
)
|
||||
|
|
@ -397,7 +397,7 @@ class TestRunConnectGateway:
|
|||
patch("arcade_cli.configure.console"),
|
||||
):
|
||||
run_connect(
|
||||
client="claude",
|
||||
client="claude-code",
|
||||
gateway="my-production-gw",
|
||||
config_path=config_path,
|
||||
)
|
||||
|
|
@ -424,7 +424,7 @@ class TestRunConnectGateway:
|
|||
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
entry = config["mcpServers"]["test-gw"]
|
||||
assert entry["type"] == "sse"
|
||||
assert "type" not in entry # cursor docs show no "type" field
|
||||
assert "api.arcade.dev/mcp/test-gw" in entry["url"]
|
||||
|
||||
def test_gateway_mode_configures_vscode(self, tmp_path: Path) -> None:
|
||||
|
|
@ -472,7 +472,7 @@ class TestRunConnectToolkit:
|
|||
patch("arcade_cli.configure.console"),
|
||||
):
|
||||
run_connect(
|
||||
client="claude",
|
||||
client="claude-code",
|
||||
toolkits=["github"],
|
||||
config_path=config_path,
|
||||
)
|
||||
|
|
@ -516,7 +516,7 @@ class TestRunConnectToolkit:
|
|||
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
entry = config["mcpServers"]["github-slack"]
|
||||
assert entry["type"] == "sse"
|
||||
assert "type" not in entry # cursor docs show no "type" field
|
||||
assert "api.arcade.dev/mcp/github-slack" in entry["url"]
|
||||
|
||||
|
||||
|
|
@ -547,7 +547,7 @@ class TestRunConnectInteractive:
|
|||
patch("arcade_cli.configure.console"),
|
||||
):
|
||||
run_connect(
|
||||
client="claude",
|
||||
client="claude-code",
|
||||
all_tools=True,
|
||||
config_path=config_path,
|
||||
)
|
||||
|
|
@ -565,7 +565,7 @@ class TestRunConnectInteractive:
|
|||
patch("arcade_cli.connect.console"),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
run_connect(client="claude", all_tools=True)
|
||||
run_connect(client="claude-code", all_tools=True)
|
||||
|
||||
def test_toolkit_not_found_exits(self) -> None:
|
||||
with (
|
||||
|
|
@ -575,40 +575,7 @@ class TestRunConnectInteractive:
|
|||
patch("arcade_cli.connect.console"),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
run_connect(client="claude", toolkits=["nonexistent"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_project_api_key
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateProjectApiKey:
|
||||
@patch("arcade_cli.connect.httpx.post")
|
||||
@patch("arcade_cli.utils.get_org_project_context", return_value=("org1", "proj1"))
|
||||
def test_returns_api_key(self, _ctx: MagicMock, mock_post: MagicMock) -> None:
|
||||
from arcade_cli.connect import create_project_api_key
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 201
|
||||
mock_resp.json.return_value = {"api_key": "arc_test123"}
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
result = create_project_api_key("tok", label="test")
|
||||
assert result == "arc_test123"
|
||||
|
||||
@patch("arcade_cli.connect.httpx.post")
|
||||
@patch("arcade_cli.utils.get_org_project_context", return_value=("org1", "proj1"))
|
||||
def test_raises_on_error(self, _ctx: MagicMock, mock_post: MagicMock) -> None:
|
||||
from arcade_cli.connect import create_project_api_key
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 403
|
||||
mock_resp.text = "forbidden"
|
||||
mock_post.return_value = mock_resp
|
||||
|
||||
with pytest.raises(RuntimeError, match="403"):
|
||||
create_project_api_key("tok")
|
||||
run_connect(client="claude-code", toolkits=["nonexistent"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -681,7 +648,7 @@ class TestRunConnectAdvanced:
|
|||
patch("arcade_cli.configure.console"),
|
||||
):
|
||||
run_connect(
|
||||
client="claude",
|
||||
client="claude-code",
|
||||
toolkits=["github"],
|
||||
config_path=config_path,
|
||||
)
|
||||
|
|
@ -690,27 +657,6 @@ class TestRunConnectAdvanced:
|
|||
entry = config["mcpServers"]["github"]
|
||||
assert "existing-gw" in entry["url"]
|
||||
|
||||
def test_gateway_with_api_key(self, tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "claude.json"
|
||||
|
||||
with (
|
||||
patch("arcade_cli.connect.ensure_login", return_value="tok_abc"),
|
||||
_mock_resolve_slug(),
|
||||
patch("arcade_cli.connect.create_project_api_key", return_value="arc_key123"),
|
||||
patch("arcade_cli.connect.console"),
|
||||
patch("arcade_cli.configure.console"),
|
||||
):
|
||||
run_connect(
|
||||
client="claude",
|
||||
gateway="my-gw",
|
||||
use_api_key=True,
|
||||
config_path=config_path,
|
||||
)
|
||||
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
entry = config["mcpServers"]["my-gw"]
|
||||
assert entry["headers"]["Authorization"] == "Bearer arc_key123"
|
||||
|
||||
def test_toolkit_with_custom_slug(self, tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "claude.json"
|
||||
|
||||
|
|
@ -729,7 +675,7 @@ class TestRunConnectAdvanced:
|
|||
patch("arcade_cli.configure.console"),
|
||||
):
|
||||
run_connect(
|
||||
client="claude",
|
||||
client="claude-code",
|
||||
toolkits=["github"],
|
||||
gateway_slug="my-custom",
|
||||
config_path=config_path,
|
||||
|
|
@ -758,7 +704,7 @@ class TestRunConnectAdvanced:
|
|||
patch("arcade_cli.configure.console"),
|
||||
):
|
||||
run_connect(
|
||||
client="claude",
|
||||
client="claude-code",
|
||||
toolkits=["github"],
|
||||
tools=["Slack.SendMessage"],
|
||||
config_path=config_path,
|
||||
|
|
|
|||
Loading…
Reference in a new issue