mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-04-29 04:00:13 +00:00
836 lines
27 KiB
Python
836 lines
27 KiB
Python
"""CLI commands for switching local MCP client configs and managing optional saved profiles."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
import os
|
|
import platform
|
|
import re
|
|
import shutil
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
from urllib.parse import urlparse, urlunparse
|
|
|
|
import toml
|
|
import typer
|
|
from rich.panel import Panel
|
|
from rich.prompt import Confirm, Prompt
|
|
from rich.syntax import Syntax
|
|
from rich.table import Table
|
|
|
|
from .console import console
|
|
from .setup_commands import (
|
|
_backup_config_path,
|
|
_claude_code_global_config_path,
|
|
_claude_code_project_config_path,
|
|
_claude_desktop_config_path,
|
|
_codex_config_path,
|
|
_cursor_config_path,
|
|
_find_server_key,
|
|
_get_env_credentials,
|
|
_load_mcp_config,
|
|
_mask_key,
|
|
_mask_secrets,
|
|
_windsurf_config_path,
|
|
_write_mcp_config,
|
|
)
|
|
|
|
mcp_app = typer.Typer(help="Manage local MCP configs and optional saved Skyvern profiles.", no_args_is_help=True)
|
|
profile_app = typer.Typer(help="Manage saved Skyvern MCP profiles.", no_args_is_help=True)
|
|
mcp_app.add_typer(profile_app, name="profile")
|
|
|
|
_MANUAL_SOURCE_NAME = "Manual entry"
|
|
_DEFAULT_BASE_URL = "https://api.skyvern.com"
|
|
_PROFILE_FILENAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
|
_ANSI_ESCAPE_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
_CONFIG_FORMAT_JSON = "json"
|
|
_CONFIG_FORMAT_CODEX = "codex_toml"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MCPProfile:
|
|
name: str
|
|
api_key: str
|
|
base_url: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SwitchTarget:
|
|
name: str
|
|
config_path: Path
|
|
entry_key: str | None
|
|
entry: dict | None
|
|
config_format: str = _CONFIG_FORMAT_JSON
|
|
error: str | None = None
|
|
|
|
|
|
@dataclass
|
|
class ProfileChoice:
|
|
label: str
|
|
profile: MCPProfile
|
|
sources: list[str]
|
|
saved_name: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SwitchTargetSpec:
|
|
name: str
|
|
config_path_fn: Callable[[], Path]
|
|
config_format: str = _CONFIG_FORMAT_JSON
|
|
|
|
|
|
def _sanitize_prompt_response(value: str) -> str:
|
|
cleaned = _ANSI_ESCAPE_RE.sub("", value)
|
|
return "".join(ch for ch in cleaned if ch.isprintable()).strip()
|
|
|
|
|
|
def _prompt_text(prompt: str, *, default: str | None = None, password: bool = False) -> str:
|
|
return _sanitize_prompt_response(Prompt.ask(prompt, default=default, password=password))
|
|
|
|
|
|
def _ask_choice(prompt: str, *, choices: list[str], default: str | None = None) -> str:
|
|
allowed = set(choices)
|
|
while True:
|
|
response = _prompt_text(prompt, default=default)
|
|
if response in allowed:
|
|
return response
|
|
console.print(f"[red]Invalid choice. Enter one of: {', '.join(choices)}[/red]")
|
|
|
|
|
|
def _profile_store_dir() -> Path:
|
|
if platform.system() == "Windows":
|
|
appdata = os.environ.get("APPDATA")
|
|
if appdata:
|
|
return Path(appdata) / "skyvern" / "mcp-profiles"
|
|
|
|
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
|
|
if xdg_config_home:
|
|
return Path(xdg_config_home) / "skyvern" / "mcp-profiles"
|
|
|
|
return Path.home() / ".config" / "skyvern" / "mcp-profiles"
|
|
|
|
|
|
def _profile_slug(name: str) -> str:
|
|
slug = _PROFILE_FILENAME_RE.sub("-", name.strip()).strip("-.").lower()
|
|
if not slug:
|
|
raise typer.BadParameter("Profile name must include at least one letter or number.")
|
|
return slug
|
|
|
|
|
|
def _profile_path(name: str) -> Path:
|
|
return _profile_store_dir() / f"{_profile_slug(name)}.json"
|
|
|
|
|
|
def _normalize_base_url(base_url: str) -> str:
|
|
normalized = base_url.strip()
|
|
if not normalized:
|
|
raise typer.BadParameter("Base URL cannot be empty.")
|
|
|
|
parsed = urlparse(normalized)
|
|
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
|
raise typer.BadParameter("Base URL must start with http:// or https://")
|
|
|
|
path = parsed.path.rstrip("/")
|
|
if path.endswith("/mcp"):
|
|
path = path[: -len("/mcp")]
|
|
|
|
return urlunparse((parsed.scheme, parsed.netloc, path, "", "", "")).rstrip("/")
|
|
|
|
|
|
def _profile_to_mcp_url(base_url: str) -> str:
|
|
return f"{_normalize_base_url(base_url)}/mcp/"
|
|
|
|
|
|
def _build_profile(name: str, api_key: str, base_url: str) -> MCPProfile:
|
|
clean_name = name.strip()
|
|
clean_key = api_key.strip()
|
|
if not clean_name:
|
|
raise typer.BadParameter("Profile name cannot be empty.")
|
|
if not clean_key:
|
|
raise typer.BadParameter("API key cannot be empty.")
|
|
|
|
return MCPProfile(
|
|
name=clean_name,
|
|
api_key=clean_key,
|
|
base_url=_normalize_base_url(base_url),
|
|
)
|
|
|
|
|
|
def _load_profile_from_path(path: Path) -> MCPProfile:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
if not isinstance(data, dict):
|
|
raise ValueError(f"{path} must contain a JSON object.")
|
|
|
|
name = data.get("name", "")
|
|
api_key = data.get("api_key", "")
|
|
base_url = data.get("base_url", "")
|
|
for field_name, value in (("name", name), ("api_key", api_key), ("base_url", base_url)):
|
|
if not isinstance(value, str):
|
|
raise ValueError(f"{path} field '{field_name}' must be a string.")
|
|
|
|
return _build_profile(
|
|
name,
|
|
api_key,
|
|
base_url,
|
|
)
|
|
|
|
|
|
def _list_profiles() -> list[MCPProfile]:
|
|
store_dir = _profile_store_dir()
|
|
if not store_dir.exists():
|
|
return []
|
|
|
|
profiles: list[MCPProfile] = []
|
|
for path in sorted(store_dir.glob("*.json")):
|
|
try:
|
|
profiles.append(_load_profile_from_path(path))
|
|
except Exception as exc:
|
|
console.print(f"[yellow]Skipping invalid profile {path.name}: {exc}[/yellow]")
|
|
|
|
return sorted(profiles, key=lambda profile: profile.name.lower())
|
|
|
|
|
|
def _load_profile(name: str) -> MCPProfile:
|
|
path = _profile_path(name)
|
|
if not path.exists():
|
|
console.print(f"[red]No saved MCP profile named '{name}'.[/red]")
|
|
raise typer.Exit(code=1)
|
|
try:
|
|
return _load_profile_from_path(path)
|
|
except Exception as exc:
|
|
console.print(f"[red]Failed to load profile '{name}': {exc}[/red]")
|
|
raise typer.Exit(code=1) from exc
|
|
|
|
|
|
def _save_profile(profile: MCPProfile, overwrite: bool = False) -> Path:
|
|
path = _profile_path(profile.name)
|
|
if path.exists() and not overwrite:
|
|
console.print(
|
|
f"[red]Profile '{profile.name}' already exists at {path}. Re-run with --overwrite to replace it.[/red]"
|
|
)
|
|
raise typer.Exit(code=1)
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
if os.name != "nt":
|
|
try:
|
|
path.parent.chmod(0o700)
|
|
except OSError:
|
|
pass
|
|
|
|
payload = {
|
|
"name": profile.name,
|
|
"api_key": profile.api_key,
|
|
"base_url": profile.base_url,
|
|
}
|
|
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
if os.name != "nt":
|
|
try:
|
|
path.chmod(0o600)
|
|
except OSError:
|
|
pass
|
|
return path
|
|
|
|
|
|
def _prompt_for_manual_source() -> MCPProfile:
|
|
env_key, env_base_url = _get_env_credentials()
|
|
default_base_url = env_base_url or _DEFAULT_BASE_URL
|
|
|
|
api_key = _prompt_text("Skyvern API key", password=True)
|
|
base_url = _prompt_text("Skyvern base URL (remote configs derive /mcp automatically)", default=default_base_url)
|
|
return _build_profile(_MANUAL_SOURCE_NAME, api_key, base_url)
|
|
|
|
|
|
def _extract_entry_base_url(entry: dict) -> str:
|
|
location = _entry_location(entry).strip()
|
|
if not location:
|
|
return ""
|
|
|
|
try:
|
|
return _normalize_base_url(location)
|
|
except typer.BadParameter:
|
|
return ""
|
|
|
|
|
|
def _server_block_key(config_format: str) -> str:
|
|
return "mcp_servers" if config_format == _CONFIG_FORMAT_CODEX else "mcpServers"
|
|
|
|
|
|
def _load_codex_config(config_path: Path) -> tuple[dict | None, str | None]:
|
|
if not config_path.exists():
|
|
return {}, None
|
|
|
|
try:
|
|
existing = toml.loads(config_path.read_text(encoding="utf-8"))
|
|
except toml.TomlDecodeError:
|
|
return None, f"Cannot parse {config_path}. Fix the TOML and re-run."
|
|
|
|
if not isinstance(existing, dict):
|
|
return None, f"{config_path} must contain a top-level TOML table."
|
|
|
|
servers = existing.get("mcp_servers")
|
|
if servers is not None and not isinstance(servers, dict):
|
|
return None, f"{config_path} has invalid `mcp_servers`; expected a TOML table."
|
|
|
|
return existing, None
|
|
|
|
|
|
def _load_switch_config(config_path: Path, config_format: str) -> tuple[dict | None, str | None]:
|
|
if config_format == _CONFIG_FORMAT_CODEX:
|
|
return _load_codex_config(config_path)
|
|
return _load_mcp_config(config_path)
|
|
|
|
|
|
def _write_codex_config(config_path: Path, config: dict, create_backup: bool = True) -> Path | None:
|
|
backup_path: Path | None = None
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
if create_backup and config_path.exists():
|
|
backup_path = _backup_config_path(config_path)
|
|
shutil.copy2(config_path, backup_path)
|
|
|
|
content = toml.dumps(config)
|
|
if not content.endswith("\n"):
|
|
content += "\n"
|
|
config_path.write_text(content, encoding="utf-8")
|
|
return backup_path
|
|
|
|
|
|
def _write_switch_config(config_path: Path, config: dict, config_format: str) -> Path | None:
|
|
if config_format == _CONFIG_FORMAT_CODEX:
|
|
return _write_codex_config(config_path, config, create_backup=True)
|
|
return _write_mcp_config(config_path, config, create_backup=True)
|
|
|
|
|
|
def _switch_target_specs() -> list[SwitchTargetSpec]:
|
|
return [
|
|
SwitchTargetSpec("Claude Code (global)", _claude_code_global_config_path),
|
|
SwitchTargetSpec("Claude Code (project)", _claude_code_project_config_path),
|
|
SwitchTargetSpec("Claude Desktop", _claude_desktop_config_path),
|
|
SwitchTargetSpec("Cursor", _cursor_config_path),
|
|
SwitchTargetSpec("Windsurf", _windsurf_config_path),
|
|
SwitchTargetSpec("Codex", _codex_config_path, config_format=_CONFIG_FORMAT_CODEX),
|
|
]
|
|
|
|
|
|
def _entry_kind(entry: dict | None) -> str:
|
|
if not entry:
|
|
return "missing"
|
|
|
|
command_name = Path(str(entry.get("command", ""))).name.lower()
|
|
args = entry.get("args", [])
|
|
if command_name == "npx" and isinstance(args, list) and args and args[0] == "mcp-remote":
|
|
return "mcp-remote bridge"
|
|
|
|
if isinstance(entry.get("env"), dict):
|
|
return "local stdio"
|
|
|
|
if entry.get("type") == "http" or "url" in entry or isinstance(entry.get("http_headers"), dict):
|
|
return "remote http"
|
|
|
|
return "unsupported"
|
|
|
|
|
|
def _extract_entry_api_key(entry: dict) -> str:
|
|
headers = entry.get("headers", {})
|
|
if isinstance(headers, dict):
|
|
api_key = headers.get("x-api-key")
|
|
if isinstance(api_key, str):
|
|
return api_key
|
|
|
|
http_headers = entry.get("http_headers", {})
|
|
if isinstance(http_headers, dict):
|
|
api_key = http_headers.get("x-api-key")
|
|
if isinstance(api_key, str):
|
|
return api_key
|
|
|
|
env = entry.get("env", {})
|
|
if isinstance(env, dict):
|
|
api_key = env.get("SKYVERN_API_KEY")
|
|
if isinstance(api_key, str):
|
|
return api_key
|
|
|
|
args = entry.get("args", [])
|
|
if isinstance(args, list):
|
|
for arg in args:
|
|
if isinstance(arg, str) and arg.startswith("x-api-key:"):
|
|
return arg[len("x-api-key:") :]
|
|
|
|
return ""
|
|
|
|
|
|
def _append_profile_choice(
|
|
choices_by_key: dict[tuple[str, str], ProfileChoice],
|
|
*,
|
|
label: str,
|
|
profile: MCPProfile,
|
|
source: str,
|
|
saved_name: str | None = None,
|
|
) -> None:
|
|
key = (profile.api_key, profile.base_url)
|
|
existing = choices_by_key.get(key)
|
|
if existing is None:
|
|
choices_by_key[key] = ProfileChoice(
|
|
label=label,
|
|
profile=profile,
|
|
sources=[source],
|
|
saved_name=saved_name,
|
|
)
|
|
return
|
|
|
|
if source not in existing.sources:
|
|
existing.sources.append(source)
|
|
|
|
if saved_name and existing.saved_name is None:
|
|
existing.label = label
|
|
existing.saved_name = saved_name
|
|
|
|
|
|
def _profile_from_target(target: SwitchTarget) -> MCPProfile | None:
|
|
if target.entry is None:
|
|
return None
|
|
|
|
api_key = _extract_entry_api_key(target.entry).strip()
|
|
base_url = _extract_entry_base_url(target.entry)
|
|
if not api_key or not base_url:
|
|
return None
|
|
|
|
return MCPProfile(name=f"{target.name} current", api_key=api_key, base_url=base_url)
|
|
|
|
|
|
def _collect_profile_choices(discovered: list[SwitchTarget]) -> list[ProfileChoice]:
|
|
choices_by_key: dict[tuple[str, str], ProfileChoice] = {}
|
|
|
|
env_key, env_base_url = _get_env_credentials()
|
|
if env_key:
|
|
env_profile = _build_profile("Current environment", env_key, env_base_url or _DEFAULT_BASE_URL)
|
|
_append_profile_choice(
|
|
choices_by_key,
|
|
label="Current environment",
|
|
profile=env_profile,
|
|
source="env/.env",
|
|
)
|
|
|
|
for saved_profile in _list_profiles():
|
|
_append_profile_choice(
|
|
choices_by_key,
|
|
label=saved_profile.name,
|
|
profile=saved_profile,
|
|
source="saved profile",
|
|
saved_name=saved_profile.name,
|
|
)
|
|
|
|
for target in discovered:
|
|
discovered_profile = _profile_from_target(target)
|
|
if discovered_profile is None:
|
|
continue
|
|
_append_profile_choice(
|
|
choices_by_key,
|
|
label=f"{target.name} current config",
|
|
profile=discovered_profile,
|
|
source=target.name,
|
|
)
|
|
|
|
return sorted(choices_by_key.values(), key=lambda choice: (choice.label.lower(), choice.profile.base_url))
|
|
|
|
|
|
def _select_profile(profile_name: str | None, discovered: list[SwitchTarget]) -> MCPProfile:
|
|
if profile_name:
|
|
return _load_profile(profile_name)
|
|
|
|
choices = _collect_profile_choices(discovered)
|
|
|
|
table = Table(show_header=True, header_style="bold")
|
|
table.add_column("#", style="cyan", justify="right")
|
|
table.add_column("Source")
|
|
table.add_column("From")
|
|
table.add_column("Base URL")
|
|
table.add_column("API Key")
|
|
|
|
for index, choice in enumerate(choices, start=1):
|
|
table.add_row(
|
|
str(index),
|
|
choice.label,
|
|
", ".join(choice.sources),
|
|
choice.profile.base_url,
|
|
_mask_key(choice.profile.api_key),
|
|
)
|
|
|
|
manual_choice_index = len(choices) + 1
|
|
table.add_row(
|
|
str(manual_choice_index),
|
|
"Enter manually",
|
|
"prompt",
|
|
"-",
|
|
"-",
|
|
)
|
|
|
|
console.print("\n[bold]Available switch sources[/bold]")
|
|
console.print(table)
|
|
|
|
selected = _ask_choice(
|
|
"Choose a source number for the Skyvern API key/base URL",
|
|
choices=[str(index) for index in range(1, manual_choice_index + 1)],
|
|
default="1" if choices else str(manual_choice_index),
|
|
)
|
|
|
|
if int(selected) == manual_choice_index:
|
|
return _prompt_for_manual_source()
|
|
|
|
return choices[int(selected) - 1].profile
|
|
|
|
|
|
def _entry_location(entry: dict) -> str:
|
|
kind = _entry_kind(entry)
|
|
if kind == "local stdio":
|
|
env = entry.get("env", {})
|
|
if isinstance(env, dict):
|
|
return str(env.get("SKYVERN_BASE_URL", ""))
|
|
if kind == "remote http":
|
|
return str(entry.get("url", ""))
|
|
if kind == "mcp-remote bridge":
|
|
args = entry.get("args", [])
|
|
if isinstance(args, list) and len(args) > 1 and isinstance(args[1], str):
|
|
return args[1]
|
|
return ""
|
|
|
|
|
|
def _discover_switch_targets() -> tuple[list[SwitchTarget], list[tuple[str, Path]]]:
|
|
discovered: list[SwitchTarget] = []
|
|
missing: list[tuple[str, Path]] = []
|
|
|
|
for spec in _switch_target_specs():
|
|
path = spec.config_path_fn()
|
|
if not path.exists():
|
|
missing.append((spec.name, path))
|
|
continue
|
|
|
|
config, error = _load_switch_config(path, spec.config_format)
|
|
if error:
|
|
discovered.append(
|
|
SwitchTarget(
|
|
name=spec.name,
|
|
config_path=path,
|
|
entry_key=None,
|
|
entry=None,
|
|
config_format=spec.config_format,
|
|
error=error,
|
|
)
|
|
)
|
|
continue
|
|
|
|
servers = (config or {}).get(_server_block_key(spec.config_format), {})
|
|
server_key = _find_server_key(servers, preferred="skyvern") if isinstance(servers, dict) else None
|
|
entry = servers.get(server_key) if server_key else None
|
|
discovered.append(
|
|
SwitchTarget(
|
|
name=spec.name,
|
|
config_path=path,
|
|
entry_key=server_key,
|
|
entry=entry,
|
|
config_format=spec.config_format,
|
|
error=None,
|
|
)
|
|
)
|
|
|
|
return discovered, missing
|
|
|
|
|
|
def _print_discovery_results(discovered: list[SwitchTarget], missing: list[tuple[str, Path]]) -> None:
|
|
if discovered:
|
|
table = Table(show_header=True, header_style="bold")
|
|
table.add_column("App")
|
|
table.add_column("Config Path")
|
|
table.add_column("Status")
|
|
table.add_column("Current")
|
|
|
|
for target in discovered:
|
|
if target.error:
|
|
status = "[red]Invalid config[/red]"
|
|
current = "-"
|
|
elif target.entry is None:
|
|
status = "[yellow]Config found, no Skyvern entry[/yellow]"
|
|
current = "-"
|
|
else:
|
|
status = f"[green]{_entry_kind(target.entry)}[/green]"
|
|
location = _entry_location(target.entry)
|
|
current = f"{_mask_key(_extract_entry_api_key(target.entry))} {location}".strip()
|
|
table.add_row(target.name, str(target.config_path), status, current)
|
|
|
|
console.print(table)
|
|
|
|
if missing:
|
|
missing_names = ", ".join(f"{name} ({path})" for name, path in missing)
|
|
console.print(f"[dim]Config not found: {missing_names}[/dim]")
|
|
|
|
|
|
def _select_targets(discovered: list[SwitchTarget]) -> list[SwitchTarget]:
|
|
selectable = [
|
|
target for target in discovered if target.entry is not None and _entry_kind(target.entry) != "unsupported"
|
|
]
|
|
if not selectable:
|
|
console.print(
|
|
"[red]No switchable Skyvern MCP entries were found. Add a Skyvern MCP entry for the client first, "
|
|
"then re-run `skyvern mcp switch`.[/red]"
|
|
)
|
|
raise typer.Exit(code=1)
|
|
|
|
if len(selectable) == 1:
|
|
return selectable
|
|
|
|
table = Table(show_header=True, header_style="bold")
|
|
table.add_column("#", style="cyan", justify="right")
|
|
table.add_column("App")
|
|
table.add_column("Config Path")
|
|
table.add_column("Transport")
|
|
|
|
for index, target in enumerate(selectable, start=1):
|
|
table.add_row(str(index), target.name, str(target.config_path), _entry_kind(target.entry))
|
|
|
|
console.print("\n[bold]Switchable Skyvern configs[/bold]")
|
|
console.print(table)
|
|
|
|
raw_choice = _prompt_text(
|
|
"Which apps should use the selected profile? Enter numbers separated by commas or 'all'",
|
|
default="all",
|
|
)
|
|
if raw_choice.strip().lower() == "all":
|
|
return selectable
|
|
|
|
chosen_indexes: list[int] = []
|
|
for item in raw_choice.split(","):
|
|
stripped = item.strip()
|
|
if not stripped:
|
|
continue
|
|
try:
|
|
index = int(stripped)
|
|
except ValueError as exc:
|
|
raise typer.BadParameter("Selections must be numbers separated by commas or 'all'.") from exc
|
|
if index < 1 or index > len(selectable):
|
|
raise typer.BadParameter(f"Selection {index} is out of range.")
|
|
if index not in chosen_indexes:
|
|
chosen_indexes.append(index)
|
|
|
|
if not chosen_indexes:
|
|
raise typer.BadParameter("Choose at least one app to update.")
|
|
|
|
return [selectable[index - 1] for index in chosen_indexes]
|
|
|
|
|
|
def _patch_entry_with_profile(
|
|
entry: dict,
|
|
profile: MCPProfile,
|
|
*,
|
|
config_format: str = _CONFIG_FORMAT_JSON,
|
|
) -> dict:
|
|
patched = copy.deepcopy(entry)
|
|
kind = _entry_kind(entry)
|
|
|
|
if kind == "local stdio":
|
|
env = dict(patched.get("env") or {})
|
|
env["SKYVERN_API_KEY"] = profile.api_key
|
|
env["SKYVERN_BASE_URL"] = profile.base_url
|
|
patched["env"] = env
|
|
return patched
|
|
|
|
if kind == "remote http":
|
|
target_url = _profile_to_mcp_url(profile.base_url)
|
|
if config_format == _CONFIG_FORMAT_CODEX or "http_headers" in patched:
|
|
headers = dict(patched.get("http_headers") or {})
|
|
headers["x-api-key"] = profile.api_key
|
|
patched["http_headers"] = headers
|
|
patched["url"] = target_url
|
|
return patched
|
|
|
|
headers = dict(patched.get("headers") or {})
|
|
headers["x-api-key"] = profile.api_key
|
|
patched["headers"] = headers
|
|
patched["type"] = "http"
|
|
patched["url"] = target_url
|
|
return patched
|
|
|
|
if kind == "mcp-remote bridge":
|
|
args = list(patched.get("args") or [])
|
|
target_url = _profile_to_mcp_url(profile.base_url)
|
|
if not args or args[0] != "mcp-remote":
|
|
raise ValueError("Unsupported mcp-remote entry format.")
|
|
|
|
if len(args) == 1:
|
|
args.append(target_url)
|
|
else:
|
|
args[1] = target_url
|
|
|
|
cleaned_args: list[object] = []
|
|
skip_next = False
|
|
for index, arg in enumerate(args):
|
|
if skip_next:
|
|
skip_next = False
|
|
continue
|
|
if arg == "--header" and index + 1 < len(args):
|
|
next_arg = args[index + 1]
|
|
if isinstance(next_arg, str) and next_arg.startswith("x-api-key:"):
|
|
skip_next = True
|
|
continue
|
|
if isinstance(arg, str) and arg.startswith("x-api-key:"):
|
|
continue
|
|
cleaned_args.append(arg)
|
|
|
|
cleaned_args.extend(["--header", f"x-api-key:{profile.api_key}"])
|
|
patched["args"] = cleaned_args
|
|
return patched
|
|
|
|
raise ValueError(f"Unsupported Skyvern MCP entry format: {kind}")
|
|
|
|
|
|
def _render_patched_entry(target: SwitchTarget, patched: dict) -> Syntax:
|
|
masked = _mask_secrets(patched)
|
|
if target.config_format == _CONFIG_FORMAT_CODEX and target.entry_key:
|
|
snippet = toml.dumps({_server_block_key(target.config_format): {target.entry_key: masked}})
|
|
return Syntax(snippet, "toml")
|
|
return Syntax(json.dumps(masked, indent=2), "json")
|
|
|
|
|
|
def _apply_profile_to_target(
|
|
target: SwitchTarget,
|
|
profile: MCPProfile,
|
|
*,
|
|
dry_run: bool = False,
|
|
) -> tuple[bool, Path | None]:
|
|
if target.entry is None or target.entry_key is None:
|
|
raise ValueError(f"{target.name} does not have a switchable Skyvern MCP entry.")
|
|
|
|
config, error = _load_switch_config(target.config_path, target.config_format)
|
|
if error:
|
|
raise ValueError(error)
|
|
|
|
existing = config or {}
|
|
server_block = _server_block_key(target.config_format)
|
|
servers = existing.setdefault(server_block, {})
|
|
current = servers.get(target.entry_key)
|
|
if not isinstance(current, dict):
|
|
raise ValueError(f"{target.name} does not have a valid Skyvern MCP entry.")
|
|
|
|
patched = _patch_entry_with_profile(current, profile, config_format=target.config_format)
|
|
if patched == current:
|
|
return False, None
|
|
|
|
if dry_run:
|
|
console.print(f"\n[bold]{target.name}[/bold] -> {target.config_path}")
|
|
console.print(_render_patched_entry(target, patched))
|
|
return True, None
|
|
|
|
servers[target.entry_key] = patched
|
|
backup_path = _write_switch_config(target.config_path, existing, target.config_format)
|
|
return True, backup_path
|
|
|
|
|
|
@profile_app.command("save")
|
|
def save_profile_command(
|
|
name: str = typer.Argument(..., help="Profile name, for example 'work-prod'."),
|
|
api_key: str | None = typer.Option(None, "--api-key", "-k", help="Skyvern API key (reads env/.env if omitted)"),
|
|
base_url: str | None = typer.Option(
|
|
None,
|
|
"--base-url",
|
|
help="Skyvern API base URL (reads env/.env if omitted, default: https://api.skyvern.com)",
|
|
),
|
|
overwrite: bool = typer.Option(False, "--overwrite", help="Replace an existing profile with the same name."),
|
|
) -> None:
|
|
env_key, env_base_url = _get_env_credentials()
|
|
resolved_api_key = api_key or env_key
|
|
if not resolved_api_key:
|
|
resolved_api_key = _prompt_text("Skyvern API key", password=True)
|
|
|
|
resolved_base_url = base_url or env_base_url or _DEFAULT_BASE_URL
|
|
profile = _build_profile(name, resolved_api_key, resolved_base_url)
|
|
path = _save_profile(profile, overwrite=overwrite)
|
|
console.print(f"[green]Saved MCP profile '{profile.name}' at {path}[/green]")
|
|
console.print(f"[dim]Saved MCP profiles store API keys in plaintext JSON under {path.parent}[/dim]")
|
|
|
|
|
|
@profile_app.command("list")
|
|
def list_profiles_command() -> None:
|
|
profiles = _list_profiles()
|
|
if not profiles:
|
|
console.print(f"[yellow]No saved MCP profiles found in {_profile_store_dir()}[/yellow]")
|
|
return
|
|
|
|
table = Table(show_header=True, header_style="bold")
|
|
table.add_column("Profile")
|
|
table.add_column("Base URL")
|
|
table.add_column("API Key")
|
|
|
|
for profile in profiles:
|
|
table.add_row(profile.name, profile.base_url, _mask_key(profile.api_key))
|
|
|
|
console.print(table)
|
|
console.print(f"[dim]Profile store: {_profile_store_dir()}[/dim]")
|
|
|
|
|
|
@mcp_app.command("switch")
|
|
def switch_command(
|
|
profile_name: str | None = typer.Option(None, "--profile", help="Saved profile name to use without prompting."),
|
|
dry_run: bool = typer.Option(False, "--dry-run", help="Preview updates without writing files."),
|
|
) -> None:
|
|
console.print(
|
|
Panel(
|
|
"[bold]Skyvern MCP Switcher[/bold]\n\n"
|
|
"This command finds supported local MCP configs, lets you choose which apps to update, "
|
|
"then swaps in a Skyvern API key/base URL from env, saved profiles, existing configs, or manual entry.",
|
|
border_style="blue",
|
|
)
|
|
)
|
|
|
|
discovered, missing = _discover_switch_targets()
|
|
if not discovered:
|
|
console.print(
|
|
"[red]No supported MCP client config files were found for Claude Code, Claude Desktop, Cursor, "
|
|
"Windsurf, or Codex.[/red]"
|
|
)
|
|
raise typer.Exit(code=1)
|
|
|
|
_print_discovery_results(discovered, missing)
|
|
|
|
selected_targets = _select_targets(discovered)
|
|
profile = _select_profile(profile_name, discovered)
|
|
|
|
console.print(
|
|
f"\n[bold]Selected source:[/bold] {profile.name}\n"
|
|
f"[dim]API key:[/dim] {_mask_key(profile.api_key)}\n"
|
|
f"[dim]Base URL:[/dim] {profile.base_url}\n"
|
|
f"[dim]Remote MCP URL:[/dim] {_profile_to_mcp_url(profile.base_url)}\n"
|
|
"[dim]Local stdio configs keep the base URL in SKYVERN_BASE_URL.[/dim]"
|
|
)
|
|
|
|
target_names = ", ".join(target.name for target in selected_targets)
|
|
if not dry_run and not Confirm.ask(f"Update {target_names} to use '{profile.name}'?", default=True):
|
|
raise typer.Abort()
|
|
|
|
updated: list[str] = []
|
|
unchanged: list[str] = []
|
|
backups: list[Path] = []
|
|
for target in selected_targets:
|
|
changed, backup_path = _apply_profile_to_target(target, profile, dry_run=dry_run)
|
|
if changed:
|
|
updated.append(target.name)
|
|
else:
|
|
unchanged.append(target.name)
|
|
if backup_path is not None:
|
|
backups.append(backup_path)
|
|
|
|
if dry_run:
|
|
console.print("\n[yellow]Dry run only. No config files were modified.[/yellow]")
|
|
return
|
|
|
|
if updated:
|
|
console.print(f"\n[green]Updated:[/green] {', '.join(updated)}")
|
|
if unchanged:
|
|
console.print(f"[dim]Already using that profile: {', '.join(unchanged)}[/dim]")
|
|
if backups:
|
|
console.print("[dim]Backups created:[/dim]")
|
|
for backup in backups:
|
|
console.print(f"[dim]- {backup}[/dim]")
|
|
|
|
console.print(
|
|
"\n[bold yellow]Restart Claude Code, Claude Desktop, Cursor, Windsurf, or Codex. "
|
|
"If you updated a project `.mcp.json`, reopen that project too.[/bold yellow]"
|
|
)
|