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."""
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

View file

@ -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")

View file

@ -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

View file

@ -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,