diff --git a/docs/patterns/cli.mdx b/docs/patterns/cli.mdx index 7fd4cce4f..078aa37cd 100644 --- a/docs/patterns/cli.mdx +++ b/docs/patterns/cli.mdx @@ -300,13 +300,17 @@ Install a MCP server in MCP client applications. FastMCP currently supports the - **Claude Code** - Installs via Claude Code's built-in MCP management system - **Claude Desktop** - Installs via direct configuration file modification - **Cursor** - Installs via deeplink that opens Cursor for user confirmation +- **Gemini CLI** - Installs via Gemini CLI's built-in MCP management system - **MCP JSON** - Generates standard MCP JSON configuration for manual use +- **Stdio** - Outputs the shell command to run a server over stdio transport ```bash fastmcp install claude-code server.py fastmcp install claude-desktop server.py fastmcp install cursor server.py +fastmcp install gemini-cli server.py fastmcp install mcp-json server.py +fastmcp install stdio server.py ``` Note that for security reasons, MCP clients usually run every server in a completely isolated environment. Therefore, all dependencies must be explicitly specified using the `--with` and/or `--with-editable` options (following `uv` conventions) or by attaching them to your server in code via the `dependencies` parameter. You should not assume that the MCP server will have access to your local environment. @@ -394,6 +398,15 @@ fastmcp install mcp-json server.py --name "My Server" --with pandas # Copy JSON configuration to clipboard fastmcp install mcp-json server.py --copy + +# Output the stdio command for running a server +fastmcp install stdio server.py + +# Output the stdio command from a fastmcp.json (includes configured dependencies) +fastmcp install stdio fastmcp.json + +# Copy the stdio command to clipboard +fastmcp install stdio server.py --copy ``` ### MCP JSON Generation @@ -436,6 +449,38 @@ To use this configuration with your MCP client, you'll typically need to add it | ------ | ---- | ----------- | | Copy to Clipboard | `--copy` | Copy configuration to clipboard instead of printing to stdout | +### Stdio Command + +The `stdio` subcommand outputs the shell command an MCP host uses to start your server over stdio transport. Use it when you need a ready-to-paste `uv run --with fastmcp fastmcp run ...` command for a tool or script without a dedicated install target. + +```bash +# Print the command to stdout +fastmcp install stdio server.py + +# Output: uv run --with fastmcp fastmcp run /absolute/path/to/server.py +``` + +When you pass a `fastmcp.json`, FastMCP automatically includes dependencies from the configuration: + +```bash +fastmcp install stdio fastmcp.json + +# Output: uv run --with fastmcp --with pillow --with 'qrcode[pil]>=8.0' fastmcp run /absolute/path/to/qr_server.py +``` + +Use `--copy` to send the command directly to your clipboard: + +```bash +fastmcp install stdio server.py --copy +# ✓ Command copied to clipboard +``` + +**Options specific to stdio:** + +| Option | Flag | Description | +| ------ | ---- | ----------- | +| Copy to Clipboard | `--copy` | Copy command to clipboard instead of printing to stdout | + ## `fastmcp inspect` diff --git a/src/fastmcp/cli/install/__init__.py b/src/fastmcp/cli/install/__init__.py index 9b3e3960d..5b8e30f15 100644 --- a/src/fastmcp/cli/install/__init__.py +++ b/src/fastmcp/cli/install/__init__.py @@ -7,6 +7,7 @@ from .claude_desktop import claude_desktop_command from .cursor import cursor_command from .gemini_cli import gemini_cli_command from .mcp_json import mcp_json_command +from .stdio import stdio_command # Create a cyclopts app for install subcommands install_app = cyclopts.App( @@ -20,3 +21,4 @@ install_app.command(claude_desktop_command, name="claude-desktop") install_app.command(cursor_command, name="cursor") install_app.command(gemini_cli_command, name="gemini-cli") install_app.command(mcp_json_command, name="mcp-json") +install_app.command(stdio_command, name="stdio") diff --git a/src/fastmcp/cli/install/shared.py b/src/fastmcp/cli/install/shared.py index 8e183562a..c84e49a25 100644 --- a/src/fastmcp/cli/install/shared.py +++ b/src/fastmcp/cli/install/shared.py @@ -42,6 +42,7 @@ async def process_common_args( 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(): @@ -76,7 +77,12 @@ async def process_common_args( # Extract file and server_object from the source # The FileSystemSource handles parsing path:object syntax - file = Path(config.source.path).resolve() + 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() server_object = ( config.source.entrypoint if hasattr(config.source, "entrypoint") else None ) @@ -91,14 +97,21 @@ async def process_common_args( }, ) - # Try to import server to get its name and dependencies + # 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) as e: + except (ImportError, ModuleNotFoundError, SystemExit) as e: logger.debug( "Could not import server (likely missing dependencies), using file name", extra={"error": str(e)}, diff --git a/src/fastmcp/cli/install/stdio.py b/src/fastmcp/cli/install/stdio.py new file mode 100644 index 000000000..31c89c1dc --- /dev/null +++ b/src/fastmcp/cli/install/stdio.py @@ -0,0 +1,156 @@ +"""Stdio command generation for FastMCP install using Cyclopts.""" + +import builtins +import shlex +import sys +from pathlib import Path +from typing import Annotated + +import cyclopts +import pyperclip +from rich import print as rich_print + +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment + +from .shared import process_common_args + +logger = get_logger(__name__) + + +def install_stdio( + file: Path, + server_object: str | None, + *, + with_editable: list[Path] | None = None, + with_packages: list[str] | None = None, + copy: bool = False, + python_version: str | None = None, + with_requirements: Path | None = None, + project: Path | None = None, +) -> bool: + """Generate the stdio command for running a FastMCP server. + + Args: + file: Path to the server file + server_object: Optional server object name (for :object suffix) + with_editable: Optional list of directories to install in editable mode + with_packages: Optional list of additional packages to install + copy: If True, copy to clipboard instead of printing to stdout + python_version: Optional Python version to use + with_requirements: Optional requirements file to install from + project: Optional project directory to run within + + Returns: + True if generation was successful, False otherwise + """ + try: + env_config = UVEnvironment( + python=python_version, + dependencies=(with_packages or []) + ["fastmcp"], + requirements=with_requirements, + project=project, + editable=with_editable, + ) + # Build server spec from parsed components + if server_object: + server_spec = f"{file.resolve()}:{server_object}" + else: + server_spec = str(file.resolve()) + + # Build the full command + full_command = env_config.build_command(["fastmcp", "run", server_spec]) + command_str = shlex.join(full_command) + + if copy: + pyperclip.copy(command_str) + rich_print("[green]✓ Command copied to clipboard[/green]") + else: + builtins.print(command_str) + + return True + + except (OSError, ValueError, pyperclip.PyperclipException) as e: + rich_print(f"[red]Failed to generate stdio command: {e}[/red]") + return False + + +async def stdio_command( + server_spec: str, + *, + server_name: Annotated[ + str | None, + cyclopts.Parameter( + name=["--name", "-n"], + help="Custom name for the server (used for dependency resolution)", + ), + ] = None, + with_editable: Annotated[ + list[Path] | None, + cyclopts.Parameter( + "--with-editable", + help="Directory with pyproject.toml to install in editable mode (can be used multiple times)", + ), + ] = None, + with_packages: Annotated[ + list[str] | None, + cyclopts.Parameter( + "--with", help="Additional packages to install (can be used multiple times)" + ), + ] = None, + copy: Annotated[ + bool, + cyclopts.Parameter( + "--copy", + help="Copy command to clipboard instead of printing to stdout", + ), + ] = False, + 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: + """Generate the stdio command for running a FastMCP server. + + Outputs the shell command that an MCP host would use to start this server + over stdio transport. Useful for manual configuration or debugging. + + Args: + server_spec: Python file to run, optionally with :object suffix + """ + with_editable = with_editable or [] + with_packages = with_packages or [] + file, server_object, _name, packages, _env_dict = await process_common_args( + server_spec, server_name, with_packages, [], None + ) + + success = install_stdio( + file=file, + server_object=server_object, + with_editable=with_editable, + with_packages=packages, + copy=copy, + python_version=python, + with_requirements=with_requirements, + project=project, + ) + + if not success: + sys.exit(1) diff --git a/tests/cli/test_install.py b/tests/cli/test_install.py index 6cffefa78..5a883665c 100644 --- a/tests/cli/test_install.py +++ b/tests/cli/test_install.py @@ -1,6 +1,7 @@ from pathlib import Path from fastmcp.cli.install import install_app +from fastmcp.cli.install.stdio import install_stdio class TestInstallApp: @@ -26,6 +27,7 @@ class TestInstallApp: install_app.parse_args(["cursor", "--help"]) install_app.parse_args(["gemini-cli", "--help"]) install_app.parse_args(["mcp-json", "--help"]) + install_app.parse_args(["stdio", "--help"]) except SystemExit: # Help commands exit with 0, that's expected pass @@ -185,6 +187,74 @@ class TestMcpJsonInstall: assert bound.arguments["copy"] is True +class TestStdioInstall: + """Test stdio install command.""" + + def test_stdio_basic(self): + """Test basic stdio install command parsing.""" + command, bound, _ = install_app.parse_args(["stdio", "server.py"]) + + assert command is not None + assert bound.arguments["server_spec"] == "server.py" + + def test_stdio_with_copy(self): + """Test stdio install with copy to clipboard option.""" + command, bound, _ = install_app.parse_args(["stdio", "server.py", "--copy"]) + + assert bound.arguments["copy"] is True + + def test_stdio_with_packages(self): + """Test stdio install with additional packages.""" + command, bound, _ = install_app.parse_args( + ["stdio", "server.py", "--with", "requests", "--with", "httpx"] + ) + + assert bound.arguments["with_packages"] == ["requests", "httpx"] + + def test_install_stdio_generates_command(self, tmp_path: Path): + """Test that install_stdio produces a shell command containing fastmcp run.""" + server_file = tmp_path / "server.py" + server_file.write_text("# placeholder") + + # Capture stdout + import io + import sys + + captured = io.StringIO() + old_stdout = sys.stdout + sys.stdout = captured + try: + result = install_stdio(file=server_file, server_object=None) + finally: + sys.stdout = old_stdout + + assert result is True + output = captured.getvalue() + assert "fastmcp" in output + assert "run" in output + assert str(server_file.resolve()) in output + + def test_install_stdio_with_object(self, tmp_path: Path): + """Test that install_stdio includes the :object suffix.""" + server_file = tmp_path / "server.py" + server_file.write_text("# placeholder") + + import io + import sys + + captured = io.StringIO() + old_stdout = sys.stdout + sys.stdout = captured + try: + result = install_stdio(file=server_file, server_object="app") + finally: + sys.stdout = old_stdout + + assert result is True + output = captured.getvalue() + assert f"{server_file.resolve()}:app" in output + + class TestGeminiCliInstall: """Test gemini-cli install command.""" @@ -253,6 +323,7 @@ class TestInstallCommandParsing: ["claude-desktop", "server.py"], ["cursor", "server.py"], ["gemini-cli", "server.py"], + ["stdio", "server.py"], ] for cmd_args in commands_to_test: @@ -267,6 +338,12 @@ class TestInstallCommandParsing: assert command is not None assert bound.arguments["server_spec"] == "server.py" + def test_stdio_minimal(self): + """Test that stdio works with minimal arguments.""" + command, bound, _ = install_app.parse_args(["stdio", "server.py"]) + assert command is not None + assert bound.arguments["server_spec"] == "server.py" + def test_python_option(self): """Test --python option for all install commands.""" commands_to_test = [ @@ -275,6 +352,7 @@ class TestInstallCommandParsing: ["cursor", "server.py", "--python", "3.11"], ["gemini-cli", "server.py", "--python", "3.11"], ["mcp-json", "server.py", "--python", "3.11"], + ["stdio", "server.py", "--python", "3.11"], ] for cmd_args in commands_to_test: @@ -290,6 +368,7 @@ class TestInstallCommandParsing: ["cursor", "server.py", "--with-requirements", "requirements.txt"], ["gemini-cli", "server.py", "--with-requirements", "requirements.txt"], ["mcp-json", "server.py", "--with-requirements", "requirements.txt"], + ["stdio", "server.py", "--with-requirements", "requirements.txt"], ] for cmd_args in commands_to_test: @@ -305,6 +384,7 @@ class TestInstallCommandParsing: ["cursor", "server.py", "--project", "/path/to/project"], ["gemini-cli", "server.py", "--project", "/path/to/project"], ["mcp-json", "server.py", "--project", "/path/to/project"], + ["stdio", "server.py", "--project", "/path/to/project"], ] for cmd_args in commands_to_test: