mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-24 06:24:18 +02:00
Add fastmcp install stdio command (#3032)
This commit is contained in:
parent
d52534ae32
commit
003df5b22b
5 changed files with 299 additions and 3 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)},
|
||||
|
|
|
|||
156
src/fastmcp/cli/install/stdio.py
Normal file
156
src/fastmcp/cli/install/stdio.py
Normal 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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue