From 5aacd0b3ca9eecd88bb7ec6168426f5214036f75 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 17 May 2025 15:04:55 -0400 Subject: [PATCH 1/3] fastmcp run works with remote servers --- docs/deployment/cli.mdx | 17 +++- src/fastmcp/cli/cli.py | 166 ++++++-------------------------- src/fastmcp/cli/run.py | 179 +++++++++++++++++++++++++++++++++++ src/fastmcp/server/http.py | 3 + src/fastmcp/server/server.py | 17 +++- 5 files changed, 236 insertions(+), 146 deletions(-) create mode 100644 src/fastmcp/cli/run.py diff --git a/docs/deployment/cli.mdx b/docs/deployment/cli.mdx index 1397d8aeb..a010722d2 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 @@ -48,12 +48,13 @@ This command runs the server directly in your current Python environment. You ar #### 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 +80,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..869368419 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -213,7 +213,7 @@ class FastMCP(Generic[LifespanResultT]): Args: transport: Transport protocol to use ("stdio", "sse", or "streamable-http") """ - logger.info(f'Starting server "{self.name}"...') + logger.debug(f'Starting server "{self.name}"...') anyio.run(partial(self.run_async, transport, **transport_kwargs)) @@ -730,6 +730,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 +764,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( From de14845868e0af7f2c99dbcc2f61a1994480685b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 17 May 2025 15:07:23 -0400 Subject: [PATCH 2/3] Update cli.mdx --- docs/deployment/cli.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/deployment/cli.mdx b/docs/deployment/cli.mdx index a010722d2..7bfd13566 100644 --- a/docs/deployment/cli.mdx +++ b/docs/deployment/cli.mdx @@ -47,6 +47,7 @@ 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 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. From 24ed957f3a6b0e499d9e33919b18dd0ed6a96034 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 17 May 2025 18:02:23 -0400 Subject: [PATCH 3/3] Update tests --- src/fastmcp/server/server.py | 1 - tests/cli/test_cli.py | 113 +++------------ tests/cli/test_run.py | 268 +++++++++++++++++++++++++++++++++-- 3 files changed, 277 insertions(+), 105 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 869368419..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.debug(f'Starting server "{self.name}"...') anyio.run(partial(self.run_async, transport, **transport_kwargs)) 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" + )