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 -->
742 lines
25 KiB
Python
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")
|