arcade-mcp/libs/arcade-cli/arcade_cli/connect.py
Eric Gustin cbe68462df
fix: mypy errors silently dropped during CI (#832)
Resolves
https://linear.app/arcadedev/issue/TOO-788/mypy-failures-are-silently-dropped-during-arcade-mcp-ci

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: primarily CI/Makefile behavior and type-annotation tweaks;
functional logic is unchanged aside from stricter failure propagation in
`make check`.
> 
> **Overview**
> **Stops CI from silently ignoring mypy failures.** The `make check`
target now runs `mypy` across `libs/arcade*/` and exits non-zero if any
package fails, reporting the failed libs.
> 
> Separately tightens typing to satisfy `mypy` (removing `type: ignore`
on OAuth helpers, adding `cast()`/`Any` annotations for JSON response
shapes and subprocess kwargs, and handling non-`str` `server_address`
hosts), and bumps patch versions for `arcade-mcp` and
`arcade-mcp-server`.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
e79575b13a2d03adf3548104a0064c643f1e21b1. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-04-28 13:25:44 -07:00

742 lines
25 KiB
Python

"""Connect command — one-command toolkit + gateway setup for Arcade MCP."""
from __future__ import annotations
import json as _json
import logging
import time
from pathlib import Path
from typing import Any, cast
import httpx
from arcade_core.constants import PROD_COORDINATOR_HOST, PROD_ENGINE_HOST
from arcade_cli.console import console
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tool catalog cache
# ---------------------------------------------------------------------------
_CACHE_DIR = Path.home() / ".arcade" / "cache"
_CACHE_FILE = _CACHE_DIR / "tools.json"
_CACHE_TTL_SECONDS = 300 # 5 minutes
def _get_context_key() -> str:
"""Return a string identifying the active org+project (for cache scoping)."""
try:
from arcade_cli.utils import get_org_project_context
org_id, project_id = get_org_project_context()
except Exception:
return "unknown"
else:
return f"{org_id}:{project_id}"
def _read_cache(debug: bool = False) -> dict[str, list[str]] | None:
"""Return cached toolkit map if the cache file exists, is fresh, and matches the active context."""
try:
if not _CACHE_FILE.exists():
return None
data = _json.loads(_CACHE_FILE.read_text(encoding="utf-8"))
age = time.time() - data.get("ts", 0)
if age > _CACHE_TTL_SECONDS:
if debug:
console.print(f" [dim]Cache expired ({age:.0f}s old)[/dim]")
return None
# Invalidate if org/project changed
cached_ctx = data.get("context")
current_ctx = _get_context_key()
if cached_ctx and cached_ctx != current_ctx:
if debug:
console.print(" [dim]Cache stale (different project context)[/dim]")
return None
if debug:
console.print(f" [dim]Using cached tool catalog ({age:.0f}s old)[/dim]")
return cast("dict[str, list[str]]", data.get("toolkits", {}))
except Exception:
return None
def _write_cache(toolkits: dict[str, list[str]]) -> None:
"""Persist the toolkit map to disk, scoped to the active org/project."""
try:
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
_CACHE_FILE.write_text(
_json.dumps({
"ts": time.time(),
"context": _get_context_key(),
"toolkits": toolkits,
}),
encoding="utf-8",
)
except Exception:
logger.debug("Failed to write tool cache", exc_info=True)
# ---------------------------------------------------------------------------
# Well-known toolkit metadata
# ---------------------------------------------------------------------------
TOOLKIT_EXAMPLES: dict[str, list[str]] = {
"github": [
"List my open pull requests",
"Show recent issues in my repo",
"Create a new issue titled 'Bug: login fails'",
],
"slack": [
"Send a message to #general saying hello",
"List my unread Slack messages",
"Search Slack for messages about deployment",
],
"google": [
"List my upcoming Google Calendar events",
"Search my Gmail for emails from Alice",
"Create a new Google Doc titled 'Meeting Notes'",
],
"linear": [
"Show my assigned Linear issues",
"Create a new Linear issue for the API refactor",
],
"notion": [
"Search my Notion workspace for project plans",
"Create a new Notion page in my workspace",
],
"jira": [
"List my open Jira tickets",
"Create a Jira issue for the backend migration",
],
"spotify": [
"Play my Discover Weekly playlist",
"What song is currently playing?",
],
"x": [
"Post a tweet saying 'Hello from Arcade!'",
"Search recent tweets about AI tools",
],
"reddit": [
"Search Reddit for posts about MCP tools",
"Get the top posts from r/programming",
],
"figma": [
"List my recent Figma files",
"Get comments on my latest Figma design",
],
"atlassian": [
"List my Confluence pages",
"Search Jira for open bugs",
],
"dropbox": [
"List files in my Dropbox root folder",
"Search Dropbox for 'project plan'",
],
"asana": [
"List my Asana tasks",
"Create a new Asana task for the launch",
],
"hubspot": [
"List my recent HubSpot contacts",
"Search HubSpot deals closing this month",
],
"discord": [
"Send a message to my Discord server",
"List channels in my Discord server",
],
"zoom": [
"List my upcoming Zoom meetings",
"Create a Zoom meeting for tomorrow at 2pm",
],
"microsoft": [
"List my recent Outlook emails",
"Search OneDrive for 'quarterly report'",
],
"pagerduty": [
"List my on-call schedules",
"Show recent PagerDuty incidents",
],
}
PRESET_BUNDLES: dict[str, list[str]] = {
"Productivity": ["google", "slack", "notion"],
"Development": ["github", "linear", "jira"],
"Communication": ["slack", "google", "x"],
"Project Management": ["linear", "jira", "notion"],
"DevOps": ["github", "slack", "linear"],
"Social": ["x", "slack", "reddit"],
"Creative": ["spotify", "figma", "notion"],
}
def get_toolkit_examples(toolkits: list[str]) -> list[str]:
"""Return example prompts for the given toolkit names."""
examples: list[str] = []
for tk in toolkits:
tk_lower = tk.lower().replace("arcade-", "").replace("arcade_", "")
if tk_lower in TOOLKIT_EXAMPLES:
examples.extend(TOOLKIT_EXAMPLES[tk_lower][:2])
if not examples:
examples.append("Ask your AI assistant to use one of the configured tools!")
return examples
# ---------------------------------------------------------------------------
# Login helper
# ---------------------------------------------------------------------------
def ensure_login(coordinator_url: str | None = None) -> str:
"""Ensure the user is logged in, triggering OAuth if needed.
Returns the valid access token.
"""
from arcade_cli.authn import (
OAuthLoginError,
check_existing_login,
get_valid_access_token,
perform_oauth_login,
save_credentials_from_whoami,
)
resolved_url = coordinator_url or f"https://{PROD_COORDINATOR_HOST}"
if check_existing_login(suppress_message=True):
return get_valid_access_token(resolved_url)
console.print("You need to log in to Arcade first.\n", style="yellow")
try:
result = perform_oauth_login(
resolved_url,
on_status=lambda msg: console.print(msg, style="dim"),
)
save_credentials_from_whoami(result.tokens, result.whoami, resolved_url)
console.print(f"\nLogged in as {result.email}.", style="bold green")
return get_valid_access_token(resolved_url)
except OAuthLoginError as e:
raise SystemExit(f"Login failed: {e}") from e
# ---------------------------------------------------------------------------
# Arcade API helpers
# ---------------------------------------------------------------------------
def fetch_available_toolkits(
base_url: str | None = None,
debug: bool = False,
skip_cache: bool = False,
) -> dict[str, list[str]]:
"""Fetch tools from the Arcade Engine and group them by toolkit name.
Results are cached to ``~/.arcade/cache/tools.json`` for 5 minutes so
repeated invocations (e.g. interactive → allow-list) are instant.
Returns a dict mapping toolkit names to lists of tool qualified names
(e.g. ``"Github.ListPRs"``).
"""
if not skip_cache:
cached = _read_cache(debug=debug)
if cached is not None:
return cached
from arcadepy import NOT_GIVEN, APIConnectionError
from arcade_cli.utils import compute_base_url, get_arcade_client
url = base_url or compute_base_url(False, False, PROD_ENGINE_HOST, None, default_port=None)
if debug:
console.print(f" [dim]Connecting to Arcade Engine at {url}[/dim]")
client = get_arcade_client(url)
toolkits: dict[str, list[str]] = {}
tool_count = 0
try:
# limit= is the page size, not a cap — the iterator auto-paginates
for tool in client.tools.list(toolkit=NOT_GIVEN, limit=1000):
toolkit_name = getattr(tool.toolkit, "name", None) or "unknown"
tool_name = tool.name or "unknown"
# Gateway API requires qualified names: "ToolkitName.ToolName"
qualified = f"{toolkit_name}.{tool_name}"
toolkits.setdefault(toolkit_name, []).append(qualified)
tool_count += 1
if debug:
console.print(f" [dim] Found tool: {qualified}[/dim]")
except APIConnectionError:
console.print(f"Could not connect to Arcade Engine at {url}.", style="bold red")
except Exception as e:
if debug:
console.print(f" [dim]Error fetching toolkits: {e}[/dim]")
else:
logger.debug("Failed to fetch toolkits: %s", e)
console.print(
"Could not fetch available toolkits from your account.",
style="bold red",
)
if debug:
console.print(
f" [dim]Fetched {tool_count} tools across {len(toolkits)} toolkits: "
f"{list(toolkits.keys())}[/dim]"
)
if toolkits:
_write_cache(toolkits)
return toolkits
def list_gateways(
access_token: str,
base_url: str | None = None,
debug: bool = False,
) -> list[dict]:
"""List existing MCP gateways from the user's project.
Returns a list of gateway dicts (each with ``id``, ``slug``, ``name``,
``tool_filter``, etc.).
"""
from arcade_cli.utils import compute_base_url, get_org_project_context
url = base_url or compute_base_url(False, False, PROD_ENGINE_HOST, None, default_port=None)
org_id, project_id = get_org_project_context()
endpoint = f"{url}/v1/orgs/{org_id}/projects/{project_id}/gateways"
if debug:
console.print(f" [dim]GET {endpoint}[/dim]")
resp = httpx.get(
endpoint,
headers={"Authorization": f"Bearer {access_token}"},
timeout=30,
)
if resp.status_code != 200:
if debug:
console.print(f" [dim]Failed to list gateways: {resp.status_code}[/dim]")
return []
data = resp.json()
return cast("list[dict[Any, Any]]", data.get("items", []))
def find_matching_gateway(
gateways: list[dict],
tool_allow_list: list[str],
auth_type: str = "arcade",
debug: bool = False,
) -> dict | None:
"""Find an existing gateway whose allow-list is a superset of *tool_allow_list*
and whose ``auth_type`` matches."""
needed = set(tool_allow_list)
for gw in gateways:
if gw.get("auth_type", "arcade") != auth_type:
continue
existing = set(gw.get("tool_filter", {}).get("allowed_tools", []))
if needed <= existing:
if debug:
console.print(
f" [dim]Found existing gateway '{gw.get('slug')}' "
f"with {len(existing)} tools (covers all {len(needed)} needed)[/dim]"
)
return gw
return None
def create_gateway(
access_token: str,
name: str,
tool_allow_list: list[str],
auth_type: str = "arcade",
slug: str | None = None,
base_url: str | None = None,
debug: bool = False,
) -> dict:
"""Create a new MCP gateway on Arcade Cloud.
Args:
access_token: OAuth access token for the Engine API.
name: Human-readable gateway name.
tool_allow_list: Qualified tool names (e.g. ``"Github.CreateIssue"``).
auth_type: ``"arcade"`` (OAuth, default) or ``"arcade_header"`` (API key).
slug: Custom slug for the gateway URL. Auto-generated if not provided.
base_url: Engine API base URL override.
debug: Print debug output.
Returns the gateway response dict (with ``slug``, ``id``, ``name``, etc.).
"""
from arcade_cli.utils import compute_base_url, get_org_project_context
url = base_url or compute_base_url(False, False, PROD_ENGINE_HOST, None, default_port=None)
org_id, project_id = get_org_project_context()
endpoint = f"{url}/v1/orgs/{org_id}/projects/{project_id}/gateways"
body: dict = {
"name": name,
"auth_type": auth_type,
"tool_filter": {"allowed_tools": tool_allow_list},
}
if slug:
body["slug"] = slug
if debug:
console.print(f" [dim]POST {endpoint}[/dim]")
console.print(f" [dim]Body: {body}[/dim]")
resp = httpx.post(
endpoint,
json=body,
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[:500]}[/dim]")
if resp.status_code not in (200, 201):
raise RuntimeError(f"Failed to create gateway ({resp.status_code}): {resp.text}")
data: dict[Any, Any] = resp.json()
# The API may return the gateway directly or wrapped in a list/items envelope
if "slug" in data:
return data
if data.get("items"):
return cast("dict[Any, Any]", data["items"][0])
if "id" in data:
return data
if debug:
console.print(f" [dim]Unexpected response shape: {list(data.keys())}[/dim]")
return data
# ---------------------------------------------------------------------------
# Interactive selection
# ---------------------------------------------------------------------------
def prompt_toolkit_selection(available: dict[str, list[str]]) -> list[str]:
"""Interactively prompt the user to select toolkits.
Returns a list of selected toolkit names.
"""
if not available:
console.print("No toolkits available in your account.", style="bold red")
raise SystemExit(1)
console.print("\n[bold]Available toolkits:[/bold]\n")
# Case-insensitive lookup: preset says "github", API returns "Github"
avail_lower: dict[str, str] = {k.lower(): k for k in available}
# Show preset bundles first
bundle_choices: list[tuple[str, list[str]]] = []
for bundle_name, bundle_tks in PRESET_BUNDLES.items():
# Resolve each preset toolkit to its actual API key
matching = [avail_lower[t] for t in bundle_tks if t in avail_lower]
if matching:
bundle_choices.append((bundle_name, matching))
sorted_toolkits = sorted(available.keys())
# Number the options
idx = 1
option_map: dict[int, list[str]] = {}
for bundle_name, bundle_tks in bundle_choices:
tool_count = sum(len(available.get(t, [])) for t in bundle_tks)
display_names = ", ".join(t.lower() for t in bundle_tks)
console.print(
f" [bold cyan]{idx}.[/bold cyan] {bundle_name} bundle "
f"({display_names}) — {tool_count} tools"
)
option_map[idx] = bundle_tks
idx += 1
if bundle_choices:
console.print()
for tk_name in sorted_toolkits:
tools = available[tk_name]
console.print(f" [bold cyan]{idx}.[/bold cyan] {tk_name}{len(tools)} tools")
option_map[idx] = [tk_name]
idx += 1
console.print(f"\n [bold cyan]{idx}.[/bold cyan] All available toolkits")
option_map[idx] = sorted_toolkits
console.print()
try:
raw = input("Select toolkits (comma-separated numbers, e.g. 1,3): ").strip()
except (EOFError, KeyboardInterrupt):
raise SystemExit("\nCancelled.")
selected: list[str] = []
for part in raw.split(","):
part = part.strip()
if not part:
continue
try:
choice = int(part)
except ValueError:
console.print(f" Skipping invalid choice: {part}", style="yellow")
continue
if choice in option_map:
for tk in option_map[choice]:
if tk not in selected:
selected.append(tk)
else:
console.print(f" Skipping unknown option: {choice}", style="yellow")
if not selected:
console.print("No toolkits selected.", style="bold red")
raise SystemExit(1)
return selected
# ---------------------------------------------------------------------------
# Main orchestrator
# ---------------------------------------------------------------------------
def run_connect(
client: str,
toolkits: list[str] | None = None,
tools: list[str] | None = None,
gateway: str | None = None,
all_tools: bool = False,
gateway_slug: str | None = None,
config_path: Path | None = None,
debug: bool = False,
) -> None:
"""Run the quickstart flow: login → determine mode → configure client.
Everything is configured as a cloud gateway — no local server required.
Args:
toolkits: Whole toolkit names (e.g. ``["github"]``) — adds all tools.
tools: Individual qualified tool names (e.g. ``["Github.CreateIssue"]``).
gateway: Existing gateway slug to connect to directly.
"""
# Step 1: Ensure login
access_token = ensure_login()
# Step 2: Determine mode
if gateway:
# --- 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)
_configure_gateway(client, slug, config_path, name=gateway)
return
# --- Toolkit / tool → gateway mode ---
# If only --tool is given (no --toolkit, --all, or interactive), skip catalog fetch
if tools and not toolkits and not all_tools:
tool_allow_list = list(tools)
selected_toolkits = sorted({t.split(".")[0] for t in tools})
console.print(
f"\nSetting up gateway with {len(tool_allow_list)} individual tool(s): "
f"[bold]{', '.join(tool_allow_list)}[/bold]\n"
)
else:
selected_toolkits_list: list[str]
# Fetch the tool catalog once — used for selection and allow-list
console.print("Fetching tool catalog...", style="dim")
available = fetch_available_toolkits(debug=debug)
if toolkits:
selected_toolkits_list = toolkits
elif all_tools:
if not available:
console.print(
"No toolkits found. Make sure you have tools deployed in your Arcade account.",
style="bold red",
)
raise SystemExit(1)
selected_toolkits_list = sorted(available.keys())
else:
# Interactive mode
if not available:
console.print(
"No toolkits found in your account. You can specify toolkits manually:\n"
" [bold]arcade connect claude-code --toolkit github[/bold]",
style="yellow",
)
raise SystemExit(1)
selected_toolkits_list = prompt_toolkit_selection(available)
selected_toolkits = selected_toolkits_list
console.print(
f"\nSetting up gateway for toolkits: [bold]{', '.join(selected_toolkits)}[/bold]\n"
)
# Build a case-insensitive lookup: "github" -> "Github", etc.
tk_lower_map: dict[str, str] = {k.lower(): k for k in available}
if debug:
console.print(f" [dim]Available toolkit keys: {list(available.keys())}[/dim]")
console.print(f" [dim]Looking for: {selected_toolkits}[/dim]")
tool_allow_list = []
for tk in selected_toolkits:
actual_key = tk_lower_map.get(tk.lower())
if actual_key:
tk_tools = available[actual_key]
tool_allow_list.extend(tk_tools)
if debug:
console.print(
f" [dim]Matched '{tk}' -> '{actual_key}' ({len(tk_tools)} tools)[/dim]"
)
else:
console.print(f" [yellow]Warning: No tools found for toolkit '{tk}'.[/yellow]")
if available:
console.print(
f" [yellow]Available toolkits: "
f"{', '.join(sorted(available.keys()))}[/yellow]"
)
# Append any individual --tool names
if tools:
for t in tools:
if t not in tool_allow_list:
tool_allow_list.append(t)
if debug:
console.print(f" [dim]Added {len(tools)} individual tool(s)[/dim]")
if not tool_allow_list:
console.print(
"\nNo tools to add to the gateway. Deploy toolkits first with "
"[bold]arcade deploy[/bold].",
style="bold red",
)
raise SystemExit(1)
# Check if an existing gateway already covers these tools
auth_type = "arcade"
console.print("Checking existing gateways...", style="dim")
existing_gateways = list_gateways(access_token, debug=debug)
existing = find_matching_gateway(
existing_gateways, tool_allow_list, auth_type=auth_type, debug=debug
)
if existing:
slug = existing["slug"]
console.print(
f" Found existing gateway: [bold]{existing.get('name', slug)}[/bold] (slug: {slug})\n",
style="green",
)
else:
# Create a new gateway
if len(selected_toolkits) == 1:
gateway_name = selected_toolkits[0].lower()
else:
gateway_name = "-".join(sorted({tk.lower() for tk in selected_toolkits}))
console.print(
f"Creating gateway '{gateway_name}' with {len(tool_allow_list)} tools "
f"(auth: {auth_type})...",
style="dim",
)
gw = create_gateway(
access_token=access_token,
name=gateway_name,
tool_allow_list=tool_allow_list,
auth_type=auth_type,
slug=gateway_slug,
debug=debug,
)
slug = gw.get("slug", gateway_name)
if debug:
console.print(f" [dim]Gateway response: id={gw.get('id')}, slug={slug}[/dim]")
console.print(f" Gateway created: [bold]{slug}[/bold]\n", style="green")
# Config key: prefer --slug if given, otherwise derive from toolkit names
if gateway_slug:
display_name = gateway_slug
elif len(selected_toolkits) == 1:
display_name = selected_toolkits[0].lower()
else:
display_name = "-".join(sorted({tk.lower() for tk in selected_toolkits}))
_configure_gateway(client, slug, config_path, name=display_name)
# Print examples
examples = get_toolkit_examples(selected_toolkits)
console.print("\nTry asking your AI assistant:", style="bold")
for ex in examples[:3]:
console.print(f" - {ex}", style="dim")
def _resolve_gateway_slug(
user_input: str,
access_token: str,
debug: bool = False,
) -> str:
"""Resolve a gateway name or slug to the actual slug.
The user may pass a name (``opencode``) or a slug (``pascal_opencode``).
We look up existing gateways and match by slug first, then by name.
Falls back to the original input if no match is found.
"""
gateways = list_gateways(access_token, debug=debug)
input_lower = user_input.lower()
for gw in gateways:
if gw.get("slug", "").lower() == input_lower:
if debug:
console.print(f" [dim]Matched by slug: {gw['slug']}[/dim]")
return cast("str", gw["slug"])
for gw in gateways:
if gw.get("name", "").lower() == input_lower:
slug = gw["slug"]
if debug:
console.print(f" [dim]Matched by name '{gw['name']}' -> slug: {slug}[/dim]")
return cast("str", slug)
if debug:
available = [f"{g.get('name')} ({g.get('slug')})" for g in gateways]
console.print(f" [dim]No match for '{user_input}', available: {available}[/dim]")
return user_input
def _configure_gateway(
client: str,
slug: str,
config_path: Path | None,
name: str | None = None,
) -> None:
"""Configure the MCP client to connect to a gateway by slug.
*name* is the human-readable label used as the config key (e.g. ``github``).
Defaults to *slug* if not provided.
"""
from arcade_cli.configure import configure_client_gateway
from arcade_cli.utils import compute_base_url
api_base = compute_base_url(False, False, PROD_ENGINE_HOST, None, default_port=None)
gateway_url = f"{api_base}/mcp/{slug}"
server_name = name or slug
console.print(f"Configuring [bold]{client}[/bold] to connect to gateway: [bold]{slug}[/bold]\n")
configure_client_gateway(
client=client,
server_name=server_name,
gateway_url=gateway_url,
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")
console.print(" Auth: OAuth (handled by your MCP client)", style="dim")