Consolidate server loading logic into FileSystemSource (#1614)

This commit is contained in:
Jeremiah Lowin 2025-08-24 20:50:25 -04:00 committed by GitHub
commit 9c5c4f5113
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1117 additions and 520 deletions

View file

@ -328,6 +328,47 @@ class TestRunCommand:
]
)
def test_run_command_parsing_skip_env_flag(self):
"""Test run command parsing with --skip-env flag."""
command, bound, _ = app.parse_args(
[
"run",
"server.py",
"--skip-env",
]
)
assert command is not None
assert bound.arguments["server_spec"] == "server.py"
assert bound.arguments["skip_env"] is True
def test_run_command_parsing_skip_source_flag(self):
"""Test run command parsing with --skip-source flag."""
command, bound, _ = app.parse_args(
[
"run",
"server.py",
"--skip-source",
]
)
assert command is not None
assert bound.arguments["server_spec"] == "server.py"
assert bound.arguments["skip_source"] is True
def test_run_command_parsing_both_skip_flags(self):
"""Test run command parsing with both --skip-env and --skip-source flags."""
command, bound, _ = app.parse_args(
[
"run",
"server.py",
"--skip-env",
"--skip-source",
]
)
assert command is not None
assert bound.arguments["server_spec"] == "server.py"
assert bound.arguments["skip_env"] is True
assert bound.arguments["skip_source"] is True
class TestWindowsSpecific:
"""Test Windows-specific functionality."""
@ -413,22 +454,27 @@ class TestWindowsSpecific:
def test_windows_path_parsing_with_colon(self, tmp_path):
"""Test parsing Windows paths with drive letters and colons."""
from fastmcp.cli.run import parse_file_path
from pathlib import Path
from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import (
FileSystemSource,
)
# Create a real test file to test the logic
test_file = tmp_path / "server.py"
test_file.write_text("# test server")
# Test normal file parsing (works on all platforms)
file_path, obj = parse_file_path(str(test_file))
assert obj is None
source = FileSystemSource(path=str(test_file))
assert source.entrypoint is None
assert Path(source.path).resolve() == test_file.resolve()
# Test file:object parsing
file_path, obj = parse_file_path(f"{test_file}:myapp")
assert obj == "myapp"
source = FileSystemSource(path=f"{test_file}:myapp")
assert source.entrypoint == "myapp"
# Test that the file portion resolves correctly when object is specified
assert file_path == test_file.resolve()
assert Path(source.path).resolve() == test_file.resolve()
class TestInspectCommand:

View file

@ -11,8 +11,8 @@ from fastmcp.utilities.fastmcp_config import (
Deployment,
Environment,
FastMCPConfig,
FileSystemSource,
)
from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource
class TestFileSystemSource:

View file

@ -280,7 +280,11 @@ class TestInstallCursor:
# Verify failure message was printed
mock_print.assert_called()
def test_install_cursor_deduplicate_packages(self):
@patch(
"fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path",
return_value=None, # Mock to disable dev mode so "fastmcp" count is predictable
)
def test_install_cursor_deduplicate_packages(self, mock_find_dev):
"""Test that duplicate packages are deduplicated."""
with patch("fastmcp.cli.install.cursor.open_deeplink") as mock_open:
mock_open.return_value = True

View file

@ -7,14 +7,13 @@ from pydantic import ValidationError
from fastmcp.cli.run import (
create_mcp_config_server,
import_server,
is_url,
parse_file_path,
)
from fastmcp.client.client import Client
from fastmcp.client.transports import FastMCPTransport
from fastmcp.mcp_config import MCPConfig, StdioMCPServer
from fastmcp.server.server import FastMCP
from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource
class TestUrlDetection:
@ -41,52 +40,54 @@ class TestUrlDetection:
assert not is_url("file:///path/to/file")
class TestFilePathParsing:
"""Test file path parsing functionality."""
class TestFileSystemSource:
"""Test FileSystemSource path parsing functionality."""
def test_parse_file_path_simple(self, tmp_path):
def test_parse_simple_path(self, tmp_path):
"""Test parsing simple file path without object."""
test_file = tmp_path / "server.py"
test_file.write_text("# test server")
file_path, server_object = parse_file_path(str(test_file))
assert file_path == test_file.resolve()
assert server_object is None
source = FileSystemSource(path=str(test_file))
assert Path(source.path).resolve() == test_file.resolve()
assert source.entrypoint is None
def test_parse_file_path_with_object(self, tmp_path):
def test_parse_path_with_object(self, tmp_path):
"""Test parsing file path with object specification."""
test_file = tmp_path / "server.py"
test_file.write_text("# test server")
file_path, server_object = parse_file_path(f"{test_file}:app")
assert file_path == test_file.resolve()
assert server_object == "app"
source = FileSystemSource(path=f"{test_file}:app")
assert Path(source.path).resolve() == test_file.resolve()
assert source.entrypoint == "app"
def test_parse_file_path_complex_object(self, tmp_path):
def test_parse_complex_object(self, tmp_path):
"""Test parsing file path with complex object specification."""
test_file = tmp_path / "server.py"
test_file.write_text("# test server")
# The current implementation splits on the last colon, so file:module:app
# becomes file_path="file:module" and server_object="app"
# The implementation splits on the last colon, so file:module:app
# becomes file_path="file:module" and entrypoint="app"
# We need to create a file with a colon in the name for this test
complex_file = tmp_path / "server:module.py"
complex_file.write_text("# test server")
file_path, server_object = parse_file_path(f"{complex_file}:app")
assert file_path == complex_file.resolve()
assert server_object == "app"
source = FileSystemSource(path=f"{complex_file}:app")
assert Path(source.path).resolve() == complex_file.resolve()
assert source.entrypoint == "app"
def test_parse_file_path_nonexistent(self):
"""Test parsing nonexistent file path exits."""
async def test_load_server_nonexistent(self):
"""Test loading nonexistent file path exits."""
source = FileSystemSource(path="nonexistent.py")
with pytest.raises(SystemExit) as exc_info:
parse_file_path("nonexistent.py")
await source.load_server()
assert exc_info.value.code == 1
def test_parse_file_path_directory(self, tmp_path):
"""Test parsing directory path exits."""
async def test_load_server_directory(self, tmp_path):
"""Test loading directory path exits."""
source = FileSystemSource(path=str(tmp_path))
with pytest.raises(SystemExit) as exc_info:
parse_file_path(str(tmp_path))
await source.load_server()
assert exc_info.value.code == 1
@ -157,7 +158,8 @@ def greet(name: str) -> str:
return f"Hello, {name}!"
""")
server = await import_server(test_file)
source = FileSystemSource(path=str(test_file))
server = await source.load_server()
assert server.name == "TestServer"
tools = await server.get_tools()
assert "greet" in tools
@ -178,7 +180,8 @@ if __name__ == "__main__":
app.run()
""")
server = await import_server(test_file)
source = FileSystemSource(path=str(test_file))
server = await source.load_server()
assert server.name == "MainServer"
tools = await server.get_tools()
assert "calculate" in tools
@ -192,7 +195,8 @@ import fastmcp
mcp = fastmcp.FastMCP("MCPServer")
""")
server = await import_server(mcp_file)
source = FileSystemSource(path=str(mcp_file))
server = await source.load_server()
assert server.name == "MCPServer"
# Test with 'server' name
@ -202,7 +206,8 @@ import fastmcp
server = fastmcp.FastMCP("ServerServer")
""")
server = await import_server(server_file)
source = FileSystemSource(path=str(server_file))
server = await source.load_server()
assert server.name == "ServerServer"
# Test with 'app' name
@ -212,7 +217,8 @@ import fastmcp
app = fastmcp.FastMCP("AppServer")
""")
server = await import_server(app_file)
source = FileSystemSource(path=str(app_file))
server = await source.load_server()
assert server.name == "AppServer"
async def test_import_server_nonstandard_name(self, tmp_path):
@ -228,7 +234,8 @@ def custom_tool() -> str:
return "custom"
""")
server = await import_server(test_file, "my_custom_server")
source = FileSystemSource(path=f"{test_file}:my_custom_server")
server = await source.load_server()
assert server.name == "CustomServer"
tools = await server.get_tools()
assert "custom_tool" in tools
@ -242,8 +249,9 @@ import fastmcp
other_name = fastmcp.FastMCP("OtherServer")
""")
source = FileSystemSource(path=str(test_file))
with pytest.raises(SystemExit) as exc_info:
await import_server(test_file)
await source.load_server()
assert exc_info.value.code == 1
async def test_import_server_nonexistent_object_fails(self, tmp_path):
@ -255,6 +263,131 @@ import fastmcp
mcp = fastmcp.FastMCP("TestServer")
""")
source = FileSystemSource(path=f"{test_file}:nonexistent")
with pytest.raises(SystemExit) as exc_info:
await import_server(test_file, "nonexistent")
await source.load_server()
assert exc_info.value.code == 1
class TestSkipSource:
"""Test the --skip-source functionality."""
async def test_run_command_calls_prepare_by_default(self, tmp_path):
"""Test that run_command calls source.prepare() by default."""
from unittest.mock import AsyncMock, patch
from fastmcp.cli.run import run_command
# Create a test server file
test_file = tmp_path / "server.py"
test_file.write_text("""
import fastmcp
mcp = fastmcp.FastMCP("TestServer")
""")
# Create a test config file
config_file = tmp_path / "fastmcp.json"
config_data = {"source": {"path": str(test_file), "entrypoint": "mcp"}}
config_file.write_text(json.dumps(config_data))
# Mock the prepare method and server run
with (
patch.object(
FileSystemSource, "prepare", new_callable=AsyncMock
) as prepare_mock,
patch("fastmcp.server.server.FastMCP.run_async", new_callable=AsyncMock),
):
# Run the command
await run_command(str(config_file))
# Verify prepare was called
prepare_mock.assert_called_once()
async def test_run_command_skips_prepare_with_flag(self, tmp_path):
"""Test that run_command skips source.prepare() when skip_source=True."""
from unittest.mock import AsyncMock, patch
from fastmcp.cli.run import run_command
# Create a test server file
test_file = tmp_path / "server.py"
test_file.write_text("""
import fastmcp
mcp = fastmcp.FastMCP("TestServer")
""")
# Create a test config file
config_file = tmp_path / "fastmcp.json"
config_data = {"source": {"path": str(test_file), "entrypoint": "mcp"}}
config_file.write_text(json.dumps(config_data))
# Mock the prepare method and server run
with (
patch.object(
FileSystemSource, "prepare", new_callable=AsyncMock
) as prepare_mock,
patch("fastmcp.server.server.FastMCP.run_async", new_callable=AsyncMock),
):
# Run the command with skip_source=True
await run_command(str(config_file), skip_source=True)
# Verify prepare was NOT called
prepare_mock.assert_not_called()
async def test_filesystem_source_prepare_by_default(self, tmp_path):
"""Test that FileSystemSource is prepared when using direct file spec."""
from unittest.mock import AsyncMock, patch
from fastmcp.cli.run import run_command
from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import (
FileSystemSource,
)
# Create a test server file
test_file = tmp_path / "server.py"
test_file.write_text("""
import fastmcp
mcp = fastmcp.FastMCP("TestServer")
""")
# Mock the prepare method and server run
with (
patch.object(
FileSystemSource, "prepare", new_callable=AsyncMock
) as prepare_mock,
patch("fastmcp.server.server.FastMCP.run_async", new_callable=AsyncMock),
):
# Run with direct file specification
await run_command(str(test_file))
# Verify prepare was called
prepare_mock.assert_called_once()
async def test_filesystem_source_skip_prepare_with_flag(self, tmp_path):
"""Test that FileSystemSource.prepare() is skipped with skip_source flag."""
from unittest.mock import AsyncMock, patch
from fastmcp.cli.run import run_command
from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import (
FileSystemSource,
)
# Create a test server file
test_file = tmp_path / "server.py"
test_file.write_text("""
import fastmcp
mcp = fastmcp.FastMCP("TestServer")
""")
# Mock the prepare method and server run
with (
patch.object(
FileSystemSource, "prepare", new_callable=AsyncMock
) as prepare_mock,
patch("fastmcp.server.server.FastMCP.run_async", new_callable=AsyncMock),
):
# Run with direct file specification and skip_source=True
await run_command(str(test_file), skip_source=True)
# Verify prepare was NOT called
prepare_mock.assert_not_called()

View file

@ -11,8 +11,8 @@ from fastmcp.utilities.fastmcp_config import (
Deployment,
Environment,
FastMCPConfig,
FileSystemSource,
)
from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource
@pytest.fixture

View file

@ -12,8 +12,12 @@ from fastmcp.cli.run import run_with_uv
class TestRunWithUv:
"""Test the run_with_uv function."""
@patch(
"fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path",
return_value=None,
)
@patch("subprocess.run")
def test_run_with_uv_basic(self, mock_run):
def test_run_with_uv_basic(self, mock_run, mock_find_dev_path):
"""Test basic run_with_uv execution."""
mock_run.return_value = Mock(returncode=0)
@ -38,8 +42,12 @@ class TestRunWithUv:
]
assert cmd == expected
@patch(
"fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path",
return_value=None,
)
@patch("subprocess.run")
def test_run_with_uv_python_version(self, mock_run):
def test_run_with_uv_python_version(self, mock_run, mock_find_dev_path):
"""Test run_with_uv with Python version."""
mock_run.return_value = Mock(returncode=0)
@ -63,8 +71,12 @@ class TestRunWithUv:
]
assert cmd == expected
@patch(
"fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path",
return_value=None,
)
@patch("subprocess.run")
def test_run_with_uv_project(self, mock_run):
def test_run_with_uv_project(self, mock_run, mock_find_dev_path):
"""Test run_with_uv with project directory."""
mock_run.return_value = Mock(returncode=0)
# Use an absolute path that works on all platforms
@ -90,8 +102,12 @@ class TestRunWithUv:
"--skip-env",
]
@patch(
"fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path",
return_value=None,
)
@patch("subprocess.run")
def test_run_with_uv_with_packages(self, mock_run):
def test_run_with_uv_with_packages(self, mock_run, mock_find_dev_path):
"""Test run_with_uv with additional packages."""
mock_run.return_value = Mock(returncode=0)
@ -117,8 +133,12 @@ class TestRunWithUv:
]
assert cmd == expected
@patch(
"fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path",
return_value=None,
)
@patch("subprocess.run")
def test_run_with_uv_with_requirements(self, mock_run):
def test_run_with_uv_with_requirements(self, mock_run, mock_find_dev_path):
"""Test run_with_uv with requirements file."""
mock_run.return_value = Mock(returncode=0)
req_path = Path("requirements.txt")
@ -143,8 +163,12 @@ class TestRunWithUv:
]
assert cmd == expected
@patch(
"fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path",
return_value=None,
)
@patch("subprocess.run")
def test_run_with_uv_transport_options(self, mock_run):
def test_run_with_uv_transport_options(self, mock_run, mock_find_dev_path):
"""Test run_with_uv with transport-related options."""
mock_run.return_value = Mock(returncode=0)
@ -185,8 +209,12 @@ class TestRunWithUv:
]
assert cmd == expected
@patch(
"fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path",
return_value=None,
)
@patch("subprocess.run")
def test_run_with_uv_all_options(self, mock_run):
def test_run_with_uv_all_options(self, mock_run, mock_find_dev_path):
"""Test run_with_uv with all options combined."""
mock_run.return_value = Mock(returncode=0)
@ -230,8 +258,12 @@ class TestRunWithUv:
"--no-banner",
]
@patch(
"fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path",
return_value=None,
)
@patch("subprocess.run")
def test_run_with_uv_error_handling(self, mock_run):
def test_run_with_uv_error_handling(self, mock_run, mock_find_dev_path):
"""Test run_with_uv error handling."""
mock_run.side_effect = subprocess.CalledProcessError(1, ["uv", "run"])
@ -240,9 +272,13 @@ class TestRunWithUv:
assert exc_info.value.code == 1
@patch(
"fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path",
return_value=None,
)
@patch("fastmcp.cli.run.logger")
@patch("subprocess.run")
def test_run_with_uv_logging(self, mock_run, mock_logger):
def test_run_with_uv_logging(self, mock_run, mock_logger, mock_find_dev_path):
"""Test that run_with_uv logs the command."""
mock_run.return_value = Mock(returncode=0)

View file

@ -0,0 +1,139 @@
"""Test server argument passing functionality."""
from pathlib import Path
import pytest
from fastmcp.utilities.fastmcp_config import FastMCPConfig
from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource
class TestServerArguments:
"""Test passing arguments to servers."""
@pytest.mark.asyncio
async def test_server_with_argparse(self, tmp_path):
"""Test a server that uses argparse with command line arguments."""
server_file = tmp_path / "argparse_server.py"
server_file.write_text("""
import argparse
from fastmcp import FastMCP
parser = argparse.ArgumentParser()
parser.add_argument("--name", default="DefaultServer")
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--debug", action="store_true")
args = parser.parse_args()
server_name = f"{args.name}:{args.port}"
if args.debug:
server_name += " (Debug)"
mcp = FastMCP(server_name)
@mcp.tool
def get_config() -> dict:
return {"name": args.name, "port": args.port, "debug": args.debug}
""")
# Test with arguments
source = FileSystemSource(path=str(server_file))
config = FastMCPConfig(source=source)
from fastmcp.cli.cli import with_argv
# Simulate passing arguments
with with_argv(["--name", "TestServer", "--port", "9000", "--debug"]):
server = await config.source.load_server()
assert server.name == "TestServer:9000 (Debug)"
# Test the tool works and can access the parsed args
tools = await server.get_tools()
assert "get_config" in tools
@pytest.mark.asyncio
async def test_server_with_no_args(self, tmp_path):
"""Test a server that uses argparse with no arguments (defaults)."""
server_file = tmp_path / "default_server.py"
server_file.write_text("""
import argparse
from fastmcp import FastMCP
parser = argparse.ArgumentParser()
parser.add_argument("--name", default="DefaultName")
args = parser.parse_args()
mcp = FastMCP(args.name)
""")
source = FileSystemSource(path=str(server_file))
config = FastMCPConfig(source=source)
from fastmcp.cli.cli import with_argv
# Test with empty args list (should use defaults)
with with_argv([]):
server = await config.source.load_server()
assert server.name == "DefaultName"
@pytest.mark.asyncio
async def test_server_with_sys_argv_access(self, tmp_path):
"""Test a server that directly accesses sys.argv."""
server_file = tmp_path / "sysargv_server.py"
server_file.write_text("""
import sys
from fastmcp import FastMCP
# Direct sys.argv access (less common but should work)
name = "DirectServer"
if len(sys.argv) > 1 and sys.argv[1] == "--custom":
name = "CustomServer"
mcp = FastMCP(name)
""")
source = FileSystemSource(path=str(server_file))
config = FastMCPConfig(source=source)
from fastmcp.cli.cli import with_argv
# Test with custom argument
with with_argv(["--custom"]):
server = await config.source.load_server()
assert server.name == "CustomServer"
# Test without argument
with with_argv([]):
server = await config.source.load_server()
assert server.name == "DirectServer"
@pytest.mark.asyncio
async def test_config_server_example(self):
"""Test the actual config_server.py example."""
# Find the examples directory
examples_dir = Path(__file__).parent.parent.parent / "examples"
config_server = examples_dir / "config_server.py"
if not config_server.exists():
pytest.skip("config_server.py example not found")
source = FileSystemSource(path=str(config_server))
config = FastMCPConfig(source=source)
from fastmcp.cli.cli import with_argv
# Test with debug flag
with with_argv(["--name", "TestExample", "--debug"]):
server = await config.source.load_server()
assert server.name == "TestExample (Debug)"
# Verify tools are available
tools = await server.get_tools()
assert "get_status" in tools
assert "echo_message" in tools

View file

@ -0,0 +1,91 @@
"""Test the with_argv context manager."""
import sys
from unittest.mock import patch
import pytest
from fastmcp.cli.cli import with_argv
class TestWithArgv:
"""Test the with_argv context manager."""
def test_with_argv_replaces_args(self):
"""Test that with_argv properly replaces sys.argv."""
original_argv = sys.argv[:]
test_args = ["--name", "TestServer", "--debug"]
with with_argv(test_args):
# Should preserve script name and add new args
assert sys.argv[0] == original_argv[0]
assert sys.argv[1:] == test_args
# Should restore original argv after context
assert sys.argv == original_argv
def test_with_argv_none_does_nothing(self):
"""Test that with_argv(None) doesn't change sys.argv."""
original_argv = sys.argv[:]
with with_argv(None):
assert sys.argv == original_argv
assert sys.argv == original_argv
def test_with_argv_empty_list(self):
"""Test that with_argv([]) clears arguments but keeps script name."""
original_argv = sys.argv[:]
with with_argv([]):
# Should have only the script name (no additional args)
assert sys.argv == [original_argv[0]]
assert len(sys.argv) == 1
assert sys.argv == original_argv
def test_with_argv_restores_on_exception(self):
"""Test that sys.argv is restored even if an exception occurs."""
original_argv = sys.argv[:]
test_args = ["--error"]
with pytest.raises(ValueError):
with with_argv(test_args):
assert sys.argv == [original_argv[0]] + test_args
raise ValueError("Test error")
# Should still restore original argv
assert sys.argv == original_argv
def test_with_argv_nested(self):
"""Test nested with_argv contexts."""
original_argv = sys.argv[:]
args1 = ["--level1"]
args2 = ["--level2", "--debug"]
with with_argv(args1):
assert sys.argv == [original_argv[0]] + args1
with with_argv(args2):
assert sys.argv == [original_argv[0]] + args2
# Should restore to level 1
assert sys.argv == [original_argv[0]] + args1
# Should restore to original
assert sys.argv == original_argv
@patch("sys.argv", ["test_script.py", "existing", "args"])
def test_with_argv_with_existing_args(self):
"""Test with_argv when sys.argv already has arguments."""
original_argv = sys.argv[:]
assert original_argv == ["test_script.py", "existing", "args"]
test_args = ["--new", "args"]
with with_argv(test_args):
# Should replace existing args but keep script name
assert sys.argv == ["test_script.py", "--new", "args"]
# Should restore original
assert sys.argv == original_argv