From 2f2bed411eec17e0ea381b9d869670c747fff97b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 10 Apr 2025 09:01:48 -0400 Subject: [PATCH] Add CLI --- pyproject.toml | 1 + src/fastmcp/cli/__init__.py | 6 + src/fastmcp/cli/claude.py | 140 +++++++++++ src/fastmcp/cli/cli.py | 472 ++++++++++++++++++++++++++++++++++++ uv.lock | 15 +- 5 files changed, 633 insertions(+), 1 deletion(-) create mode 100644 src/fastmcp/cli/__init__.py create mode 100644 src/fastmcp/cli/claude.py create mode 100644 src/fastmcp/cli/cli.py diff --git a/pyproject.toml b/pyproject.toml index 20e56e036..95ba8a371 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ dynamic = ["version"] description = "An ergonomic MCP interface" authors = [{ name = "Jeremiah Lowin" }] dependencies = [ + "dotenv>=0.9.9", "mcp>=1.6.0,<2.0.0", "rich>=13.9.4", "typer>=0.15.2", diff --git a/src/fastmcp/cli/__init__.py b/src/fastmcp/cli/__init__.py new file mode 100644 index 000000000..3ef56d806 --- /dev/null +++ b/src/fastmcp/cli/__init__.py @@ -0,0 +1,6 @@ +"""FastMCP CLI package.""" + +from .cli import app + +if __name__ == "__main__": + app() diff --git a/src/fastmcp/cli/claude.py b/src/fastmcp/cli/claude.py new file mode 100644 index 000000000..33d635b9c --- /dev/null +++ b/src/fastmcp/cli/claude.py @@ -0,0 +1,140 @@ +"""Claude app integration utilities.""" + +import json +import os +import sys +from pathlib import Path +from typing import Any + +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +def get_claude_config_path() -> Path | None: + """Get the Claude config directory based on platform.""" + if sys.platform == "win32": + path = Path(Path.home(), "AppData", "Roaming", "Claude") + elif sys.platform == "darwin": + path = Path(Path.home(), "Library", "Application Support", "Claude") + elif sys.platform.startswith("linux"): + path = Path( + os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"), "Claude" + ) + else: + return None + + if path.exists(): + return path + return None + + +def update_claude_config( + file_spec: str, + server_name: str, + *, + with_editable: Path | None = None, + with_packages: list[str] | None = None, + env_vars: dict[str, str] | None = None, +) -> bool: + """Add or update a FastMCP server in Claude's configuration. + + Args: + file_spec: Path to the server file, optionally with :object suffix + server_name: Name for the server in Claude's config + with_editable: Optional directory to install in editable mode + with_packages: Optional list of additional packages to install + env_vars: Optional dictionary of environment variables. These are merged with + any existing variables, with new values taking precedence. + + Raises: + RuntimeError: If Claude Desktop's config directory is not found, indicating + Claude Desktop may not be installed or properly set up. + """ + config_dir = get_claude_config_path() + if not config_dir: + raise RuntimeError( + "Claude Desktop config directory not found. Please ensure Claude Desktop" + " is installed and has been run at least once to initialize its config." + ) + + config_file = config_dir / "claude_desktop_config.json" + if not config_file.exists(): + try: + config_file.write_text("{}") + except Exception as e: + logger.error( + "Failed to create Claude config file", + extra={ + "error": str(e), + "config_file": str(config_file), + }, + ) + return False + + try: + config = json.loads(config_file.read_text()) + if "mcpServers" not in config: + config["mcpServers"] = {} + + # Always preserve existing env vars and merge with new ones + if ( + server_name in config["mcpServers"] + and "env" in config["mcpServers"][server_name] + ): + existing_env = config["mcpServers"][server_name]["env"] + if env_vars: + # New vars take precedence over existing ones + env_vars = {**existing_env, **env_vars} + else: + env_vars = existing_env + + # Build uv run command + args = ["run"] + + # Collect all packages in a set to deduplicate + packages = {"fastmcp"} + if with_packages: + packages.update(pkg for pkg in with_packages if pkg) + + # Add all packages with --with + for pkg in sorted(packages): + args.extend(["--with", pkg]) + + if with_editable: + args.extend(["--with-editable", str(with_editable)]) + + # Convert file path to absolute before adding to command + # Split off any :object suffix first + if ":" in file_spec: + file_path, server_object = file_spec.rsplit(":", 1) + file_spec = f"{Path(file_path).resolve()}:{server_object}" + else: + file_spec = str(Path(file_spec).resolve()) + + # Add fastmcp run command + args.extend(["fastmcp", "run", file_spec]) + + server_config: dict[str, Any] = {"command": "uv", "args": args} + + # Add environment variables if specified + if env_vars: + server_config["env"] = env_vars + + config["mcpServers"][server_name] = server_config + + config_file.write_text(json.dumps(config, indent=2)) + logger.info( + f"Added server '{server_name}' to Claude config", + extra={"config_file": str(config_file)}, + ) + return True + except Exception as e: + logger.error( + "Failed to update Claude config", + extra={ + "error": str(e), + "config_file": str(config_file), + }, + ) + return False diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py new file mode 100644 index 000000000..09c2bb30a --- /dev/null +++ b/src/fastmcp/cli/cli.py @@ -0,0 +1,472 @@ +"""FastmMCP CLI tools.""" + +import importlib.metadata +import importlib.util +import os +import platform +import subprocess +import sys +from pathlib import Path +from typing import Annotated + +import dotenv +import typer +from rich.console import Console +from rich.table import Table +from typer import Context, Exit + +import fastmcp +from fastmcp.cli import claude +from fastmcp.utilities.logging import get_logger + +logger = get_logger("cli") +console = Console() + +app = typer.Typer( + name="fastmcp", + help="FastMCP CLI", + add_completion=False, + no_args_is_help=True, # Show help if no args provided +) + + +def _get_npx_command(): + """Get the correct npx command for the current platform.""" + if sys.platform == "win32": + # Try both npx.cmd and npx.exe on Windows + for cmd in ["npx.cmd", "npx.exe", "npx"]: + try: + subprocess.run( + [cmd, "--version"], check=True, capture_output=True, shell=True + ) + return cmd + except subprocess.CalledProcessError: + continue + return None + return "npx" # On Unix-like systems, just use npx + + +def _parse_env_var(env_var: str) -> tuple[str, str]: + """Parse environment variable string in format KEY=VALUE.""" + if "=" not in env_var: + logger.error( + f"Invalid environment variable format: {env_var}. Must be KEY=VALUE" + ) + sys.exit(1) + key, value = env_var.split("=", 1) + return key.strip(), value.strip() + + +def _build_uv_command( + file_spec: str, + with_editable: Path | None = None, + with_packages: list[str] | None = None, +) -> list[str]: + """Build the uv run command that runs a MCP server through mcp run.""" + cmd = ["uv"] + + cmd.extend(["run", "--with", "mcp"]) + + if with_editable: + cmd.extend(["--with-editable", str(with_editable)]) + + if with_packages: + for pkg in with_packages: + if pkg: + cmd.extend(["--with", pkg]) + + # Add mcp run command + cmd.extend(["mcp", "run", file_spec]) + return cmd + + +def _parse_file_path(file_spec: str) -> tuple[Path, str | None]: + """Parse a file path that may include a server object specification. + + Args: + file_spec: Path to file, optionally with :object suffix + + Returns: + Tuple of (file_path, server_object) + """ + # First check if we have a Windows path (e.g., C:\...) + has_windows_drive = len(file_spec) > 1 and file_spec[1] == ":" + + # Split on the last colon, but only if it's not part of the Windows drive letter + # and there's actually another colon in the string after the drive letter + if ":" in (file_spec[2:] if has_windows_drive else file_spec): + file_str, server_object = file_spec.rsplit(":", 1) + else: + file_str, server_object = file_spec, None + + # Resolve the file path + file_path = Path(file_str).expanduser().resolve() + if not file_path.exists(): + logger.error(f"File not found: {file_path}") + sys.exit(1) + if not file_path.is_file(): + logger.error(f"Not a file: {file_path}") + sys.exit(1) + + return file_path, server_object + + +def _import_server(file: Path, server_object: str | None = None): + """Import a MCP server from a file. + + Args: + file: Path to the file + server_object: Optional object name in format "module:object" or just "object" + + Returns: + The server object + """ + # Add parent directory to Python path so imports can be resolved + file_dir = str(file.parent) + if file_dir not in sys.path: + sys.path.insert(0, file_dir) + + # Import the module + spec = importlib.util.spec_from_file_location("server_module", file) + if not spec or not spec.loader: + logger.error("Could not load module", extra={"file": str(file)}) + sys.exit(1) + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # If no object specified, try common server names + if not server_object: + # Look for the most common server object names + for name in ["mcp", "server", "app"]: + if hasattr(module, name): + return getattr(module, name) + + logger.error( + f"No server object found in {file}. Please either:\n" + "1. Use a standard variable name (mcp, server, or app)\n" + "2. Specify the object name with file:object syntax", + extra={"file": str(file)}, + ) + sys.exit(1) + + # Handle module:object syntax + if ":" in server_object: + module_name, object_name = server_object.split(":", 1) + try: + server_module = importlib.import_module(module_name) + server = getattr(server_module, object_name, None) + except ImportError: + logger.error( + f"Could not import module '{module_name}'", + extra={"file": str(file)}, + ) + sys.exit(1) + else: + # Just object name + server = getattr(module, server_object, None) + + if server is None: + logger.error( + f"Server object '{server_object}' not found", + extra={"file": str(file)}, + ) + sys.exit(1) + + return server + + +@app.command() +def version(ctx: Context): + if ctx.resilient_parsing: + return + + info = { + "FastMCP version": fastmcp.__version__, + "MCP version": importlib.metadata.version("mcp"), + "Python version": platform.python_version(), + "Platform": platform.platform(), + "FastMCP root path": f"~/{Path(__file__).resolve().parents[3].relative_to(Path.home())}", + } + + g = Table.grid(padding=(0, 1)) + g.add_column(style="bold", justify="left") + g.add_column(style="cyan", justify="right") + for k, v in info.items(): + g.add_row(k + ":", str(v).replace("\n", " ")) + console.print(g) + + raise Exit() + + +@app.command() +def dev( + file_spec: str = typer.Argument( + ..., + help="Python file to run, optionally with :object suffix", + ), + with_editable: Annotated[ + Path | None, + typer.Option( + "--with-editable", + "-e", + help="Directory containing pyproject.toml to install in editable mode", + exists=True, + file_okay=False, + resolve_path=True, + ), + ] = None, + with_packages: Annotated[ + list[str], + typer.Option( + "--with", + help="Additional packages to install", + ), + ] = [], +) -> None: + """Run a MCP server with the MCP Inspector.""" + file, server_object = _parse_file_path(file_spec) + + logger.debug( + "Starting dev server", + extra={ + "file": str(file), + "server_object": server_object, + "with_editable": str(with_editable) if with_editable else None, + "with_packages": with_packages, + }, + ) + + try: + # Import server to get dependencies + server = _import_server(file, server_object) + if hasattr(server, "dependencies"): + with_packages = list(set(with_packages + server.dependencies)) + + uv_cmd = _build_uv_command(file_spec, with_editable, with_packages) + + # Get the correct npx command + npx_cmd = _get_npx_command() + if not npx_cmd: + logger.error( + "npx not found. Please ensure Node.js and npm are properly installed " + "and added to your system PATH." + ) + sys.exit(1) + + # Run the MCP Inspector command with shell=True on Windows + shell = sys.platform == "win32" + process = subprocess.run( + [npx_cmd, "@modelcontextprotocol/inspector"] + uv_cmd, + check=True, + shell=shell, + env=dict(os.environ.items()), # Convert to list of tuples for env update + ) + sys.exit(process.returncode) + except subprocess.CalledProcessError as e: + logger.error( + "Dev server failed", + extra={ + "file": str(file), + "error": str(e), + "returncode": e.returncode, + }, + ) + sys.exit(e.returncode) + except FileNotFoundError: + logger.error( + "npx not found. Please ensure Node.js and npm are properly installed " + "and added to your system PATH. You may need to restart your terminal " + "after installation.", + extra={"file": str(file)}, + ) + sys.exit(1) + + +@app.command() +def run( + file_spec: str = typer.Argument( + ..., + help="Python file to run, optionally with :object suffix", + ), + transport: Annotated[ + str | None, + typer.Option( + "--transport", + "-t", + help="Transport protocol to use (stdio or sse)", + ), + ] = None, +) -> None: + """Run a MCP server. + + The server can be specified in two ways:\n + 1. Module approach: server.py - runs the module directly, expecting a server.run() call.\n + 2. Import approach: server.py:app - imports and runs the specified server object.\n\n + + Note: This command runs the server directly. You are responsible for ensuring + all dependencies are available.\n + For dependency management, use `mcp install` or `mcp dev` instead. + """ # noqa: E501 + file, server_object = _parse_file_path(file_spec) + + logger.debug( + "Running server", + extra={ + "file": str(file), + "server_object": server_object, + "transport": transport, + }, + ) + + try: + # Import and get server object + server = _import_server(file, server_object) + + # Run the server + kwargs = {} + if transport: + kwargs["transport"] = transport + + server.run(**kwargs) + + except Exception as e: + logger.error( + f"Failed to run server: {e}", + extra={ + "file": str(file), + "error": str(e), + }, + ) + sys.exit(1) + + +@app.command() +def install( + file_spec: str = typer.Argument( + ..., + help="Python file to run, optionally with :object suffix", + ), + server_name: Annotated[ + str | None, + typer.Option( + "--name", + "-n", + help="Custom name for the server (defaults to server's name attribute or" + " file name)", + ), + ] = None, + with_editable: Annotated[ + Path | None, + typer.Option( + "--with-editable", + "-e", + help="Directory containing pyproject.toml to install in editable mode", + exists=True, + file_okay=False, + resolve_path=True, + ), + ] = None, + with_packages: Annotated[ + list[str], + typer.Option( + "--with", + help="Additional packages to install", + ), + ] = [], + env_vars: Annotated[ + list[str], + typer.Option( + "--env-var", + "-v", + help="Environment variables in KEY=VALUE format", + ), + ] = [], + env_file: Annotated[ + Path | None, + typer.Option( + "--env-file", + "-f", + help="Load environment variables from a .env file", + exists=True, + file_okay=True, + dir_okay=False, + resolve_path=True, + ), + ] = None, +) -> None: + """Install a MCP server in the Claude desktop app. + + Environment variables are preserved once added and only updated if new values + are explicitly provided. + """ + file, server_object = _parse_file_path(file_spec) + + logger.debug( + "Installing server", + extra={ + "file": str(file), + "server_name": server_name, + "server_object": server_object, + "with_editable": str(with_editable) if with_editable else None, + "with_packages": with_packages, + }, + ) + + if not claude.get_claude_config_path(): + logger.error("Claude app not found") + sys.exit(1) + + # Try to import server to get its name, but fall back to file name if dependencies + # missing + name = server_name + server = None + if not name: + try: + server = _import_server(file, server_object) + name = server.name + except (ImportError, ModuleNotFoundError) as e: + logger.debug( + "Could not import server (likely missing dependencies), using file" + " name", + extra={"error": str(e)}, + ) + name = file.stem + + # Get server dependencies if available + server_dependencies = getattr(server, "dependencies", []) if server else [] + if server_dependencies: + with_packages = list(set(with_packages + server_dependencies)) + + # 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.dotenv_values(env_file).items() + if v is not None + } + except Exception as e: + logger.error(f"Failed to load .env file: {e}") + 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 + + if claude.update_claude_config( + file_spec, + name, + with_editable=with_editable, + with_packages=with_packages, + env_vars=env_dict, + ): + logger.info(f"Successfully installed {name} in Claude app") + else: + logger.error(f"Failed to install {name} in Claude app") + sys.exit(1) diff --git a/uv.lock b/uv.lock index 9444459d0..fce01b641 100644 --- a/uv.lock +++ b/uv.lock @@ -187,6 +187,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, ] +[[package]] +name = "dotenv" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dotenv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892 }, +] + [[package]] name = "exceptiongroup" version = "1.2.2" @@ -229,9 +240,10 @@ wheels = [ [[package]] name = "fastmcp" -version = "0.4.2.dev28+g728aeec.d20250408" +version = "0.4.2.dev32+g9b48745.d20250410" source = { editable = "." } dependencies = [ + { name = "dotenv" }, { name = "mcp" }, { name = "rich" }, { name = "typer" }, @@ -255,6 +267,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "dotenv", specifier = ">=0.9.9" }, { name = "mcp", specifier = ">=1.6.0,<2.0.0" }, { name = "rich", specifier = ">=13.9.4" }, { name = "typer", specifier = ">=0.15.2" },