diff --git a/docs/deployment/cli.mdx b/docs/deployment/cli.mdx
index 1397d8aeb..7bfd13566 100644
--- a/docs/deployment/cli.mdx
+++ b/docs/deployment/cli.mdx
@@ -27,7 +27,7 @@ fastmcp --help
### `run`
-Run a FastMCP server directly.
+Run a FastMCP server directly or proxy a remote server.
```bash
fastmcp run server.py
@@ -47,13 +47,15 @@ This command runs the server directly in your current Python environment. You ar
| Log Level | `--log-level`, `-l` | Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) |
#### Server Specification
+
-The server can be specified in two ways:
+The server can be specified in three ways:
1. `server.py` - imports the module and looks for a FastMCP object named `mcp`, `server`, or `app`. Errors if no such object is found.
2. `server.py:custom_name` - imports and uses the specified server object
+3. `http://server-url/path` or `https://server-url/path` - connects to a remote server and creates a proxy
-When using `fastmcp run`, it **ignores** the `if __name__ == "__main__"` block entirely. Instead, it finds your server object and calls its `run()` method directly with the transport options you specify. This means you can use `fastmcp run` to override the transport specified in your code.
+When using `fastmcp run` with a local file, it **ignores** the `if __name__ == "__main__"` block entirely. Instead, it finds your server object and calls its `run()` method directly with the transport options you specify. This means you can use `fastmcp run` to override the transport specified in your code.
For example, if your code contains:
@@ -79,11 +81,17 @@ You can run it with Streamable HTTP transport regardless of what's in the `__mai
fastmcp run server.py --transport streamable-http --port 8000
```
-**Example**
+**Examples**
```bash
-# Run a server with Streamable HTTP transport on a custom port
+# Run a local server with Streamable HTTP transport on a custom port
fastmcp run server.py --transport streamable-http --port 8000
+
+# Connect to a remote server and proxy as a stdio server
+fastmcp run https://example.com/mcp-server
+
+# Connect to a remote server with specified log level
+fastmcp run https://example.com/mcp-server --log-level DEBUG
```
### `dev`
diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py
index feda85a0e..819144d22 100644
--- a/src/fastmcp/cli/cli.py
+++ b/src/fastmcp/cli/cli.py
@@ -17,6 +17,7 @@ from typer import Context, Exit
import fastmcp
from fastmcp.cli import claude
+from fastmcp.cli import run as run_module
from fastmcp.utilities.logging import get_logger
logger = get_logger("cli")
@@ -58,7 +59,7 @@ def _parse_env_var(env_var: str) -> tuple[str, str]:
def _build_uv_command(
- file_spec: str,
+ server_spec: str,
with_editable: Path | None = None,
with_packages: list[str] | None = None,
) -> list[str]:
@@ -76,106 +77,10 @@ def _build_uv_command(
cmd.extend(["--with", pkg])
# Add mcp run command
- cmd.extend(["fastmcp", "run", file_spec])
+ cmd.extend(["fastmcp", "run", server_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:
@@ -201,7 +106,7 @@ def version(ctx: Context):
@app.command()
def dev(
- file_spec: str = typer.Argument(
+ server_spec: str = typer.Argument(
...,
help="Python file to run, optionally with :object suffix",
),
@@ -246,7 +151,7 @@ def dev(
] = None,
) -> None:
"""Run a MCP server with the MCP Inspector."""
- file, server_object = _parse_file_path(file_spec)
+ file, server_object = run_module.parse_file_path(server_spec)
logger.debug(
"Starting dev server",
@@ -262,7 +167,7 @@ def dev(
try:
# Import server to get dependencies
- server = _import_server(file, server_object)
+ server = run_module.import_server(file, server_object)
if hasattr(server, "dependencies") and server.dependencies is not None:
with_packages = list(set(with_packages + server.dependencies))
@@ -285,7 +190,7 @@ def dev(
if inspector_version:
inspector_cmd += f"@{inspector_version}"
- uv_cmd = _build_uv_command(file_spec, with_editable, with_packages)
+ uv_cmd = _build_uv_command(server_spec, with_editable, with_packages)
# Run the MCP Inspector command with shell=True on Windows
shell = sys.platform == "win32"
@@ -318,9 +223,9 @@ def dev(
@app.command()
def run(
- file_spec: str = typer.Argument(
+ server_spec: str = typer.Argument(
...,
- help="Python file to run, optionally with :object suffix",
+ help="Python file, object specification (file:obj), or URL",
),
transport: Annotated[
str | None,
@@ -354,22 +259,20 @@ def run(
),
] = None,
) -> None:
- """Run a MCP server.
+ """Run a MCP server or connect to a remote one.
- The server can be specified in two ways:
- 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
+ The server can be specified in three ways:
+ 1. Module approach: server.py - runs the module directly, looking for an object named mcp/server/app.\n
+ 2. Import approach: server.py:app - imports and runs the specified server object.\n
+ 3. URL approach: http://server-url - connects to a remote server and creates a proxy.\n\n
Note: This command runs the server directly. You are responsible for ensuring
all dependencies are available.
"""
- file, server_object = _parse_file_path(file_spec)
-
logger.debug(
- "Running server",
+ "Running server or client",
extra={
- "file": str(file),
- "server_object": server_object,
+ "server_spec": server_spec,
"transport": transport,
"host": host,
"port": port,
@@ -378,29 +281,18 @@ def run(
)
try:
- # Import and get server object
- server = _import_server(file, server_object)
-
- logger.info(f'Found server "{server.name}" in {file}')
-
- # Run the server
- kwargs = {}
- if transport:
- kwargs["transport"] = transport
- if host:
- kwargs["host"] = host
- if port:
- kwargs["port"] = port
- if log_level:
- kwargs["log_level"] = log_level
-
- server.run(**kwargs)
-
+ run_module.run_command(
+ server_spec=server_spec,
+ transport=transport,
+ host=host,
+ port=port,
+ log_level=log_level,
+ )
except Exception as e:
logger.error(
- f"Failed to run server: {e}",
+ f"Failed to run: {e}",
extra={
- "file": str(file),
+ "server_spec": server_spec,
"error": str(e),
},
)
@@ -409,7 +301,7 @@ def run(
@app.command()
def install(
- file_spec: str = typer.Argument(
+ server_spec: str = typer.Argument(
...,
help="Python file to run, optionally with :object suffix",
),
@@ -466,7 +358,7 @@ def install(
Environment variables are preserved once added and only updated if new values
are explicitly provided.
"""
- file, server_object = _parse_file_path(file_spec)
+ file, server_object = run_module.parse_file_path(server_spec)
logger.debug(
"Installing server",
@@ -489,7 +381,7 @@ def install(
server = None
if not name:
try:
- server = _import_server(file, server_object)
+ server = run_module.import_server(file, server_object)
name = server.name
except (ImportError, ModuleNotFoundError) as e:
logger.debug(
@@ -526,7 +418,7 @@ def install(
env_dict[key] = value
if claude.update_claude_config(
- file_spec,
+ server_spec,
name,
with_editable=with_editable,
with_packages=with_packages,
diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py
new file mode 100644
index 000000000..fc3bf1780
--- /dev/null
+++ b/src/fastmcp/cli/run.py
@@ -0,0 +1,179 @@
+"""FastMCP run command implementation."""
+
+import importlib.util
+import re
+import sys
+from pathlib import Path
+from typing import Any, Literal
+
+from fastmcp.utilities.logging import get_logger
+
+logger = get_logger("cli.run")
+
+TransportType = Literal["stdio", "streamable-http", "sse"]
+
+
+def is_url(path: str) -> bool:
+ """Check if a string is a URL."""
+ url_pattern = re.compile(r"^https?://")
+ return bool(url_pattern.match(path))
+
+
+def parse_file_path(server_spec: str) -> tuple[Path, str | None]:
+ """Parse a file path that may include a server object specification.
+
+ Args:
+ server_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(server_spec) > 1 and server_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 (server_spec[2:] if has_windows_drive else server_spec):
+ file_str, server_object = server_spec.rsplit(":", 1)
+ else:
+ file_str, server_object = server_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) -> Any:
+ """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
+
+
+def create_client_server(url: str) -> Any:
+ """Create a FastMCP server from a client URL.
+
+ Args:
+ url: The URL to connect to
+
+ Returns:
+ A FastMCP server instance
+ """
+ try:
+ import fastmcp
+
+ client = fastmcp.Client(url)
+ server = fastmcp.FastMCP.from_client(client)
+ return server
+ except Exception as e:
+ logger.error(f"Failed to create client for URL {url}: {e}")
+ sys.exit(1)
+
+
+def run_command(
+ server_spec: str,
+ transport: str | None = None,
+ host: str | None = None,
+ port: int | None = None,
+ log_level: str | None = None,
+) -> None:
+ """Run a MCP server or connect to a remote one.
+
+ Args:
+ server_spec: Python file, object specification (file:obj), or URL
+ transport: Transport protocol to use
+ host: Host to bind to when using http transport
+ port: Port to bind to when using http transport
+ log_level: Log level
+ """
+ if is_url(server_spec):
+ # Handle URL case
+ server = create_client_server(server_spec)
+ logger.debug(f"Created client proxy server for {server_spec}")
+ else:
+ # Handle file case
+ file, server_object = parse_file_path(server_spec)
+ server = import_server(file, server_object)
+ logger.debug(f'Found server "{server.name}" in {file}')
+
+ # Run the server
+ kwargs = {}
+ if transport:
+ kwargs["transport"] = transport
+ if host:
+ kwargs["host"] = host
+ if port:
+ kwargs["port"] = port
+ if log_level:
+ kwargs["log_level"] = log_level
+
+ try:
+ server.run(**kwargs)
+ except Exception as e:
+ logger.error(f"Failed to run server: {e}")
+ sys.exit(1)
diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py
index d718d610a..8655b2d64 100644
--- a/src/fastmcp/server/http.py
+++ b/src/fastmcp/server/http.py
@@ -254,6 +254,7 @@ def create_sse_app(
)
# Store the FastMCP server instance on the Starlette app state
app.state.fastmcp_server = server
+ app.state.path = sse_path
return app
@@ -357,4 +358,6 @@ def create_streamable_http_app(
# Store the FastMCP server instance on the Starlette app state
app.state.fastmcp_server = server
+ app.state.path = streamable_http_path
+
return app
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index c6aaeedba..89df5a2cb 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -213,7 +213,6 @@ class FastMCP(Generic[LifespanResultT]):
Args:
transport: Transport protocol to use ("stdio", "sse", or "streamable-http")
"""
- logger.info(f'Starting server "{self.name}"...')
anyio.run(partial(self.run_async, transport, **transport_kwargs))
@@ -730,6 +729,7 @@ class FastMCP(Generic[LifespanResultT]):
async def run_stdio_async(self) -> None:
"""Run the server using stdio transport."""
async with stdio_server() as (read_stream, write_stream):
+ logger.info(f"Starting MCP server {self.name!r} with transport 'stdio'")
await self._mcp_server.run(
read_stream,
write_stream,
@@ -763,16 +763,24 @@ class FastMCP(Generic[LifespanResultT]):
# lifespan is required for streamable http
uvicorn_config["lifespan"] = "on"
+ host = host or self.settings.host
+ port = port or self.settings.port
+ log_level = log_level or self.settings.log_level.lower()
+
app = self.http_app(path=path, transport=transport, middleware=middleware)
config = uvicorn.Config(
app,
- host=host or self.settings.host,
- port=port or self.settings.port,
- log_level=log_level or self.settings.log_level.lower(),
+ host=host,
+ port=port,
+ log_level=log_level,
**uvicorn_config,
)
server = uvicorn.Server(config)
+ path = app.state.path.lstrip("/") # type: ignore
+ logger.info(
+ f"Starting MCP server {self.name!r} with transport {transport!r} on http://{host}:{port}/{path}"
+ )
await server.serve()
async def run_sse_async(
diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py
index 30510506f..2620c6c22 100644
--- a/tests/cli/test_cli.py
+++ b/tests/cli/test_cli.py
@@ -173,74 +173,6 @@ class TestHelperFunctions:
"file.py:server",
]
- def test_parse_file_path_simple(self):
- """Test parsing simple file path."""
- with (
- patch("pathlib.Path.exists") as mock_exists,
- patch("pathlib.Path.is_file") as mock_is_file,
- patch("pathlib.Path.expanduser") as mock_expanduser,
- patch("pathlib.Path.resolve") as mock_resolve,
- ):
- mock_exists.return_value = True
- mock_is_file.return_value = True
- mock_expanduser.return_value = Path("file.py")
- mock_resolve.return_value = Path("file.py")
-
- path, obj = cli._parse_file_path("file.py")
- assert path == Path("file.py")
- assert obj is None
-
- def test_parse_file_path_with_object(self):
- """Test parsing file path with object."""
- with (
- patch("pathlib.Path.exists") as mock_exists,
- patch("pathlib.Path.is_file") as mock_is_file,
- patch("pathlib.Path.expanduser") as mock_expanduser,
- patch("pathlib.Path.resolve") as mock_resolve,
- ):
- mock_exists.return_value = True
- mock_is_file.return_value = True
- mock_expanduser.return_value = Path("file.py")
- mock_resolve.return_value = Path("file.py")
-
- path, obj = cli._parse_file_path("file.py:server")
- assert path == Path("file.py")
- assert obj == "server"
-
- def test_parse_file_path_windows(self):
- """Test parsing Windows file path."""
- with (
- patch("pathlib.Path.exists") as mock_exists,
- patch("pathlib.Path.is_file") as mock_is_file,
- patch("pathlib.Path.expanduser") as mock_expanduser,
- patch("pathlib.Path.resolve") as mock_resolve,
- ):
- mock_exists.return_value = True
- mock_is_file.return_value = True
- mock_expanduser.return_value = Path("C:/path/file.py")
- mock_resolve.return_value = Path("C:/path/file.py")
-
- path, obj = cli._parse_file_path("C:/path/file.py:server")
- assert path == Path("C:/path/file.py")
- assert obj == "server"
-
- def test_parse_file_path_not_file(self, mock_exit, mock_logger):
- """Test parsing path that is not a file."""
- with (
- patch("pathlib.Path.exists") as mock_exists,
- patch("pathlib.Path.is_file") as mock_is_file,
- patch("pathlib.Path.expanduser") as mock_expanduser,
- patch("pathlib.Path.resolve") as mock_resolve,
- ):
- mock_exists.return_value = True
- mock_is_file.return_value = False
- mock_expanduser.return_value = Path("directory")
- mock_resolve.return_value = Path("directory")
-
- cli._parse_file_path("directory")
- mock_logger.error.assert_called_once()
- mock_exit.assert_called_once_with(1)
-
class TestVersionCommand:
"""Tests for the version command."""
@@ -259,8 +191,8 @@ class TestDevCommand:
def test_dev_command_success(self, temp_python_file, mock_logger):
"""Test successful dev command execution."""
with (
- patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
- patch("fastmcp.cli.cli._import_server") as mock_import,
+ patch("fastmcp.cli.run.parse_file_path") as mock_parse,
+ patch("fastmcp.cli.run.import_server") as mock_import,
patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx,
patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv,
patch("subprocess.run") as mock_run,
@@ -285,8 +217,8 @@ class TestDevCommand:
def test_dev_command_with_ui_port(self, temp_python_file):
"""Test dev command with UI port."""
with (
- patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
- patch("fastmcp.cli.cli._import_server") as mock_import,
+ patch("fastmcp.cli.run.parse_file_path") as mock_parse,
+ patch("fastmcp.cli.run.import_server") as mock_import,
patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx,
patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv,
patch("subprocess.run") as mock_run,
@@ -310,8 +242,8 @@ class TestDevCommand:
def test_dev_command_with_server_port(self, temp_python_file):
"""Test dev command with server port."""
with (
- patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
- patch("fastmcp.cli.cli._import_server") as mock_import,
+ patch("fastmcp.cli.run.parse_file_path") as mock_parse,
+ patch("fastmcp.cli.run.import_server") as mock_import,
patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx,
patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv,
patch("subprocess.run") as mock_run,
@@ -335,8 +267,8 @@ class TestDevCommand:
def test_dev_command_inspector_version(self, temp_python_file):
"""Test dev command with specific inspector version."""
with (
- patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
- patch("fastmcp.cli.cli._import_server") as mock_import,
+ patch("fastmcp.cli.run.parse_file_path") as mock_parse,
+ patch("fastmcp.cli.run.import_server") as mock_import,
patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx,
patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv,
patch("subprocess.run") as mock_run,
@@ -360,11 +292,12 @@ class TestDevCommand:
class TestRunCommand:
"""Tests for the run command."""
- def test_run_command_success(self, temp_python_file, mock_logger):
+ def test_run_command_success(self, temp_python_file):
"""Test successful run command execution."""
with (
- patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
- patch("fastmcp.cli.cli._import_server") as mock_import,
+ patch("fastmcp.cli.run.parse_file_path") as mock_parse,
+ patch("fastmcp.cli.run.import_server") as mock_import,
+ patch("fastmcp.cli.run.logger") as mock_logger,
):
mock_parse.return_value = (temp_python_file, None)
mock_server = MagicMock()
@@ -374,15 +307,15 @@ class TestRunCommand:
result = runner.invoke(cli.app, ["run", str(temp_python_file)])
assert result.exit_code == 0
mock_server.run.assert_called_once_with()
- mock_logger.info.assert_called_with(
+ mock_logger.debug.assert_called_with(
f'Found server "test_server" in {temp_python_file}'
)
def test_run_command_with_transport(self, temp_python_file):
"""Test run command with transport option."""
with (
- patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
- patch("fastmcp.cli.cli._import_server") as mock_import,
+ patch("fastmcp.cli.run.parse_file_path") as mock_parse,
+ patch("fastmcp.cli.run.import_server") as mock_import,
):
mock_parse.return_value = (temp_python_file, None)
mock_server = MagicMock()
@@ -398,8 +331,8 @@ class TestRunCommand:
def test_run_command_with_host(self, temp_python_file):
"""Test run command with host option."""
with (
- patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
- patch("fastmcp.cli.cli._import_server") as mock_import,
+ patch("fastmcp.cli.run.parse_file_path") as mock_parse,
+ patch("fastmcp.cli.run.import_server") as mock_import,
):
mock_parse.return_value = (temp_python_file, None)
mock_server = MagicMock()
@@ -415,8 +348,8 @@ class TestRunCommand:
def test_run_command_with_port(self, temp_python_file):
"""Test run command with port option."""
with (
- patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
- patch("fastmcp.cli.cli._import_server") as mock_import,
+ patch("fastmcp.cli.run.parse_file_path") as mock_parse,
+ patch("fastmcp.cli.run.import_server") as mock_import,
):
mock_parse.return_value = (temp_python_file, None)
mock_server = MagicMock()
@@ -432,8 +365,8 @@ class TestRunCommand:
def test_run_command_with_log_level(self, temp_python_file):
"""Test run command with log level option."""
with (
- patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
- patch("fastmcp.cli.cli._import_server") as mock_import,
+ patch("fastmcp.cli.run.parse_file_path") as mock_parse,
+ patch("fastmcp.cli.run.import_server") as mock_import,
):
mock_parse.return_value = (temp_python_file, None)
mock_server = MagicMock()
@@ -449,8 +382,8 @@ class TestRunCommand:
def test_run_command_with_multiple_options(self, temp_python_file):
"""Test run command with multiple options."""
with (
- patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
- patch("fastmcp.cli.cli._import_server") as mock_import,
+ patch("fastmcp.cli.run.parse_file_path") as mock_parse,
+ patch("fastmcp.cli.run.import_server") as mock_import,
):
mock_parse.return_value = (temp_python_file, None)
mock_server = MagicMock()
diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py
index 827d90a9d..ea4bcefbe 100644
--- a/tests/cli/test_run.py
+++ b/tests/cli/test_run.py
@@ -1,22 +1,262 @@
+"""Tests for the CLI module."""
+
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
import pytest
+from typer.testing import CliRunner
+
+import fastmcp.cli.run
+from fastmcp.cli import cli
+
+# Set up test runner
+runner = CliRunner()
@pytest.fixture
-def server_file(tmp_path):
- """Create a simple server file for testing"""
- server_path = tmp_path / "test_server.py"
- server_path.write_text(
- """
-from fastmcp import FastMCP
+def mock_console():
+ """Mock the rich console to test output."""
+ with patch("fastmcp.cli.cli.console") as mock_console:
+ yield mock_console
-mcp = FastMCP(name="TestServer")
-@mcp.tool()
-def hello(name: str) -> str:
- return f"Hello, {name}!"
+@pytest.fixture
+def mock_logger():
+ """Mock the logger to test logging."""
+ with patch("fastmcp.cli.cli.logger") as mock_logger:
+ yield mock_logger
-if __name__ == "__main__":
- mcp.run()
+
+@pytest.fixture
+def mock_exit():
+ """Mock sys.exit to prevent tests from exiting."""
+ with patch("sys.exit") as mock_exit:
+ yield mock_exit
+
+
+@pytest.fixture
+def temp_python_file(tmp_path):
+ """Create a temporary Python file with a test server."""
+ server_code = """
+from mcp import Server
+
+class TestServer(Server):
+ name = "test_server"
+ dependencies = ["package1", "package2"]
+
+ def run(self, **kwargs):
+ print("Running server with", kwargs)
+
+mcp = TestServer()
+server = TestServer()
+app = TestServer()
+custom_server = TestServer()
"""
- )
- return server_path
+ file_path = tmp_path / "test_server.py"
+ file_path.write_text(server_code)
+ return file_path
+
+
+@pytest.fixture
+def temp_env_file(tmp_path):
+ """Create a temporary .env file."""
+ env_content = """
+TEST_VAR1=value1
+TEST_VAR2=value2
+"""
+ env_path = tmp_path / ".env"
+ env_path.write_text(env_content)
+ return env_path
+
+
+class TestHelperFunctions:
+ def test_parse_file_path_simple(self):
+ """Test parsing simple file path."""
+ with (
+ patch("pathlib.Path.exists") as mock_exists,
+ patch("pathlib.Path.is_file") as mock_is_file,
+ patch("pathlib.Path.expanduser") as mock_expanduser,
+ patch("pathlib.Path.resolve") as mock_resolve,
+ ):
+ mock_exists.return_value = True
+ mock_is_file.return_value = True
+ mock_expanduser.return_value = Path("file.py")
+ mock_resolve.return_value = Path("file.py")
+
+ path, obj = fastmcp.cli.run.parse_file_path("file.py")
+ assert path == Path("file.py")
+ assert obj is None
+
+ def test_parse_file_path_with_object(self):
+ """Test parsing file path with object."""
+ with (
+ patch("pathlib.Path.exists") as mock_exists,
+ patch("pathlib.Path.is_file") as mock_is_file,
+ patch("pathlib.Path.expanduser") as mock_expanduser,
+ patch("pathlib.Path.resolve") as mock_resolve,
+ ):
+ mock_exists.return_value = True
+ mock_is_file.return_value = True
+ mock_expanduser.return_value = Path("file.py")
+ mock_resolve.return_value = Path("file.py")
+
+ path, obj = fastmcp.cli.run.parse_file_path("file.py:server")
+ assert path == Path("file.py")
+ assert obj == "server"
+
+ def test_parse_file_path_windows(self):
+ """Test parsing Windows file path."""
+ with (
+ patch("pathlib.Path.exists") as mock_exists,
+ patch("pathlib.Path.is_file") as mock_is_file,
+ patch("pathlib.Path.expanduser") as mock_expanduser,
+ patch("pathlib.Path.resolve") as mock_resolve,
+ ):
+ mock_exists.return_value = True
+ mock_is_file.return_value = True
+ mock_expanduser.return_value = Path("C:/path/file.py")
+ mock_resolve.return_value = Path("C:/path/file.py")
+
+ path, obj = fastmcp.cli.run.parse_file_path("C:/path/file.py:server")
+ assert path == Path("C:/path/file.py")
+ assert obj == "server"
+
+ def test_parse_file_path_not_file(self, mock_exit):
+ """Test parsing path that is not a file."""
+ with (
+ patch("pathlib.Path.exists") as mock_exists,
+ patch("pathlib.Path.is_file") as mock_is_file,
+ patch("pathlib.Path.expanduser") as mock_expanduser,
+ patch("pathlib.Path.resolve") as mock_resolve,
+ patch("fastmcp.cli.run.logger") as mock_logger,
+ ):
+ mock_exists.return_value = True
+ mock_is_file.return_value = False
+ mock_expanduser.return_value = Path("directory")
+ mock_resolve.return_value = Path("directory")
+
+ fastmcp.cli.run.parse_file_path("directory")
+ mock_logger.error.assert_called_once()
+ mock_exit.assert_called_once_with(1)
+
+
+class TestRunCommand:
+ """Tests for the run command."""
+
+ def test_run_command_success(self, temp_python_file):
+ """Test successful run command execution."""
+ with (
+ patch("fastmcp.cli.run.parse_file_path") as mock_parse,
+ patch("fastmcp.cli.run.import_server") as mock_import,
+ patch("fastmcp.cli.run.logger") as mock_logger,
+ ):
+ mock_parse.return_value = (temp_python_file, None)
+ mock_server = MagicMock()
+ mock_server.name = "test_server"
+ mock_import.return_value = mock_server
+
+ result = runner.invoke(cli.app, ["run", str(temp_python_file)])
+ assert result.exit_code == 0
+ mock_server.run.assert_called_once_with()
+ mock_logger.debug.assert_called_with(
+ f'Found server "test_server" in {temp_python_file}'
+ )
+
+ def test_run_command_with_transport(self, temp_python_file):
+ """Test run command with transport option."""
+ with (
+ patch("fastmcp.cli.run.parse_file_path") as mock_parse,
+ patch("fastmcp.cli.run.import_server") as mock_import,
+ ):
+ mock_parse.return_value = (temp_python_file, None)
+ mock_server = MagicMock()
+ mock_server.name = "test_server"
+ mock_import.return_value = mock_server
+
+ result = runner.invoke(
+ cli.app, ["run", str(temp_python_file), "--transport", "sse"]
+ )
+ assert result.exit_code == 0
+ mock_server.run.assert_called_once_with(transport="sse")
+
+ def test_run_command_with_host(self, temp_python_file):
+ """Test run command with host option."""
+ with (
+ patch("fastmcp.cli.run.parse_file_path") as mock_parse,
+ patch("fastmcp.cli.run.import_server") as mock_import,
+ ):
+ mock_parse.return_value = (temp_python_file, None)
+ mock_server = MagicMock()
+ mock_server.name = "test_server"
+ mock_import.return_value = mock_server
+
+ result = runner.invoke(
+ cli.app, ["run", str(temp_python_file), "--host", "0.0.0.0"]
+ )
+ assert result.exit_code == 0
+ mock_server.run.assert_called_once_with(host="0.0.0.0")
+
+ def test_run_command_with_port(self, temp_python_file):
+ """Test run command with port option."""
+ with (
+ patch("fastmcp.cli.run.parse_file_path") as mock_parse,
+ patch("fastmcp.cli.run.import_server") as mock_import,
+ ):
+ mock_parse.return_value = (temp_python_file, None)
+ mock_server = MagicMock()
+ mock_server.name = "test_server"
+ mock_import.return_value = mock_server
+
+ result = runner.invoke(
+ cli.app, ["run", str(temp_python_file), "--port", "8080"]
+ )
+ assert result.exit_code == 0
+ mock_server.run.assert_called_once_with(port=8080)
+
+ def test_run_command_with_log_level(self, temp_python_file):
+ """Test run command with log level option."""
+ with (
+ patch("fastmcp.cli.run.parse_file_path") as mock_parse,
+ patch("fastmcp.cli.run.import_server") as mock_import,
+ ):
+ mock_parse.return_value = (temp_python_file, None)
+ mock_server = MagicMock()
+ mock_server.name = "test_server"
+ mock_import.return_value = mock_server
+
+ result = runner.invoke(
+ cli.app, ["run", str(temp_python_file), "--log-level", "DEBUG"]
+ )
+ assert result.exit_code == 0
+ mock_server.run.assert_called_once_with(log_level="DEBUG")
+
+ def test_run_command_with_multiple_options(self, temp_python_file):
+ """Test run command with multiple options."""
+ with (
+ patch("fastmcp.cli.run.parse_file_path") as mock_parse,
+ patch("fastmcp.cli.run.import_server") as mock_import,
+ ):
+ mock_parse.return_value = (temp_python_file, None)
+ mock_server = MagicMock()
+ mock_server.name = "test_server"
+ mock_import.return_value = mock_server
+
+ result = runner.invoke(
+ cli.app,
+ [
+ "run",
+ str(temp_python_file),
+ "--transport",
+ "sse",
+ "--host",
+ "0.0.0.0",
+ "--port",
+ "8080",
+ "--log-level",
+ "DEBUG",
+ ],
+ )
+ assert result.exit_code == 0
+ mock_server.run.assert_called_once_with(
+ transport="sse", host="0.0.0.0", port=8080, log_level="DEBUG"
+ )