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:
Pascal Matthiesen 2026-04-24 10:31:28 -07:00 committed by GitHub
parent 8f5d0ff54e
commit 40e05af27c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1628 additions and 187 deletions

View file

@ -1,12 +1,15 @@
"""Connect command for configuring MCP clients.""" """Connect command for configuring MCP clients."""
import contextlib
import json import json
import logging import logging
import os import os
import platform import platform
import re import re
import shutil import shutil
import stat
import subprocess import subprocess
import tempfile
from pathlib import Path from pathlib import Path
import typer 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: def get_claude_config_path() -> Path:
"""Get the Claude Desktop configuration file path.""" """Get the Claude Desktop configuration file path."""
system = platform.system() system = platform.system()
@ -209,6 +285,26 @@ def get_amazonq_config_path() -> Path:
return Path.home() / ".aws" / "amazonq" / "mcp.json" 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: def is_uv_installed() -> bool:
"""Check if uv is installed and available in PATH.""" """Check if uv is installed and available in PATH."""
return shutil.which("uv") is not None 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) config["mcpServers"][server_name] = get_stdio_config(entrypoint_file, server_name)
# Write updated config # Write updated config
with open(config_path, "w", encoding="utf-8") as f: _atomic_write_json(config_path, config)
json.dump(config, f, indent=2)
console.print( console.print(
f"✅ Configured Claude Desktop by adding local MCP server '{server_name}' to the configuration", 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}"} entry["headers"] = {"Authorization": f"Bearer {auth_token}"}
config["mcpServers"][server_name] = entry config["mcpServers"][server_name] = entry
with open(config_path, "w", encoding="utf-8") as f: _atomic_write_json(config_path, config)
json.dump(config, f, indent=2)
console.print(f"[green]Configured {display_name} with Arcade gateway '{server_name}'[/green]") console.print(f"[green]Configured {display_name} with Arcade gateway '{server_name}'[/green]")
console.print(f" Gateway URL: {gateway_url}", style="dim") 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") 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, server_name: str,
gateway_url: str, gateway_url: str,
auth_token: str | None = None, auth_token: str | None = None,
config_path: Path | None = None, config_path: Path | None = None,
) -> None: ) -> None:
"""Configure Claude Desktop to connect to an Arcade Cloud MCP gateway.""" """Configure Claude Code to connect to an Arcade Cloud MCP gateway.
_configure_mcpservers_arcade(
server_name, Writes to ``~/.claude.json`` (user-scope). The file contains many other
gateway_url, Claude Code settings everything outside ``mcpServers`` is preserved.
auth_token, """
config_path or get_claude_config_path(), resolved_path = config_path or get_claude_code_config_path()
"Claude Desktop", 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( def configure_cursor_local(
@ -449,8 +567,7 @@ def configure_cursor_local(
config["mcpServers"][server_name] = server_config config["mcpServers"][server_name] = server_config
# Write updated config # Write updated config
with open(config_path, "w", encoding="utf-8") as f: _atomic_write_json(config_path, config)
json.dump(config, f, indent=2)
primary_config_path = resolved_target_paths[0] primary_config_path = resolved_target_paths[0]
@ -498,7 +615,10 @@ def configure_cursor_arcade(
for path in target_paths: for path in target_paths:
resolved_target_paths.append(path if path.is_absolute() else Path.cwd() / path) 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: if auth_token:
server_config["headers"] = {"Authorization": f"Bearer {auth_token}"} server_config["headers"] = {"Authorization": f"Bearer {auth_token}"}
@ -518,8 +638,7 @@ def configure_cursor_arcade(
config["mcpServers"][server_name] = server_config config["mcpServers"][server_name] = server_config
with open(target, "w", encoding="utf-8") as f: _atomic_write_json(target, config)
json.dump(config, f, indent=2)
primary_config_path = resolved_target_paths[0] primary_config_path = resolved_target_paths[0]
console.print(f"[green]Configured Cursor with Arcade gateway '{server_name}'[/green]") console.print(f"[green]Configured Cursor with Arcade gateway '{server_name}'[/green]")
@ -579,8 +698,7 @@ def configure_vscode_local(
) )
# Write updated config # Write updated config
with open(config_path, "w", encoding="utf-8") as f: _atomic_write_json(config_path, config)
json.dump(config, f, indent=2)
console.print( console.print(
f"✅ Configured VS Code by adding local MCP server '{server_name}' to the configuration", 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}"} entry["headers"] = {"Authorization": f"Bearer {auth_token}"}
config["servers"][server_name] = entry config["servers"][server_name] = entry
with open(config_path, "w", encoding="utf-8") as f: _atomic_write_json(config_path, config)
json.dump(config, f, indent=2)
console.print(f"[green]Configured VS Code with Arcade gateway '{server_name}'[/green]") console.print(f"[green]Configured VS Code with Arcade gateway '{server_name}'[/green]")
console.print(f" Gateway URL: {gateway_url}", style="dim") console.print(f" Gateway URL: {gateway_url}", style="dim")
@ -648,7 +765,11 @@ def configure_windsurf_arcade(
auth_token: str | None = None, auth_token: str | None = None,
config_path: Path | None = None, config_path: Path | None = 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( _configure_mcpservers_arcade(
server_name, gateway_url, auth_token, config_path or get_windsurf_config_path(), "Windsurf" 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, auth_token: str | None = None,
config_path: Path | None = None, config_path: Path | None = None,
) -> None: ) -> None:
"""Configure Amazon Q Developer to connect to an Arcade Cloud MCP gateway.""" """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" 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: 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() client_lower = client.lower()
dispatch = { dispatch = {
"claude": configure_claude_arcade, "claude-code": configure_claude_code_arcade,
"cursor": configure_cursor_arcade, "cursor": configure_cursor_arcade,
"vscode": configure_vscode_arcade, "vscode": configure_vscode_arcade,
"windsurf": configure_windsurf_arcade, "windsurf": configure_windsurf_arcade,
"amazonq": configure_amazonq_arcade, "amazonq": configure_amazonq_arcade,
"codex": configure_codex_arcade,
"opencode": configure_opencode_arcade,
"gemini": configure_gemini_arcade,
} }
func = dispatch.get(client_lower) func = dispatch.get(client_lower)
if not func: if not func:
@ -784,8 +1089,7 @@ def configure_client_toolkit(
config["mcpServers"] = {} config["mcpServers"] = {}
_warn_overwrite(config, "mcpServers", server_name, _config_path) _warn_overwrite(config, "mcpServers", server_name, _config_path)
config["mcpServers"][server_name] = server_config config["mcpServers"][server_name] = server_config
with open(_config_path, "w", encoding="utf-8") as f: _atomic_write_json(_config_path, config)
json.dump(config, f, indent=2)
console.print( console.print(
f"[green]Configured Claude Desktop with Arcade toolkits: {', '.join(tool_packages)}[/green]" f"[green]Configured Claude Desktop with Arcade toolkits: {', '.join(tool_packages)}[/green]"
@ -817,8 +1121,7 @@ def configure_client_toolkit(
if idx == 0: if idx == 0:
_warn_overwrite(config, "mcpServers", server_name, target) _warn_overwrite(config, "mcpServers", server_name, target)
config["mcpServers"][server_name] = server_config config["mcpServers"][server_name] = server_config
with open(target, "w", encoding="utf-8") as f: _atomic_write_json(target, config)
json.dump(config, f, indent=2)
console.print( console.print(
f"[green]Configured Cursor with Arcade toolkits: {', '.join(tool_packages)}[/green]" f"[green]Configured Cursor with Arcade toolkits: {', '.join(tool_packages)}[/green]"
@ -847,8 +1150,7 @@ def configure_client_toolkit(
config["servers"] = {} config["servers"] = {}
_warn_overwrite(config, "servers", server_name, _config_path) _warn_overwrite(config, "servers", server_name, _config_path)
config["servers"][server_name] = server_config config["servers"][server_name] = server_config
with open(_config_path, "w", encoding="utf-8") as f: _atomic_write_json(_config_path, config)
json.dump(config, f, indent=2)
console.print( console.print(
f"[green]Configured VS Code with Arcade toolkits: {', '.join(tool_packages)}[/green]" f"[green]Configured VS Code with Arcade toolkits: {', '.join(tool_packages)}[/green]"
@ -874,8 +1176,7 @@ def configure_client_toolkit(
config["mcpServers"] = {} config["mcpServers"] = {}
_warn_overwrite(config, "mcpServers", server_name, _config_path) _warn_overwrite(config, "mcpServers", server_name, _config_path)
config["mcpServers"][server_name] = server_config config["mcpServers"][server_name] = server_config
with open(_config_path, "w", encoding="utf-8") as f: _atomic_write_json(_config_path, config)
json.dump(config, f, indent=2)
console.print( console.print(
f"[green]Configured {display} with Arcade toolkits: {', '.join(tool_packages)}[/green]" f"[green]Configured {display} with Arcade toolkits: {', '.join(tool_packages)}[/green]"
@ -931,7 +1232,7 @@ def configure_client(
if host == "arcade": if host == "arcade":
console.print( console.print(
"Use [bold]arcade connect[/bold] to connect to Arcade Cloud gateways.\n" "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", style="yellow",
) )
return return

View file

@ -416,46 +416,6 @@ def create_gateway(
return data 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 # Interactive selection
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -552,7 +512,6 @@ def run_connect(
tools: list[str] | None = None, tools: list[str] | None = None,
gateway: str | None = None, gateway: str | None = None,
all_tools: bool = False, all_tools: bool = False,
use_api_key: bool = False,
gateway_slug: str | None = None, gateway_slug: str | None = None,
config_path: Path | None = None, config_path: Path | None = None,
debug: bool = False, debug: bool = False,
@ -575,12 +534,7 @@ def run_connect(
# --- Direct gateway mode (existing gateway) --- # --- Direct gateway mode (existing gateway) ---
# Resolve the input: user may pass a name ("opencode") or slug ("pascal_opencode") # Resolve the input: user may pass a name ("opencode") or slug ("pascal_opencode")
slug = _resolve_gateway_slug(gateway, access_token, debug=debug) slug = _resolve_gateway_slug(gateway, access_token, debug=debug)
api_key: str | None = None _configure_gateway(client, slug, config_path, name=gateway)
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)
return return
# --- Toolkit / tool → gateway mode --- # --- Toolkit / tool → gateway mode ---
@ -615,7 +569,7 @@ def run_connect(
if not available: if not available:
console.print( console.print(
"No toolkits found in your account. You can specify toolkits manually:\n" "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", style="yellow",
) )
raise SystemExit(1) raise SystemExit(1)
@ -669,7 +623,7 @@ def run_connect(
raise SystemExit(1) raise SystemExit(1)
# Check if an existing gateway already covers these tools # 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") console.print("Checking existing gateways...", style="dim")
existing_gateways = list_gateways(access_token, debug=debug) existing_gateways = list_gateways(access_token, debug=debug)
existing = find_matching_gateway( 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" [dim]Gateway response: id={gw.get('id')}, slug={slug}[/dim]")
console.print(f" Gateway created: [bold]{slug}[/bold]\n", style="green") 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 # Config key: prefer --slug if given, otherwise derive from toolkit names
if gateway_slug: if gateway_slug:
display_name = gateway_slug display_name = gateway_slug
@ -722,7 +669,7 @@ def run_connect(
display_name = selected_toolkits[0].lower() display_name = selected_toolkits[0].lower()
else: else:
display_name = "-".join(sorted({tk.lower() for tk in selected_toolkits})) 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 # Print examples
examples = get_toolkit_examples(selected_toolkits) examples = get_toolkit_examples(selected_toolkits)
@ -765,7 +712,6 @@ def _configure_gateway(
client: str, client: str,
slug: str, slug: str,
config_path: Path | None, config_path: Path | None,
api_key: str | None = None,
name: str | None = None, name: str | None = None,
) -> None: ) -> None:
"""Configure the MCP client to connect to a gateway by slug. """Configure the MCP client to connect to a gateway by slug.
@ -786,13 +732,10 @@ def _configure_gateway(
client=client, client=client,
server_name=server_name, server_name=server_name,
gateway_url=gateway_url, gateway_url=gateway_url,
auth_token=api_key, auth_token=None,
config_path=config_path, config_path=config_path,
) )
console.print("\n[bold green]Setup complete![/bold green]") console.print("\n[bold green]Setup complete![/bold green]")
console.print(f" Gateway URL: {gateway_url}", style="dim") console.print(f" Gateway URL: {gateway_url}", style="dim")
if api_key: console.print(" Auth: OAuth (handled by your MCP client)", style="dim")
console.print(" Auth: API key (included in config)", style="dim")
else:
console.print(" Auth: OAuth (handled by your MCP client)", style="dim")

View file

@ -793,7 +793,16 @@ def connect(
..., ...,
help="MCP client to connect to the remote gateway", help="MCP client to connect to the remote gateway",
click_type=click.Choice( click_type=click.Choice(
["claude", "cursor", "vscode", "windsurf", "amazonq"], [
"claude-code",
"cursor",
"vscode",
"windsurf",
"amazonq",
"codex",
"opencode",
"gemini",
],
case_sensitive=False, case_sensitive=False,
), ),
show_choices=True, show_choices=True,
@ -831,11 +840,6 @@ def connect(
"-s", "-s",
help="Custom slug for the created gateway (only with --server/--tool/--preset).", 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( config_path: Optional[Path] = typer.Option(
None, None,
"--config", "--config",
@ -852,17 +856,15 @@ def connect(
creates an Arcade Cloud gateway for the selected toolkits, and writes your creates an Arcade Cloud gateway for the selected toolkits, and writes your
MCP client config, all in one step. MCP client config, all in one step.
By default gateways use OAuth (the MCP client handles the auth flow). Gateways use OAuth; the MCP client handles the auth flow.
Pass --api-key to use API-key auth instead (creates a key automatically).
To configure a local server on your filesystem instead, use 'arcade configure'. To configure a local server on your filesystem instead, use 'arcade configure'.
Examples:\n Examples:\n
arcade connect claude --server github\n arcade connect claude-code --server github\n
arcade connect cursor --preset productivity\n arcade connect cursor --preset productivity\n
arcade connect claude --tool Github.CreateIssue --tool Linear.UpdateIssue\n arcade connect claude-code --tool Github.CreateIssue --tool Linear.UpdateIssue\n
arcade connect claude --gateway my-existing-gw\n arcade connect claude-code --gateway my-existing-gw\n
arcade connect vscode --all --api-key\n
""" """
from arcade_cli.connect import PRESET_BUNDLES, run_connect from arcade_cli.connect import PRESET_BUNDLES, run_connect
@ -884,7 +886,6 @@ def connect(
tools=list(tool) if tool else None, tools=list(tool) if tool else None,
gateway=gateway, gateway=gateway,
all_tools=all_tools, all_tools=all_tools,
use_api_key=api_key,
gateway_slug=slug, gateway_slug=slug,
config_path=config_path, config_path=config_path,
debug=debug, debug=debug,

File diff suppressed because it is too large Load diff

View file

@ -359,7 +359,7 @@ class TestRunConnectToolOnly:
patch("arcade_cli.configure.console"), patch("arcade_cli.configure.console"),
): ):
run_connect( run_connect(
client="claude", client="claude-code",
tools=["Github.CreateIssue", "Slack.SendMessage"], tools=["Github.CreateIssue", "Slack.SendMessage"],
config_path=config_path, config_path=config_path,
) )
@ -397,7 +397,7 @@ class TestRunConnectGateway:
patch("arcade_cli.configure.console"), patch("arcade_cli.configure.console"),
): ):
run_connect( run_connect(
client="claude", client="claude-code",
gateway="my-production-gw", gateway="my-production-gw",
config_path=config_path, config_path=config_path,
) )
@ -424,7 +424,7 @@ class TestRunConnectGateway:
config = json.loads(config_path.read_text(encoding="utf-8")) config = json.loads(config_path.read_text(encoding="utf-8"))
entry = config["mcpServers"]["test-gw"] 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"] assert "api.arcade.dev/mcp/test-gw" in entry["url"]
def test_gateway_mode_configures_vscode(self, tmp_path: Path) -> None: def test_gateway_mode_configures_vscode(self, tmp_path: Path) -> None:
@ -472,7 +472,7 @@ class TestRunConnectToolkit:
patch("arcade_cli.configure.console"), patch("arcade_cli.configure.console"),
): ):
run_connect( run_connect(
client="claude", client="claude-code",
toolkits=["github"], toolkits=["github"],
config_path=config_path, config_path=config_path,
) )
@ -516,7 +516,7 @@ class TestRunConnectToolkit:
config = json.loads(config_path.read_text(encoding="utf-8")) config = json.loads(config_path.read_text(encoding="utf-8"))
entry = config["mcpServers"]["github-slack"] 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"] assert "api.arcade.dev/mcp/github-slack" in entry["url"]
@ -547,7 +547,7 @@ class TestRunConnectInteractive:
patch("arcade_cli.configure.console"), patch("arcade_cli.configure.console"),
): ):
run_connect( run_connect(
client="claude", client="claude-code",
all_tools=True, all_tools=True,
config_path=config_path, config_path=config_path,
) )
@ -565,7 +565,7 @@ class TestRunConnectInteractive:
patch("arcade_cli.connect.console"), patch("arcade_cli.connect.console"),
pytest.raises(SystemExit), 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: def test_toolkit_not_found_exits(self) -> None:
with ( with (
@ -575,40 +575,7 @@ class TestRunConnectInteractive:
patch("arcade_cli.connect.console"), patch("arcade_cli.connect.console"),
pytest.raises(SystemExit), pytest.raises(SystemExit),
): ):
run_connect(client="claude", toolkits=["nonexistent"]) run_connect(client="claude-code", 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")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -681,7 +648,7 @@ class TestRunConnectAdvanced:
patch("arcade_cli.configure.console"), patch("arcade_cli.configure.console"),
): ):
run_connect( run_connect(
client="claude", client="claude-code",
toolkits=["github"], toolkits=["github"],
config_path=config_path, config_path=config_path,
) )
@ -690,27 +657,6 @@ class TestRunConnectAdvanced:
entry = config["mcpServers"]["github"] entry = config["mcpServers"]["github"]
assert "existing-gw" in entry["url"] 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: def test_toolkit_with_custom_slug(self, tmp_path: Path) -> None:
config_path = tmp_path / "claude.json" config_path = tmp_path / "claude.json"
@ -729,7 +675,7 @@ class TestRunConnectAdvanced:
patch("arcade_cli.configure.console"), patch("arcade_cli.configure.console"),
): ):
run_connect( run_connect(
client="claude", client="claude-code",
toolkits=["github"], toolkits=["github"],
gateway_slug="my-custom", gateway_slug="my-custom",
config_path=config_path, config_path=config_path,
@ -758,7 +704,7 @@ class TestRunConnectAdvanced:
patch("arcade_cli.configure.console"), patch("arcade_cli.configure.console"),
): ):
run_connect( run_connect(
client="claude", client="claude-code",
toolkits=["github"], toolkits=["github"],
tools=["Slack.SendMessage"], tools=["Slack.SendMessage"],
config_path=config_path, config_path=config_path,