Skip to main content
Glama
ingeno
by ingeno
cli.py29 kB
"""FastMCP CLI tools using Cyclopts.""" import importlib.metadata import importlib.util import json import os import platform import subprocess import sys from contextlib import contextmanager from pathlib import Path from typing import Annotated, Literal import cyclopts import pyperclip from rich.console import Console from rich.table import Table import fastmcp from fastmcp.cli import run as run_module from fastmcp.cli.install import install_app from fastmcp.server.server import FastMCP from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config from fastmcp.utilities.inspect import ( InspectFormat, format_info, inspect_fastmcp, ) from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config import MCPServerConfig from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment logger = get_logger("cli") console = Console() app = cyclopts.App( name="fastmcp", help="FastMCP 2.0 - The fast, Pythonic way to build MCP servers and clients.", version=fastmcp.__version__, ) 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("Invalid environment variable format. Must be KEY=VALUE") sys.exit(1) key, value = env_var.split("=", 1) return key.strip(), value.strip() @contextmanager def with_argv(args: list[str] | None): """Temporarily replace sys.argv if args provided. This context manager is used at the CLI boundary to inject server arguments when needed, without mutating sys.argv deep in the source loading logic. Args are provided without the script name, so we preserve sys.argv[0] and replace the rest. """ if args is not None: original = sys.argv[:] try: # Preserve the script name (sys.argv[0]) and replace the rest sys.argv = [sys.argv[0]] + args yield finally: sys.argv = original else: yield @app.command def version( *, copy: Annotated[ bool, cyclopts.Parameter( "--copy", help="Copy version information to clipboard", negative="", ), ] = False, ): """Display version information and platform details.""" info = { "FastMCP version": fastmcp.__version__, "MCP version": importlib.metadata.version("mcp"), "Python version": platform.python_version(), "Platform": platform.platform(), "FastMCP root path": Path(fastmcp.__file__).resolve().parents[1], } 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", " ")) if copy: # Use Rich's plain text rendering for copying plain_console = Console(file=None, force_terminal=False, legacy_windows=False) with plain_console.capture() as capture: plain_console.print(g) pyperclip.copy(capture.get()) console.print("[green]✓[/green] Version information copied to clipboard") else: console.print(g) @app.command async def dev( server_spec: str | None = None, *, with_editable: Annotated[ list[Path] | None, cyclopts.Parameter( "--with-editable", help="Directory containing pyproject.toml to install in editable mode (can be used multiple times)", negative="", ), ] = None, with_packages: Annotated[ list[str] | None, cyclopts.Parameter( "--with", help="Additional packages to install (can be used multiple times)", negative="", ), ] = None, inspector_version: Annotated[ str | None, cyclopts.Parameter( "--inspector-version", help="Version of the MCP Inspector to use", ), ] = None, ui_port: Annotated[ int | None, cyclopts.Parameter( "--ui-port", help="Port for the MCP Inspector UI", ), ] = None, server_port: Annotated[ int | None, cyclopts.Parameter( "--server-port", help="Port for the MCP Inspector Proxy server", ), ] = None, python: Annotated[ str | None, cyclopts.Parameter( "--python", help="Python version to use (e.g., 3.10, 3.11)", ), ] = None, with_requirements: Annotated[ Path | None, cyclopts.Parameter( "--with-requirements", help="Requirements file to install dependencies from", ), ] = None, project: Annotated[ Path | None, cyclopts.Parameter( "--project", help="Run the command within the given project directory", ), ] = None, ) -> None: """Run an MCP server with the MCP Inspector for development. Args: server_spec: Python file to run, optionally with :object suffix, or None to auto-detect fastmcp.json """ try: # Load config and apply CLI overrides config, server_spec = load_and_merge_config( server_spec, python=python, with_packages=with_packages or [], with_requirements=with_requirements, project=project, editable=[str(p) for p in with_editable] if with_editable else None, port=server_port, # Use deployment config for server port ) # Get server port from config if not specified via CLI if not server_port: server_port = config.deployment.port except FileNotFoundError: sys.exit(1) logger.debug( "Starting dev server", extra={ "server_spec": server_spec, "with_editable": config.environment.editable, "with_packages": config.environment.dependencies, "ui_port": ui_port, "server_port": server_port, }, ) try: # Load server to check for deprecated dependencies if not config: logger.error("No configuration available") sys.exit(1) assert config is not None # For type checker server: FastMCP = await config.source.load_server() if server.dependencies: import warnings warnings.warn( f"Server '{server.name}' uses deprecated 'dependencies' parameter (deprecated in FastMCP 2.11.4). " "Please migrate to fastmcp.json configuration file. " "See https://gofastmcp.com/docs/deployment/server-configuration for details.", DeprecationWarning, stacklevel=2, ) # Merge server dependencies with environment dependencies env_deps = config.environment.dependencies or [] all_deps = list(set(env_deps + server.dependencies)) if not config.environment: config.environment = UVEnvironment(dependencies=all_deps) else: config.environment.dependencies = all_deps env_vars = {} if ui_port: env_vars["CLIENT_PORT"] = str(ui_port) if server_port: env_vars["SERVER_PORT"] = str(server_port) # 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) inspector_cmd = "@modelcontextprotocol/inspector" if inspector_version: inspector_cmd += f"@{inspector_version}" # Use the environment from config (already has CLI overrides applied) uv_cmd = config.environment.build_command( ["fastmcp", "run", server_spec, "--no-banner"] ) # Set marker to prevent infinite loops when subprocess calls FastMCP env = dict(os.environ.items()) | env_vars | {"FASTMCP_UV_SPAWNED": "1"} # Run the MCP Inspector command with shell=True on Windows shell = sys.platform == "win32" process = subprocess.run( [npx_cmd, inspector_cmd] + uv_cmd, check=True, shell=shell, env=env, ) sys.exit(process.returncode) except subprocess.CalledProcessError as e: logger.error( "Dev server failed", extra={ "file": str(server_spec), "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(server_spec)}, ) sys.exit(1) @app.command async def run( server_spec: str | None = None, *server_args: str, transport: Annotated[ run_module.TransportType | None, cyclopts.Parameter( name=["--transport", "-t"], help="Transport protocol to use", ), ] = None, host: Annotated[ str | None, cyclopts.Parameter( "--host", help="Host to bind to when using http transport (default: 127.0.0.1)", ), ] = None, port: Annotated[ int | None, cyclopts.Parameter( name=["--port", "-p"], help="Port to bind to when using http transport (default: 8000)", ), ] = None, path: Annotated[ str | None, cyclopts.Parameter( "--path", help="The route path for the server (default: /mcp/ for http transport, /sse/ for sse transport)", ), ] = None, log_level: Annotated[ Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None, cyclopts.Parameter( name=["--log-level", "-l"], help="Log level", ), ] = None, no_banner: Annotated[ bool, cyclopts.Parameter( "--no-banner", help="Don't show the server banner", negative="", ), ] = False, python: Annotated[ str | None, cyclopts.Parameter( "--python", help="Python version to use (e.g., 3.10, 3.11)", ), ] = None, with_packages: Annotated[ list[str] | None, cyclopts.Parameter( "--with", help="Additional packages to install (can be used multiple times)", negative="", ), ] = None, project: Annotated[ Path | None, cyclopts.Parameter( "--project", help="Run the command within the given project directory", ), ] = None, with_requirements: Annotated[ Path | None, cyclopts.Parameter( "--with-requirements", help="Requirements file to install dependencies from", ), ] = None, skip_source: Annotated[ bool, cyclopts.Parameter( "--skip-source", help="Skip source preparation step (use when source is already prepared)", negative="", ), ] = False, skip_env: Annotated[ bool, cyclopts.Parameter( "--skip-env", help="Skip environment configuration (for internal use when already in a uv environment)", negative="", ), ] = False, ) -> None: """Run an MCP server or connect to a remote one. The server can be specified in several ways: 1. Module approach: "server.py" - runs the module directly, looking for an object named 'mcp', 'server', or 'app' 2. Import approach: "server.py:app" - imports and runs the specified server object 3. URL approach: "http://server-url" - connects to a remote server and creates a proxy 4. MCPConfig file: "mcp.json" - runs as a proxy server for the MCP Servers in the MCPConfig file 5. FastMCP config: "fastmcp.json" - runs server using FastMCP configuration 6. No argument: looks for fastmcp.json in current directory Server arguments can be passed after -- : fastmcp run server.py -- --config config.json --debug Args: server_spec: Python file, object specification (file:obj), config file, URL, or None to auto-detect """ # Check if we were spawned by uv (or user explicitly set --skip-env) if skip_env or is_already_in_uv_subprocess(): skip_env = True try: # Load config and apply CLI overrides config, server_spec = load_and_merge_config( server_spec, python=python, with_packages=with_packages or [], with_requirements=with_requirements, project=project, transport=transport, host=host, port=port, path=path, log_level=log_level, server_args=list(server_args) if server_args else None, ) except FileNotFoundError: sys.exit(1) # Get effective values (CLI overrides take precedence) final_transport = transport or config.deployment.transport final_host = host or config.deployment.host final_port = port or config.deployment.port final_path = path or config.deployment.path final_log_level = log_level or config.deployment.log_level final_server_args = server_args or config.deployment.args # Use CLI override if provided, otherwise use settings # no_banner CLI flag overrides the show_cli_banner setting final_no_banner = no_banner if no_banner else not fastmcp.settings.show_cli_banner logger.debug( "Running server or client", extra={ "server_spec": server_spec, "transport": final_transport, "host": final_host, "port": final_port, "path": final_path, "log_level": final_log_level, "server_args": list(final_server_args) if final_server_args else [], }, ) # Check if we need to use uv run (but skip if we're already in uv or user said to skip) # We check if the environment would modify the command test_cmd = ["test"] needs_uv = config.environment.build_command(test_cmd) != test_cmd and not skip_env if needs_uv: # Build the inner fastmcp command inner_cmd = ["fastmcp", "run", server_spec] # Add transport options to the inner command if final_transport: inner_cmd.extend(["--transport", final_transport]) # Only add HTTP-specific options for non-stdio transports if final_transport != "stdio": if final_host: inner_cmd.extend(["--host", final_host]) if final_port: inner_cmd.extend(["--port", str(final_port)]) if final_path: inner_cmd.extend(["--path", final_path]) if final_log_level: inner_cmd.extend(["--log-level", final_log_level]) if final_no_banner: inner_cmd.append("--no-banner") # Add skip-env flag to prevent infinite recursion inner_cmd.append("--skip-env") # Add server args if any if final_server_args: inner_cmd.append("--") inner_cmd.extend(final_server_args) # Build the full uv command using the config's environment cmd = config.environment.build_command(inner_cmd) # Set marker to prevent infinite loops when subprocess calls FastMCP again env = os.environ | {"FASTMCP_UV_SPAWNED": "1"} # Run the command logger.debug(f"Running command: {' '.join(cmd)}") try: process = subprocess.run(cmd, check=True, env=env) sys.exit(process.returncode) except subprocess.CalledProcessError as e: logger.error( f"Failed to run: {e}", extra={ "server_spec": server_spec, "error": str(e), "returncode": e.returncode, }, ) sys.exit(e.returncode) else: # Use direct import for backwards compatibility try: await run_module.run_command( server_spec=server_spec, transport=final_transport, host=final_host, port=final_port, path=final_path, log_level=final_log_level, server_args=list(final_server_args) if final_server_args else [], show_banner=not final_no_banner, skip_source=skip_source, ) except Exception as e: logger.error( f"Failed to run: {e}", extra={ "server_spec": server_spec, "error": str(e), }, ) sys.exit(1) @app.command async def inspect( server_spec: str | None = None, *, format: Annotated[ InspectFormat | None, cyclopts.Parameter( name=["--format", "-f"], help="Output format: fastmcp (FastMCP-specific) or mcp (MCP protocol). Required when using -o.", ), ] = None, output: Annotated[ Path | None, cyclopts.Parameter( name=["--output", "-o"], help="Output file path for the JSON report. If not specified, outputs to stdout when format is provided.", ), ] = None, python: Annotated[ str | None, cyclopts.Parameter( "--python", help="Python version to use (e.g., 3.10, 3.11)", ), ] = None, with_packages: Annotated[ list[str] | None, cyclopts.Parameter( "--with", help="Additional packages to install (can be used multiple times)", negative="", ), ] = None, project: Annotated[ Path | None, cyclopts.Parameter( "--project", help="Run the command within the given project directory", ), ] = None, with_requirements: Annotated[ Path | None, cyclopts.Parameter( "--with-requirements", help="Requirements file to install dependencies from", ), ] = None, skip_env: Annotated[ bool, cyclopts.Parameter( "--skip-env", help="Skip environment configuration (for internal use when already in a uv environment)", negative="", ), ] = False, ) -> None: """Inspect an MCP server and display information or generate a JSON report. This command analyzes an MCP server. Without flags, it displays a text summary. Use --format to output complete JSON data. Examples: # Show text summary fastmcp inspect server.py # Output FastMCP format JSON to stdout fastmcp inspect server.py --format fastmcp # Save MCP protocol format to file (format required with -o) fastmcp inspect server.py --format mcp -o manifest.json # Inspect from fastmcp.json configuration fastmcp inspect fastmcp.json fastmcp inspect # auto-detect fastmcp.json Args: server_spec: Python file to inspect, optionally with :object suffix, or fastmcp.json """ # Check if we were spawned by uv (or user explicitly set --skip-env) if skip_env or is_already_in_uv_subprocess(): skip_env = True try: # Load config and apply CLI overrides config, server_spec = load_and_merge_config( server_spec, python=python, with_packages=with_packages or [], with_requirements=with_requirements, project=project, ) # Check if it's an MCPConfig (which inspect doesn't support) if server_spec.endswith(".json") and config is None: # This might be an MCPConfig, check the file try: with open(Path(server_spec)) as f: data = json.load(f) if "mcpServers" in data: logger.error("MCPConfig files are not supported by inspect command") sys.exit(1) except (json.JSONDecodeError, FileNotFoundError): pass except FileNotFoundError: sys.exit(1) # Check if we need to use uv run (but skip if we're already in uv or user said to skip) # We check if the environment would modify the command test_cmd = ["test"] needs_uv = config.environment.build_command(test_cmd) != test_cmd and not skip_env if needs_uv: # Build and run uv command # The environment is already configured in the config object inspect_command = [ "fastmcp", "inspect", server_spec, "--skip-env", # Prevent infinite recursion ] # Add format and output flags if specified if format: inspect_command.extend(["--format", format.value]) if output: inspect_command.extend(["--output", str(output)]) # Run the command using subprocess import subprocess cmd = config.environment.build_command(inspect_command) env = os.environ | {"FASTMCP_UV_SPAWNED": "1"} process = subprocess.run(cmd, check=True, env=env) sys.exit(process.returncode) logger.debug( "Inspecting server", extra={ "server_spec": server_spec, "format": format, "output": str(output) if output else None, }, ) try: # Load the server using the config if not config: logger.error("No configuration available") sys.exit(1) assert config is not None # For type checker server = await config.source.load_server() # Get basic server information info = await inspect_fastmcp(server) # Check for invalid combination if output and not format: console.print( "[bold red]Error:[/bold red] --format is required when using -o/--output" ) console.print( "[dim]Use --format fastmcp or --format mcp to specify the output format[/dim]" ) sys.exit(1) # If no format specified, show text summary if format is None: # Display text summary console.print() # Server section console.print("[bold]Server[/bold]") console.print(f" Name: {info.name}") if info.version: console.print(f" Version: {info.version}") console.print(f" Generation: {info.server_generation}") if info.instructions: console.print(f" Instructions: {info.instructions}") console.print() # Components section console.print("[bold]Components[/bold]") console.print(f" Tools: {len(info.tools)}") console.print(f" Prompts: {len(info.prompts)}") console.print(f" Resources: {len(info.resources)}") console.print(f" Templates: {len(info.templates)}") console.print() # Environment section console.print("[bold]Environment[/bold]") console.print(f" FastMCP: {info.fastmcp_version}") console.print(f" MCP: {info.mcp_version}") console.print() console.print( "[dim]Use --format \\[fastmcp|mcp] for complete JSON output[/dim]" ) return # Generate formatted JSON output formatted_json = await format_info(server, format, info) # Output to file or stdout if output: # Ensure output directory exists output.parent.mkdir(parents=True, exist_ok=True) # Write JSON report with output.open("wb") as f: f.write(formatted_json) logger.info(f"Server inspection complete. Report saved to {output}") # Print confirmation to console console.print( f"[bold green]✓[/bold green] Server inspection saved to: [cyan]{output}[/cyan]" ) console.print(f" Server: [bold]{info.name}[/bold]") console.print(f" Format: {format.value}") else: # Output JSON to stdout console.print(formatted_json.decode("utf-8")) except Exception as e: logger.error( f"Failed to inspect server: {e}", extra={ "server_spec": server_spec, "error": str(e), }, ) console.print(f"[bold red]✗[/bold red] Failed to inspect server: {e}") sys.exit(1) # Create project subcommand group project_app = cyclopts.App(name="project", help="Manage FastMCP projects") @project_app.command async def prepare( config_path: Annotated[ str | None, cyclopts.Parameter(help="Path to fastmcp.json configuration file"), ] = None, output_dir: Annotated[ str | None, cyclopts.Parameter(help="Directory to create the persistent environment in"), ] = None, skip_source: Annotated[ bool, cyclopts.Parameter(help="Skip source preparation (e.g., git clone)"), ] = False, ) -> None: """Prepare a FastMCP project by creating a persistent uv environment. This command creates a persistent uv project with all dependencies installed: - Creates a pyproject.toml with dependencies from the config - Installs all Python packages into a .venv - Prepares the source (git clone, download, etc.) unless --skip-source After running this command, you can use: fastmcp run <config> --project <output-dir> This is useful for: - CI/CD pipelines with separate build and run stages - Docker images where you prepare during build - Production deployments where you want fast startup times Example: fastmcp project prepare myserver.json --output-dir ./prepared-env fastmcp run myserver.json --project ./prepared-env """ from pathlib import Path # Require output-dir if output_dir is None: logger.error( "The --output-dir parameter is required.\n" "Please specify where to create the persistent environment." ) sys.exit(1) # Auto-detect fastmcp.json if not provided if config_path is None: found_config = MCPServerConfig.find_config() if found_config: config_path = str(found_config) logger.info(f"Using configuration from {config_path}") else: logger.error( "No configuration file specified and no fastmcp.json found.\n" "Please specify a configuration file or create a fastmcp.json." ) sys.exit(1) config_file = Path(config_path) if not config_file.exists(): logger.error(f"Configuration file not found: {config_path}") sys.exit(1) output_path = Path(output_dir) try: # Load the configuration config = MCPServerConfig.from_file(config_file) # Prepare environment and source await config.prepare( skip_source=skip_source, output_dir=output_path, ) console.print( f"[bold green]✓[/bold green] Project prepared successfully in {output_path}!\n" f"You can now run the server with:\n" f" [cyan]fastmcp run {config_path} --project {output_dir}[/cyan]" ) except Exception as e: logger.error(f"Failed to prepare project: {e}") console.print(f"[bold red]✗[/bold red] Failed to prepare project: {e}") sys.exit(1) # Add project subcommand group app.command(project_app) # Add install subcommands using proper Cyclopts pattern app.command(install_app) if __name__ == "__main__": app()

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ingeno/mcp-openapi-lambda'

If you have feedback or need assistance with the MCP directory API, please join our Discord server