Add Claude Desktop configuration via skyvern run mcp (#2045)

This commit is contained in:
Jose
2025-04-02 10:33:37 -06:00
committed by GitHub
parent b2ae3f999c
commit d168da4653
5 changed files with 799 additions and 56 deletions

468
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -52,6 +52,7 @@ pyotp = "^2.9.0"
asyncpg = "^0.30.0" asyncpg = "^0.30.0"
json-repair = "^0.34.0" json-repair = "^0.34.0"
pypdf = "^5.1.0" pypdf = "^5.1.0"
fastmcp = "^0.4.1"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
isort = "^5.13.2" isort = "^5.13.2"

View File

@@ -1,11 +1,17 @@
import json
import os
import shutil import shutil
import subprocess import subprocess
import time import time
from typing import Optional from typing import Optional
import typer import typer
from click import Choice
from dotenv import load_dotenv
from skyvern.utils import migrate_db from skyvern.utils import detect_os, get_windows_appdata_roaming, migrate_db
load_dotenv()
app = typer.Typer() app = typer.Typer()
run_app = typer.Typer() run_app = typer.Typer()
@@ -129,6 +135,300 @@ def migrate() -> None:
migrate_db() migrate_db()
def get_claude_config_path(host_system: str) -> str:
"""Get the Claude Desktop config file path for the current OS."""
if host_system == "wsl":
roaming_path = get_windows_appdata_roaming()
if roaming_path is None:
raise RuntimeError("Could not locate Windows AppData\\Roaming path from WSL")
return os.path.join(str(roaming_path), "Claude", "claude_desktop_config.json")
base_paths = {
"darwin": ["~/Library/Application Support/Claude"],
"linux": ["~/.config/Claude", "~/.local/share/Claude", "~/Claude"],
}
if host_system == "darwin":
base_path = os.path.expanduser(base_paths["darwin"][0])
return os.path.join(base_path, "claude_desktop_config.json")
if host_system == "linux":
for path in base_paths["linux"]:
full_path = os.path.expanduser(path)
if os.path.exists(full_path):
return os.path.join(full_path, "claude_desktop_config.json")
raise Exception(f"Unsupported host system: {host_system}")
def get_claude_command_config(
host_system: str, path_to_env: str, path_to_server: str, env_vars: str
) -> tuple[str, list]:
"""Get the command and arguments for Claude Desktop configuration."""
base_env_vars = f"{env_vars} ENABLE_OPENAI=true LOG_LEVEL=CRITICAL"
artifacts_path = os.path.join(os.path.abspath("./"), "artifacts")
if host_system == "wsl":
env_vars = f"{base_env_vars} ARTIFACT_STORAGE_PATH={artifacts_path} BROWSER_TYPE=chromium-headless"
return "wsl.exe", ["bash", "-c", f"{env_vars} {path_to_env} {path_to_server}"]
if host_system in ["linux", "darwin"]:
env_vars = f"{base_env_vars} ARTIFACT_STORAGE_PATH={artifacts_path}"
return path_to_env, [path_to_server]
raise Exception(f"Unsupported host system: {host_system}")
def is_claude_desktop_installed(host_system: str) -> bool:
"""Check if Claude Desktop is installed by looking for its config directory."""
try:
config_path = os.path.dirname(get_claude_config_path(host_system))
return os.path.exists(config_path)
except Exception:
return False
def get_cursor_config_path(host_system: str) -> str:
"""Get the Cursor config file path for the current OS."""
if host_system == "wsl":
roaming_path = get_windows_appdata_roaming()
if roaming_path is None:
raise RuntimeError("Could not locate Windows AppData\\Roaming path from WSL")
return os.path.join(str(roaming_path), ".cursor", "mcp.json")
# For both darwin and linux, use ~/.cursor/mcp.json
return os.path.expanduser("~/.cursor/mcp.json")
def is_cursor_installed(host_system: str) -> bool:
"""Check if Cursor is installed by looking for its config directory."""
try:
config_dir = os.path.expanduser("~/.cursor")
return os.path.exists(config_dir)
except Exception:
return False
def setup_cursor_mcp(host_system: str, path_to_env: str, path_to_server: str, env_vars: str) -> None:
"""Set up Cursor MCP configuration."""
if not is_cursor_installed(host_system):
print("Cursor is not installed. Skipping Cursor MCP setup.")
return
try:
path_cursor_config = get_cursor_config_path(host_system)
except Exception as e:
print(f"Error setting up Cursor: {e}")
return
# Get command configuration
try:
command, args = get_claude_command_config(host_system, path_to_env, path_to_server, env_vars)
except Exception as e:
print(f"Error configuring Cursor command: {e}")
return
# Create or update Cursor config file
os.makedirs(os.path.dirname(path_cursor_config), exist_ok=True)
config = {"Skyvern": {"command": command, "args": args}}
if os.path.exists(path_cursor_config):
try:
with open(path_cursor_config, "r") as f:
existing_config = json.load(f)
existing_config.update(config)
config = existing_config
except json.JSONDecodeError:
pass # Use default config if file is corrupted
with open(path_cursor_config, "w") as f:
json.dump(config, f, indent=2)
print("Cursor MCP configuration updated successfully.")
def setup_claude_desktop(host_system: str, path_to_env: str, path_to_server: str) -> None:
"""Set up Claude Desktop configuration for Skyvern MCP."""
if not is_claude_desktop_installed(host_system):
print("Claude Desktop is not installed. Skipping MCP setup.")
return
# Get config file path
try:
path_claude_config = get_claude_config_path(host_system)
except Exception as e:
print(f"Error setting up Claude Desktop: {e}")
return
# Setup environment variables
env_vars = ""
for key in ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]:
value = os.getenv(key)
if value is None:
value = typer.prompt(f"Enter your {key}")
env_vars += f"{key}={value} "
# Get command configuration
try:
claude_command, claude_args = get_claude_command_config(host_system, path_to_env, path_to_server, env_vars)
except Exception as e:
print(f"Error configuring Claude Desktop command: {e}")
return
# Create or update Claude config file
os.makedirs(os.path.dirname(path_claude_config), exist_ok=True)
if not os.path.exists(path_claude_config):
with open(path_claude_config, "w") as f:
json.dump({"mcpServers": {}}, f, indent=2)
with open(path_claude_config, "r") as f:
claude_config = json.load(f)
claude_config["mcpServers"].pop("Skyvern", None)
claude_config["mcpServers"]["Skyvern"] = {"command": claude_command, "args": claude_args}
with open(path_claude_config, "w") as f:
json.dump(claude_config, f, indent=2)
print("Claude Desktop configuration updated successfully.")
def get_mcp_server_url(deployment_type: str, host: str = "") -> str:
"""Get the MCP server URL based on deployment type."""
if deployment_type in ["local", "cloud"]:
return os.path.join(os.path.abspath("./skyvern/mcp"), "server.py")
else:
raise ValueError(f"Invalid deployment type: {deployment_type}")
def setup_mcp_config(host_system: str, deployment_type: str, host: str = "") -> tuple[str, str]:
"""Set up MCP configuration based on deployment type."""
if deployment_type in ["local", "cloud"]:
# For local deployment, we need Python environment
python_path = shutil.which("python")
if python_path:
path_to_env = python_path
else:
path_to_env = typer.prompt("Enter the full path to your configured python environment")
return path_to_env, get_mcp_server_url(deployment_type)
else:
raise NotImplementedError()
def get_command_config(host_system: str, command: str, target: str, env_vars: str) -> tuple[str, list]:
"""Get the command and arguments for MCP configuration."""
base_env_vars = f"{env_vars} ENABLE_OPENAI=true LOG_LEVEL=CRITICAL"
artifacts_path = os.path.join(os.path.abspath("./"), "artifacts")
if host_system == "wsl":
env_vars = f"{base_env_vars} ARTIFACT_STORAGE_PATH={artifacts_path} BROWSER_TYPE=chromium-headless"
return "wsl.exe", ["bash", "-c", f"{env_vars} {command} {target}"]
if host_system in ["linux", "darwin"]:
env_vars = f"{base_env_vars} ARTIFACT_STORAGE_PATH={artifacts_path}"
if target.startswith("http"):
return command, ["-X", "POST", target]
return command, [target]
raise Exception(f"Unsupported host system: {host_system}")
@run_app.command(name="mcp") @run_app.command(name="mcp")
def run_mcp() -> None: def run_mcp() -> None:
pass """Configure MCP for different Skyvern deployments."""
host_system = detect_os()
# Prompt for deployment type
deployment_types = ["local", "cloud"]
deployment_type = typer.prompt("Select Skyvern deployment type", type=Choice(deployment_types), default="local")
try:
command, target = setup_mcp_config(host_system, deployment_type)
except Exception as e:
print(f"Error setting up MCP configuration: {e}")
return
# Cloud deployment variables
env_vars = ""
if deployment_type == "cloud":
for key in ["SKYVERN_MCP_CLOUD_URL", "SKYVERN_MCP_API_KEY"]:
value = os.getenv(key)
if value is None:
value = typer.prompt(f"Enter your {key}")
env_vars += f"{key}={value} "
# Setup environment variables
for key in ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]:
value = os.getenv(key)
if value is None:
value = typer.prompt(f"Enter your {key}")
env_vars += f"{key}={value} "
# Configure both Claude Desktop and Cursor
success = False
success |= setup_claude_desktop_config(host_system, command, target, env_vars)
success |= setup_cursor_config(host_system, command, target, env_vars)
if not success:
print("Neither Claude Desktop nor Cursor is installed. Please install at least one of them.")
def setup_claude_desktop_config(host_system: str, command: str, target: str, env_vars: str) -> bool:
"""Set up Claude Desktop configuration with given command and args."""
if not is_claude_desktop_installed(host_system):
return False
try:
claude_command, claude_args = get_command_config(host_system, command, target, env_vars)
path_claude_config = get_claude_config_path(host_system)
os.makedirs(os.path.dirname(path_claude_config), exist_ok=True)
if not os.path.exists(path_claude_config):
with open(path_claude_config, "w") as f:
json.dump({"mcpServers": {}}, f, indent=2)
with open(path_claude_config, "r") as f:
claude_config = json.load(f)
claude_config["mcpServers"].pop("Skyvern", None)
claude_config["mcpServers"]["Skyvern"] = {"command": claude_command, "args": claude_args}
with open(path_claude_config, "w") as f:
json.dump(claude_config, f, indent=2)
print("Claude Desktop configuration updated successfully.")
return True
except Exception as e:
print(f"Error configuring Claude Desktop: {e}")
return False
def setup_cursor_config(host_system: str, command: str, target: str, env_vars: str) -> bool:
"""Set up Cursor configuration with given command and args."""
if not is_cursor_installed(host_system):
return False
try:
cursor_command, cursor_args = get_command_config(host_system, command, target, env_vars)
path_cursor_config = get_cursor_config_path(host_system)
os.makedirs(os.path.dirname(path_cursor_config), exist_ok=True)
config = {"Skyvern": {"command": cursor_command, "args": cursor_args}}
if os.path.exists(path_cursor_config):
try:
with open(path_cursor_config, "r") as f:
existing_config = json.load(f)
existing_config.update(config)
config = existing_config
except json.JSONDecodeError:
pass
with open(path_cursor_config, "w") as f:
json.dump(config, f, indent=2)
print(f"Cursor configuration updated successfully at {path_cursor_config}")
return True
except Exception as e:
print(f"Error configuring Cursor: {e}")
return False

30
skyvern/mcp/server.py Normal file
View File

@@ -0,0 +1,30 @@
import os
from mcp.server.fastmcp import FastMCP
from skyvern.agent import SkyvernAgent
mcp = FastMCP("Skyvern")
if "SKYVERN_MCP_CLOUD_URL" in os.environ and "SKYVERN_MCP_API_KEY" in os.environ:
skyvern_agent = SkyvernAgent(
base_url=os.environ.get("SKYVERN_MCP_CLOUD_URL"), api_key=os.environ.get("SKYVERN_MCP_API_KEY")
)
else:
skyvern_agent = SkyvernAgent()
@mcp.tool()
async def skyvern_run_task(prompt: str, url: str) -> str:
"""Browse the internet using a browser to achieve a user goal.
Args:
prompt: brief description of what the user wants to accomplish
url: the target website for the user goal
"""
res = await skyvern_agent.run_task(prompt=prompt, url=url)
return res.model_dump()["output"]
if __name__ == "__main__":
mcp.run(transport="stdio")

View File

@@ -1,3 +1,8 @@
import platform
import subprocess
from pathlib import Path
from typing import Optional
from alembic import command from alembic import command
from alembic.config import Config from alembic.config import Config
from skyvern.constants import REPO_ROOT_DIR from skyvern.constants import REPO_ROOT_DIR
@@ -8,3 +13,50 @@ def migrate_db() -> None:
path = f"{REPO_ROOT_DIR}/alembic" path = f"{REPO_ROOT_DIR}/alembic"
alembic_cfg.set_main_option("script_location", path) alembic_cfg.set_main_option("script_location", path)
command.upgrade(alembic_cfg, "head") command.upgrade(alembic_cfg, "head")
def detect_os() -> str:
"""
Detects the operating system.
Returns:
str: The name of the OS in lowercase.
Returns 'wsl' for Windows Subsystem for Linux,
'linux' for native Linux,
or the lowercase name of other platforms (e.g., 'windows', 'darwin').
"""
system = platform.system()
if system == "Linux":
try:
with open("/proc/version", "r") as f:
version_info = f.read().lower()
if "microsoft" in version_info:
return "wsl"
except Exception:
pass
return "linux"
else:
return system.lower()
def get_windows_appdata_roaming() -> Optional[Path]:
"""
Retrieves the Windows 'AppData\\Roaming' directory path from WSL.
Returns:
Optional[Path]: A Path object representing the translated Linux-style path
to the Windows AppData\\Roaming folder, or None if retrieval fails.
"""
try:
output = (
subprocess.check_output(
["powershell.exe", "-NoProfile", "-Command", "[Environment]::GetFolderPath('ApplicationData')"],
stderr=subprocess.DEVNULL,
)
.decode("utf-8")
.strip()
)
linux_path = "/mnt/" + output[0].lower() + output[2:].replace("\\", "/")
return Path(linux_path)
except Exception:
return None