mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-04-28 11:40:32 +00:00
Some checks failed
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
zizmor / Audit GitHub Actions (push) Has been cancelled
Auto Create GitHub Release on Version Change / check-version-change (push) Has been cancelled
Build Skyvern SDK and publish to PyPI / check-version-change (push) Has been cancelled
Auto Create GitHub Release on Version Change / create-release (push) Has been cancelled
Build Skyvern SDK and publish to PyPI / build-sdk (push) Has been cancelled
Build Skyvern SDK and publish to PyPI / run-ci (push) Has been cancelled
701 lines
25 KiB
Python
701 lines
25 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import stat
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import toml
|
|
from typer.testing import CliRunner
|
|
|
|
from skyvern.cli.mcp_commands import (
|
|
MCPProfile,
|
|
SwitchTarget,
|
|
_apply_profile_to_target,
|
|
_build_profile,
|
|
_collect_profile_choices,
|
|
_discover_switch_targets,
|
|
_entry_kind,
|
|
_list_profiles,
|
|
_load_profile_from_path,
|
|
_patch_entry_with_profile,
|
|
_profile_to_mcp_url,
|
|
_sanitize_prompt_response,
|
|
_save_profile,
|
|
mcp_app,
|
|
)
|
|
|
|
|
|
def test_save_profile_and_list_round_trip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._profile_store_dir", lambda: tmp_path)
|
|
|
|
profile = _build_profile("Work Prod", "sk-test-1234567890", "https://api.skyvern.com/")
|
|
saved_path = _save_profile(profile)
|
|
|
|
assert saved_path.exists()
|
|
assert _list_profiles() == [
|
|
MCPProfile(name="Work Prod", api_key="sk-test-1234567890", base_url="https://api.skyvern.com")
|
|
]
|
|
|
|
|
|
def test_save_profile_command_sanitizes_prompted_api_key_and_warns(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._profile_store_dir", lambda: tmp_path)
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._get_env_credentials", lambda: ("", ""))
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands.Prompt.ask", lambda *args, **kwargs: "\x1b[Cprompt-key-1234567890")
|
|
|
|
result = CliRunner().invoke(mcp_app, ["profile", "save", "Work Prod"])
|
|
|
|
assert result.exit_code == 0
|
|
saved = json.loads((tmp_path / "work-prod.json").read_text(encoding="utf-8"))
|
|
assert saved["api_key"] == "prompt-key-1234567890"
|
|
assert "plaintext JSON" in result.output
|
|
|
|
|
|
def test_save_profile_restricts_permissions_on_posix(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
if os.name == "nt":
|
|
pytest.skip("POSIX permissions are not enforced on Windows in the same way.")
|
|
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._profile_store_dir", lambda: tmp_path / "profiles")
|
|
|
|
saved_path = _save_profile(_build_profile("Work Prod", "sk-test-1234567890", "https://api.skyvern.com"))
|
|
|
|
assert stat.S_IMODE(saved_path.stat().st_mode) == 0o600
|
|
assert stat.S_IMODE(saved_path.parent.stat().st_mode) == 0o700
|
|
|
|
|
|
def test_load_profile_from_path_rejects_non_string_fields(tmp_path: Path) -> None:
|
|
profile_path = tmp_path / "invalid.json"
|
|
profile_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"name": "Work Prod",
|
|
"api_key": {"secret": "bad"},
|
|
"base_url": "https://api.skyvern.com",
|
|
}
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="field 'api_key' must be a string"):
|
|
_load_profile_from_path(profile_path)
|
|
|
|
|
|
def test_apply_profile_to_target_updates_local_entry_and_creates_backup(tmp_path: Path) -> None:
|
|
config_path = tmp_path / "mcp.json"
|
|
original_config = {
|
|
"mcpServers": {
|
|
"Skyvern": {
|
|
"command": "/opt/homebrew/bin/python3.11",
|
|
"args": ["-m", "skyvern", "run", "mcp"],
|
|
"env": {
|
|
"SKYVERN_BASE_URL": "http://localhost:8000",
|
|
"SKYVERN_API_KEY": "old-key",
|
|
"OTHER": "keep-me",
|
|
},
|
|
}
|
|
}
|
|
}
|
|
config_path.write_text(json.dumps(original_config, indent=2) + "\n", encoding="utf-8")
|
|
|
|
target = SwitchTarget(
|
|
name="Cursor",
|
|
config_path=config_path,
|
|
entry_key="Skyvern",
|
|
entry=original_config["mcpServers"]["Skyvern"],
|
|
)
|
|
profile = _build_profile("Prod", "new-key-1234567890", "https://api.skyvern.com")
|
|
|
|
changed, backup_path = _apply_profile_to_target(target, profile)
|
|
|
|
assert changed is True
|
|
assert backup_path is not None
|
|
assert backup_path.exists()
|
|
|
|
written = json.loads(config_path.read_text(encoding="utf-8"))
|
|
written_entry = written["mcpServers"]["Skyvern"]
|
|
assert written_entry["command"] == "/opt/homebrew/bin/python3.11"
|
|
assert written_entry["args"] == ["-m", "skyvern", "run", "mcp"]
|
|
assert written_entry["env"]["SKYVERN_API_KEY"] == "new-key-1234567890"
|
|
assert written_entry["env"]["SKYVERN_BASE_URL"] == "https://api.skyvern.com"
|
|
assert written_entry["env"]["OTHER"] == "keep-me"
|
|
|
|
backup = json.loads(backup_path.read_text(encoding="utf-8"))
|
|
assert backup["mcpServers"]["Skyvern"]["env"]["SKYVERN_API_KEY"] == "old-key"
|
|
|
|
|
|
def test_patch_entry_with_profile_updates_mcp_remote_bridge() -> None:
|
|
profile = _build_profile("Cloud Alt", "new-key-1234567890", "https://alt.skyvern.example")
|
|
entry = {
|
|
"command": "npx",
|
|
"args": [
|
|
"mcp-remote",
|
|
"https://api.skyvern.com/mcp/",
|
|
"--header",
|
|
"x-api-key:old-key",
|
|
"--transport",
|
|
"stdio",
|
|
],
|
|
}
|
|
|
|
patched = _patch_entry_with_profile(entry, profile, config_format="json5")
|
|
|
|
assert patched["args"][0] == "mcp-remote"
|
|
assert patched["args"][1] == "https://alt.skyvern.example/mcp/"
|
|
assert patched["args"].count("--header") == 1
|
|
assert "x-api-key:new-key-1234567890" in patched["args"]
|
|
assert "x-api-key:old-key" not in patched["args"]
|
|
|
|
|
|
def test_patch_entry_with_profile_updates_mcp_remote_bridge_even_with_env() -> None:
|
|
profile = _build_profile("Cloud Alt", "new-key-1234567890", "https://alt.skyvern.example")
|
|
entry = {
|
|
"command": "npx",
|
|
"args": [
|
|
"mcp-remote",
|
|
"https://api.skyvern.com/mcp/",
|
|
"--header",
|
|
"x-api-key:old-key",
|
|
],
|
|
"env": {
|
|
"DEBUG": "1",
|
|
"SKYVERN_API_KEY": "do-not-touch",
|
|
},
|
|
}
|
|
|
|
patched = _patch_entry_with_profile(entry, profile, config_format="json5")
|
|
|
|
assert _entry_kind(entry) == "mcp-remote bridge"
|
|
assert "x-api-key:new-key-1234567890" in patched["args"]
|
|
assert patched["env"]["SKYVERN_API_KEY"] == "do-not-touch"
|
|
|
|
|
|
def test_entry_kind_requires_exact_npx_for_mcp_remote_bridge() -> None:
|
|
entry = {
|
|
"command": "npx-wrapper",
|
|
"args": ["mcp-remote", "https://api.skyvern.com/mcp/"],
|
|
}
|
|
|
|
assert _entry_kind(entry) == "unsupported"
|
|
|
|
|
|
def test_sanitize_prompt_response_strips_arrow_escape_noise() -> None:
|
|
assert _sanitize_prompt_response("\x1b[C\x1b[C\x1b[Call") == "all"
|
|
|
|
|
|
def test_profile_to_mcp_url_normalizes_user_base_url() -> None:
|
|
assert _profile_to_mcp_url("https://api.skyvern.com") == "https://api.skyvern.com/mcp/"
|
|
assert _profile_to_mcp_url("https://api.skyvern.com/") == "https://api.skyvern.com/mcp/"
|
|
assert _profile_to_mcp_url("https://api.skyvern.com/mcp/") == "https://api.skyvern.com/mcp/"
|
|
|
|
|
|
def test_apply_profile_to_target_updates_codex_entry_and_creates_backup(tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text(
|
|
toml.dumps(
|
|
{
|
|
"model": "gpt-5.4",
|
|
"mcp_servers": {
|
|
"skyvern": {
|
|
"url": "https://api.skyvern.com/mcp/",
|
|
"http_headers": {"x-api-key": "old-key"},
|
|
"startup_timeout_sec": 30,
|
|
"tool_timeout_sec": 120,
|
|
}
|
|
},
|
|
}
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
target = SwitchTarget(
|
|
name="Codex",
|
|
config_path=config_path,
|
|
entry_key="skyvern",
|
|
entry={
|
|
"url": "https://api.skyvern.com/mcp/",
|
|
"http_headers": {"x-api-key": "old-key"},
|
|
"startup_timeout_sec": 30,
|
|
"tool_timeout_sec": 120,
|
|
},
|
|
config_format="codex_toml",
|
|
)
|
|
profile = _build_profile("Prod", "new-key-1234567890", "https://alt.skyvern.example")
|
|
|
|
changed, backup_path = _apply_profile_to_target(target, profile)
|
|
|
|
assert changed is True
|
|
assert backup_path is not None
|
|
assert backup_path.exists()
|
|
|
|
written = toml.loads(config_path.read_text(encoding="utf-8"))
|
|
written_entry = written["mcp_servers"]["skyvern"]
|
|
assert written_entry["url"] == "https://alt.skyvern.example/mcp/"
|
|
assert written_entry["http_headers"]["x-api-key"] == "new-key-1234567890"
|
|
assert written_entry["startup_timeout_sec"] == 30
|
|
assert written_entry["tool_timeout_sec"] == 120
|
|
|
|
backup = toml.loads(backup_path.read_text(encoding="utf-8"))
|
|
assert backup["mcp_servers"]["skyvern"]["http_headers"]["x-api-key"] == "old-key"
|
|
|
|
|
|
def test_patch_entry_with_profile_preserves_openclaw_transport_and_extras() -> None:
|
|
profile = _build_profile("Prod", "new-key-1234567890", "https://alt.skyvern.example")
|
|
entry = {
|
|
"url": "https://api.skyvern.com/mcp/",
|
|
"transport": "streamable-http",
|
|
"headers": {"x-api-key": "old-key"},
|
|
"connectionTimeoutMs": 120000,
|
|
}
|
|
|
|
patched = _patch_entry_with_profile(entry, profile, config_format="json5")
|
|
|
|
assert patched["url"] == "https://alt.skyvern.example/mcp/"
|
|
assert patched["transport"] == "streamable-http"
|
|
assert patched["headers"]["x-api-key"] == "new-key-1234567890"
|
|
assert patched["connectionTimeoutMs"] == 120000
|
|
assert "type" not in patched
|
|
|
|
|
|
def test_patch_entry_with_profile_repairs_openclaw_remote_shape_without_transport() -> None:
|
|
profile = _build_profile("Prod", "new-key-1234567890", "https://alt.skyvern.example")
|
|
entry = {
|
|
"type": "http",
|
|
"url": "https://api.skyvern.com/mcp/",
|
|
"headers": {"x-api-key": "old-key"},
|
|
"connectionTimeoutMs": 120000,
|
|
}
|
|
|
|
patched = _patch_entry_with_profile(entry, profile, config_format="json5")
|
|
|
|
assert patched["url"] == "https://alt.skyvern.example/mcp/"
|
|
assert patched["transport"] == "streamable-http"
|
|
assert patched["headers"]["x-api-key"] == "new-key-1234567890"
|
|
assert patched["connectionTimeoutMs"] == 120000
|
|
assert "type" not in patched
|
|
assert "http_headers" not in patched
|
|
|
|
|
|
def test_apply_profile_to_target_updates_openclaw_entry_and_creates_backup(tmp_path: Path) -> None:
|
|
config_path = tmp_path / "openclaw.json"
|
|
config_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"mcp": {
|
|
"servers": {
|
|
"skyvern": {
|
|
"url": "https://api.skyvern.com/mcp/",
|
|
"transport": "streamable-http",
|
|
"headers": {"x-api-key": "old-key"},
|
|
"connectionTimeoutMs": 120000,
|
|
}
|
|
}
|
|
}
|
|
},
|
|
indent=2,
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
target = SwitchTarget(
|
|
name="OpenClaw",
|
|
config_path=config_path,
|
|
entry_key="skyvern",
|
|
entry={
|
|
"url": "https://api.skyvern.com/mcp/",
|
|
"transport": "streamable-http",
|
|
"headers": {"x-api-key": "old-key"},
|
|
"connectionTimeoutMs": 120000,
|
|
},
|
|
config_format="json5",
|
|
entry_path=["mcp", "servers"],
|
|
)
|
|
profile = _build_profile("Prod", "new-key-1234567890", "https://alt.skyvern.example")
|
|
|
|
changed, backup_path = _apply_profile_to_target(target, profile)
|
|
|
|
assert changed is True
|
|
assert backup_path is not None
|
|
assert backup_path.exists()
|
|
|
|
written = json.loads(config_path.read_text(encoding="utf-8"))
|
|
entry = written["mcp"]["servers"]["skyvern"]
|
|
assert entry["url"] == "https://alt.skyvern.example/mcp/"
|
|
assert entry["transport"] == "streamable-http"
|
|
assert entry["headers"]["x-api-key"] == "new-key-1234567890"
|
|
assert entry["connectionTimeoutMs"] == 120000
|
|
|
|
backup = json.loads(backup_path.read_text(encoding="utf-8"))
|
|
assert backup["mcp"]["servers"]["skyvern"]["headers"]["x-api-key"] == "old-key"
|
|
|
|
|
|
def test_apply_profile_to_target_repairs_openclaw_remote_shape_without_transport(tmp_path: Path) -> None:
|
|
config_path = tmp_path / "openclaw.json"
|
|
config_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"mcp": {
|
|
"servers": {
|
|
"skyvern": {
|
|
"type": "http",
|
|
"url": "https://api.skyvern.com/mcp/",
|
|
"headers": {"x-api-key": "old-key"},
|
|
"connectionTimeoutMs": 120000,
|
|
}
|
|
}
|
|
}
|
|
},
|
|
indent=2,
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
target = SwitchTarget(
|
|
name="OpenClaw",
|
|
config_path=config_path,
|
|
entry_key="skyvern",
|
|
entry={
|
|
"type": "http",
|
|
"url": "https://api.skyvern.com/mcp/",
|
|
"headers": {"x-api-key": "old-key"},
|
|
"connectionTimeoutMs": 120000,
|
|
},
|
|
config_format="json5",
|
|
entry_path=["mcp", "servers"],
|
|
)
|
|
profile = _build_profile("Prod", "new-key-1234567890", "https://alt.skyvern.example")
|
|
|
|
changed, backup_path = _apply_profile_to_target(target, profile)
|
|
|
|
assert changed is True
|
|
assert backup_path is not None
|
|
written = json.loads(config_path.read_text(encoding="utf-8"))
|
|
entry = written["mcp"]["servers"]["skyvern"]
|
|
assert entry["url"] == "https://alt.skyvern.example/mcp/"
|
|
assert entry["transport"] == "streamable-http"
|
|
assert entry["headers"]["x-api-key"] == "new-key-1234567890"
|
|
assert entry["connectionTimeoutMs"] == 120000
|
|
assert "type" not in entry
|
|
|
|
|
|
def test_collect_profile_choices_includes_env_and_existing_config(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._profile_store_dir", lambda: tmp_path / "profiles")
|
|
monkeypatch.setattr(
|
|
"skyvern.cli.mcp_commands._get_env_credentials",
|
|
lambda: ("env-key-1234567890", "https://api.skyvern.com"),
|
|
)
|
|
|
|
target = SwitchTarget(
|
|
name="Cursor",
|
|
config_path=tmp_path / "mcp.json",
|
|
entry_key="Skyvern",
|
|
entry={
|
|
"command": "npx",
|
|
"args": ["mcp-remote", "https://staging.skyvern.example/mcp/", "--header", "x-api-key:staging-key"],
|
|
},
|
|
)
|
|
|
|
choices = _collect_profile_choices([target])
|
|
|
|
assert len(choices) == 2
|
|
assert any(choice.label == "Current environment" for choice in choices)
|
|
assert any(choice.label == "Cursor current config" for choice in choices)
|
|
|
|
|
|
def test_switch_uses_env_candidate_without_saved_profile(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
config_path = tmp_path / "mcp.json"
|
|
config_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"mcpServers": {
|
|
"Skyvern": {
|
|
"command": "skyvern",
|
|
"args": ["run", "mcp"],
|
|
"env": {
|
|
"SKYVERN_BASE_URL": "http://localhost:8000",
|
|
"SKYVERN_API_KEY": "old-key",
|
|
},
|
|
}
|
|
}
|
|
},
|
|
indent=2,
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
target = SwitchTarget(
|
|
name="Cursor",
|
|
config_path=config_path,
|
|
entry_key="Skyvern",
|
|
entry={
|
|
"command": "skyvern",
|
|
"args": ["run", "mcp"],
|
|
"env": {
|
|
"SKYVERN_BASE_URL": "http://localhost:8000",
|
|
"SKYVERN_API_KEY": "old-key",
|
|
},
|
|
},
|
|
)
|
|
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._discover_switch_targets", lambda: ([target], []))
|
|
monkeypatch.setattr(
|
|
"skyvern.cli.mcp_commands._get_env_credentials",
|
|
lambda: ("env-key-1234567890", "https://api.skyvern.com"),
|
|
)
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._profile_store_dir", lambda: tmp_path / "profiles")
|
|
|
|
result = CliRunner().invoke(mcp_app, ["switch"], input="1\ny\n")
|
|
|
|
assert result.exit_code == 0
|
|
written = json.loads(config_path.read_text(encoding="utf-8"))
|
|
assert written["mcpServers"]["Skyvern"]["env"]["SKYVERN_API_KEY"] == "env-key-1234567890"
|
|
assert written["mcpServers"]["Skyvern"]["env"]["SKYVERN_BASE_URL"] == "https://api.skyvern.com"
|
|
|
|
|
|
def test_switch_manual_entry_does_not_prompt_for_profile_name_and_normalizes_remote_base_url(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
config_path = tmp_path / "claude.json"
|
|
config_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"mcpServers": {
|
|
"Skyvern": {
|
|
"type": "http",
|
|
"url": "https://old.skyvern.example/mcp/",
|
|
"headers": {
|
|
"x-api-key": "old-key",
|
|
},
|
|
}
|
|
}
|
|
},
|
|
indent=2,
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
target = SwitchTarget(
|
|
name="Claude Code (global)",
|
|
config_path=config_path,
|
|
entry_key="Skyvern",
|
|
entry={
|
|
"type": "http",
|
|
"url": "https://old.skyvern.example/mcp/",
|
|
"headers": {
|
|
"x-api-key": "old-key",
|
|
},
|
|
},
|
|
)
|
|
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._discover_switch_targets", lambda: ([target], []))
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._get_env_credentials", lambda: ("", "https://api.skyvern.com"))
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._profile_store_dir", lambda: tmp_path / "profiles")
|
|
prompt_calls: list[str] = []
|
|
prompt_values = iter(
|
|
[
|
|
"2",
|
|
"manual-key-1234567890",
|
|
"https://alt.skyvern.example/mcp/",
|
|
]
|
|
)
|
|
|
|
def fake_prompt(prompt: str, *, default: str | None = None, password: bool = False) -> str:
|
|
prompt_calls.append(prompt)
|
|
return next(prompt_values)
|
|
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._prompt_text", fake_prompt)
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands.Confirm.ask", lambda *args, **kwargs: True)
|
|
|
|
result = CliRunner().invoke(mcp_app, ["switch"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "Available switch sources" in result.output
|
|
assert "Available switch profiles" not in result.output
|
|
assert "Selected source:" in result.output
|
|
assert "Remote MCP URL:" in result.output
|
|
assert "Profile name" not in prompt_calls
|
|
|
|
written = json.loads(config_path.read_text(encoding="utf-8"))
|
|
entry = written["mcpServers"]["Skyvern"]
|
|
assert entry["headers"]["x-api-key"] == "manual-key-1234567890"
|
|
assert entry["url"] == "https://alt.skyvern.example/mcp/"
|
|
|
|
|
|
def test_switch_accepts_arrow_key_noise_in_target_prompt(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
config_a = tmp_path / "claude.json"
|
|
config_b = tmp_path / "codex.json"
|
|
for config_path in (config_a, config_b):
|
|
config_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"mcpServers": {
|
|
"Skyvern": {
|
|
"command": "skyvern",
|
|
"args": ["run", "mcp"],
|
|
"env": {
|
|
"SKYVERN_BASE_URL": "http://localhost:8000",
|
|
"SKYVERN_API_KEY": "old-key",
|
|
},
|
|
}
|
|
}
|
|
},
|
|
indent=2,
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
targets = [
|
|
SwitchTarget(
|
|
name="Claude Code (global)",
|
|
config_path=config_a,
|
|
entry_key="Skyvern",
|
|
entry={
|
|
"command": "skyvern",
|
|
"args": ["run", "mcp"],
|
|
"env": {
|
|
"SKYVERN_BASE_URL": "http://localhost:8000",
|
|
"SKYVERN_API_KEY": "old-key",
|
|
},
|
|
},
|
|
),
|
|
SwitchTarget(
|
|
name="Codex",
|
|
config_path=config_b,
|
|
entry_key="Skyvern",
|
|
entry={
|
|
"command": "skyvern",
|
|
"args": ["run", "mcp"],
|
|
"env": {
|
|
"SKYVERN_BASE_URL": "http://localhost:8000",
|
|
"SKYVERN_API_KEY": "old-key",
|
|
},
|
|
},
|
|
),
|
|
]
|
|
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._discover_switch_targets", lambda: (targets, []))
|
|
monkeypatch.setattr(
|
|
"skyvern.cli.mcp_commands._select_profile",
|
|
lambda profile_name, discovered: MCPProfile(
|
|
name="Env",
|
|
api_key="env-key-1234567890",
|
|
base_url="https://api.skyvern.com",
|
|
),
|
|
)
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._profile_store_dir", lambda: tmp_path / "profiles")
|
|
|
|
result = CliRunner().invoke(mcp_app, ["switch"], input="\x1b[C\x1b[C\x1b[Call\ny\n")
|
|
|
|
assert result.exit_code == 0
|
|
for config_path in (config_a, config_b):
|
|
written = json.loads(config_path.read_text(encoding="utf-8"))
|
|
assert written["mcpServers"]["Skyvern"]["env"]["SKYVERN_API_KEY"] == "env-key-1234567890"
|
|
assert written["mcpServers"]["Skyvern"]["env"]["SKYVERN_BASE_URL"] == "https://api.skyvern.com"
|
|
|
|
|
|
def test_discover_switch_targets_finds_claude_code_and_codex(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
global_config = tmp_path / ".claude.json"
|
|
global_config.write_text(
|
|
json.dumps({"mcpServers": {"skyvern": {"type": "http", "url": "https://api.skyvern.com/mcp/"}}}) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
project_config = tmp_path / ".mcp.json"
|
|
project_config.write_text(
|
|
json.dumps({"mcpServers": {"Skyvern": {"command": "skyvern", "args": ["run", "mcp"], "env": {}}}}) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
codex_config = tmp_path / "config.toml"
|
|
codex_config.write_text(
|
|
toml.dumps({"mcp_servers": {"skyvern": {"url": "https://api.skyvern.com/mcp/"}}}) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
openclaw_config = tmp_path / "openclaw.json"
|
|
openclaw_config.write_text(
|
|
json.dumps(
|
|
{
|
|
"mcp": {
|
|
"servers": {
|
|
"skyvern": {
|
|
"url": "https://api.skyvern.com/mcp/",
|
|
"transport": "streamable-http",
|
|
}
|
|
}
|
|
}
|
|
}
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._claude_code_global_config_path", lambda: global_config)
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._claude_code_project_config_path", lambda: project_config)
|
|
monkeypatch.setattr(
|
|
"skyvern.cli.mcp_commands._claude_desktop_config_path", lambda: tmp_path / "missing-claude.json"
|
|
)
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._cursor_config_path", lambda: tmp_path / "missing-cursor.json")
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._windsurf_config_path", lambda: tmp_path / "missing-windsurf.json")
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._codex_config_path", lambda: codex_config)
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._openclaw_config_path", lambda: openclaw_config)
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._hermes_config_path", lambda: tmp_path / "missing-hermes.yaml")
|
|
|
|
discovered, missing = _discover_switch_targets()
|
|
|
|
discovered_by_name = {target.name: target for target in discovered}
|
|
assert "Claude Code (global)" in discovered_by_name
|
|
assert "Claude Code (project)" in discovered_by_name
|
|
assert "Codex" in discovered_by_name
|
|
assert "OpenClaw" in discovered_by_name
|
|
assert discovered_by_name["Codex"].config_format == "codex_toml"
|
|
assert discovered_by_name["Codex"].entry_key == "skyvern"
|
|
assert discovered_by_name["OpenClaw"].entry_key == "skyvern"
|
|
assert discovered_by_name["OpenClaw"].entry_path == ["mcp", "servers"]
|
|
assert {name for name, _ in missing} == {"Claude Desktop", "Cursor", "Windsurf", "Hermes"}
|
|
|
|
|
|
def test_discover_switch_targets_reports_openclaw_invalid_nested_structure(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
openclaw_config = tmp_path / "openclaw.json"
|
|
openclaw_config.write_text(json.dumps({"mcp": "bad"}) + "\n", encoding="utf-8")
|
|
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._claude_code_global_config_path", lambda: tmp_path / "missing-a.json")
|
|
monkeypatch.setattr(
|
|
"skyvern.cli.mcp_commands._claude_code_project_config_path", lambda: tmp_path / "missing-b.json"
|
|
)
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._claude_desktop_config_path", lambda: tmp_path / "missing-c.json")
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._cursor_config_path", lambda: tmp_path / "missing-d.json")
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._windsurf_config_path", lambda: tmp_path / "missing-e.json")
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._codex_config_path", lambda: tmp_path / "missing-f.toml")
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._openclaw_config_path", lambda: openclaw_config)
|
|
monkeypatch.setattr("skyvern.cli.mcp_commands._hermes_config_path", lambda: tmp_path / "missing-g.yaml")
|
|
|
|
discovered, _missing = _discover_switch_targets()
|
|
|
|
openclaw_target = next(target for target in discovered if target.name == "OpenClaw")
|
|
assert openclaw_target.entry is None
|
|
assert openclaw_target.error is not None
|
|
assert "Invalid nested structure" in openclaw_target.error
|