Add fastmcp install stdio command (#3032)

This commit is contained in:
Jeremiah Lowin 2026-01-29 22:19:15 -05:00 committed by GitHub
commit 003df5b22b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 299 additions and 3 deletions

View file

@ -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`
<VersionBadge version="2.9.0" />

View file

@ -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")

View file

@ -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)},

View file

@ -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)

View file

@ -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: