mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
* Add fastmcp-client workspace package * Fix client package static checks * Document client-only package * Harden fastmcp-client package split * Preserve forwarded headers in full package * Switch to fastmcp-slim package * Fix fastmcp-slim release edges * Match pydantic-style slim layout * Polish fastmcp-slim packaging
195 lines
6.8 KiB
Python
195 lines
6.8 KiB
Python
"""Shared utilities for install commands."""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
from dotenv import dotenv_values
|
|
from pydantic import ValidationError
|
|
from rich import print
|
|
|
|
from fastmcp.utilities.logging import get_logger
|
|
from fastmcp.utilities.mcp_server_config import MCPServerConfig
|
|
from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
# Server names are passed as subprocess arguments to CLI tools like `claude`
|
|
# and `gemini`. On Windows these may resolve to .cmd/.bat wrappers that run
|
|
# through cmd.exe, where shell metacharacters (& | ; etc.) in arguments can
|
|
# cause command injection. Restrict names to safe characters.
|
|
_SAFE_NAME_RE = re.compile(r"^[\w\-. ]+$")
|
|
|
|
|
|
def validate_server_name(name: str) -> str:
|
|
"""Validate that a server name is safe for use as a subprocess argument.
|
|
|
|
Raises SystemExit if the name contains shell metacharacters.
|
|
"""
|
|
if not _SAFE_NAME_RE.match(name):
|
|
print(
|
|
f"[red]Invalid server name '[bold]{name}[/bold]': "
|
|
"names may only contain letters, numbers, hyphens, underscores, dots, and spaces.[/red]"
|
|
)
|
|
sys.exit(1)
|
|
return name
|
|
|
|
|
|
def parse_env_var(env_var: str) -> tuple[str, str]:
|
|
"""Parse environment variable string in format KEY=VALUE."""
|
|
if "=" not in env_var:
|
|
print(
|
|
f"[red]Invalid environment variable format: '[bold]{env_var}[/bold]'. Must be KEY=VALUE[/red]"
|
|
)
|
|
sys.exit(1)
|
|
key, value = env_var.split("=", 1)
|
|
return key.strip(), value.strip()
|
|
|
|
|
|
async def process_common_args(
|
|
server_spec: str,
|
|
server_name: str | None,
|
|
with_packages: list[str] | None,
|
|
env_vars: list[str] | None,
|
|
env_file: Path | None,
|
|
) -> tuple[Path, str | None, str, list[str], dict[str, str] | None]:
|
|
"""Process common arguments shared by all install commands.
|
|
|
|
Handles both fastmcp.json config files and traditional file.py:object syntax.
|
|
"""
|
|
# Convert None to empty lists for list parameters
|
|
with_packages = with_packages or []
|
|
env_vars = env_vars or []
|
|
# Create MCPServerConfig from server_spec
|
|
config = None
|
|
config_path: Path | None = None
|
|
if server_spec.endswith(".json"):
|
|
config_path = Path(server_spec).resolve()
|
|
if not config_path.exists():
|
|
print(f"[red]Configuration file not found: {config_path}[/red]")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
with open(config_path) as f:
|
|
data = json.load(f)
|
|
|
|
# Check if it's an MCPConfig (has mcpServers key)
|
|
if "mcpServers" in data:
|
|
# MCPConfig files aren't supported for install
|
|
print("[red]MCPConfig files are not supported for installation[/red]")
|
|
sys.exit(1)
|
|
else:
|
|
# It's a MCPServerConfig
|
|
config = MCPServerConfig.from_file(config_path)
|
|
|
|
# Merge packages from config if not overridden
|
|
if config.environment.dependencies:
|
|
# Merge with CLI packages (CLI takes precedence)
|
|
config_packages = list(config.environment.dependencies)
|
|
with_packages = list(set(with_packages + config_packages))
|
|
except (json.JSONDecodeError, ValidationError) as e:
|
|
print(f"[red]Invalid configuration file: {e}[/red]")
|
|
sys.exit(1)
|
|
else:
|
|
# Create config from file path
|
|
source = FileSystemSource(path=server_spec)
|
|
config = MCPServerConfig(source=source)
|
|
|
|
# Extract file and server_object from the source
|
|
# The FileSystemSource handles parsing path:object syntax
|
|
source_path = Path(config.source.path).expanduser()
|
|
# If loaded from a JSON config, resolve relative paths against the config's directory
|
|
if not source_path.is_absolute() and config_path is not None:
|
|
file = (config_path.parent / source_path).resolve()
|
|
else:
|
|
file = source_path.resolve()
|
|
# Update the source path so load_server() resolves correctly
|
|
config.source.path = str(file)
|
|
server_object = (
|
|
config.source.entrypoint if hasattr(config.source, "entrypoint") else None
|
|
)
|
|
|
|
logger.debug(
|
|
"Installing server",
|
|
extra={
|
|
"file": str(file),
|
|
"server_name": server_name,
|
|
"server_object": server_object,
|
|
"with_packages": with_packages,
|
|
},
|
|
)
|
|
|
|
# Verify the resolved file actually exists
|
|
if not file.is_file():
|
|
print(f"[red]Server file not found: {file}[/red]")
|
|
sys.exit(1)
|
|
|
|
# Try to import server to get its name and dependencies.
|
|
# load_server() resolves paths against cwd, which may differ from our
|
|
# config-relative resolution, so we catch SystemExit from its file check.
|
|
name = server_name
|
|
server = None
|
|
if not name:
|
|
try:
|
|
server = await config.source.load_server()
|
|
name = server.name
|
|
except (ImportError, ModuleNotFoundError, SystemExit) as e:
|
|
logger.debug(
|
|
"Could not import server (likely missing dependencies), using file name",
|
|
extra={"error": str(e)},
|
|
)
|
|
name = file.stem
|
|
|
|
# Process environment variables if provided
|
|
env_dict: dict[str, str] | None = None
|
|
if env_file or env_vars:
|
|
env_dict = {}
|
|
# Load from .env file if specified
|
|
if env_file:
|
|
try:
|
|
env_dict |= {
|
|
k: v for k, v in dotenv_values(env_file).items() if v is not None
|
|
}
|
|
except Exception as e:
|
|
print(f"[red]Failed to load .env file: {e}[/red]")
|
|
sys.exit(1)
|
|
|
|
# Add command line environment variables
|
|
for env_var in env_vars:
|
|
key, value = parse_env_var(env_var)
|
|
env_dict[key] = value
|
|
|
|
return file, server_object, name, with_packages, env_dict
|
|
|
|
|
|
def open_deeplink(url: str, *, expected_scheme: str) -> bool:
|
|
"""Attempt to open a deeplink URL using the system's default handler.
|
|
|
|
Args:
|
|
url: The deeplink URL to open.
|
|
expected_scheme: The URL scheme to validate (e.g. "cursor", "goose").
|
|
|
|
Returns:
|
|
True if the command succeeded, False otherwise.
|
|
"""
|
|
parsed = urlparse(url)
|
|
if parsed.scheme != expected_scheme:
|
|
logger.warning(
|
|
f"Invalid deeplink scheme: {parsed.scheme}, expected {expected_scheme}"
|
|
)
|
|
return False
|
|
|
|
try:
|
|
if sys.platform == "darwin":
|
|
subprocess.run(["open", url], check=True, capture_output=True)
|
|
elif sys.platform == "win32":
|
|
os.startfile(url)
|
|
else:
|
|
subprocess.run(["xdg-open", url], check=True, capture_output=True)
|
|
return True
|
|
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
|
|
return False
|