diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 822641d16..74e3541da 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,7 +25,7 @@ repos: - repo: local hooks: - id: ty - name: type check + name: ty check entry: uv run ty check language: system types: [python] @@ -37,4 +37,5 @@ repos: rev: v6.0.0 hooks: - id: no-commit-to-branch + name: prevent commits to main args: [--branch, main] diff --git a/src/fastmcp/__init__.py b/src/fastmcp/__init__.py index fa76758dc..3a596584a 100644 --- a/src/fastmcp/__init__.py +++ b/src/fastmcp/__init__.py @@ -6,11 +6,11 @@ from fastmcp.settings import Settings from fastmcp.utilities.logging import configure_logging as _configure_logging settings = Settings() -# if False: -_configure_logging( - level=settings.log_level, - enable_rich_tracebacks=settings.enable_rich_tracebacks, -) +if settings.log_enabled: + _configure_logging( + level=settings.log_level, + enable_rich_tracebacks=settings.enable_rich_tracebacks, + ) from fastmcp.server.server import FastMCP from fastmcp.server.context import Context diff --git a/src/fastmcp/cli/claude.py b/src/fastmcp/cli/claude.py index 33d635b9c..b19c0a7bd 100644 --- a/src/fastmcp/cli/claude.py +++ b/src/fastmcp/cli/claude.py @@ -6,6 +6,7 @@ import sys from pathlib import Path from typing import Any +from fastmcp.utilities.cli import build_uv_run_args from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) @@ -89,20 +90,11 @@ def update_claude_config( else: env_vars = existing_env - # Build uv run command - args = ["run"] - - # Collect all packages in a set to deduplicate - packages = {"fastmcp"} - if with_packages: - packages.update(pkg for pkg in with_packages if pkg) - - # Add all packages with --with - for pkg in sorted(packages): - args.extend(["--with", pkg]) - - if with_editable: - args.extend(["--with-editable", str(with_editable)]) + # Build uv run command using centralized function + args = build_uv_run_args( + with_editable=with_editable, + with_packages=with_packages, + ) # Convert file path to absolute before adding to command # Split off any :object suffix first diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index ca5b5ce6c..7028dc326 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -19,6 +19,7 @@ import fastmcp from fastmcp.cli import run as run_module from fastmcp.cli.install import install_app from fastmcp.server.server import FastMCP +from fastmcp.utilities.cli import build_uv_command from fastmcp.utilities.inspect import FastMCPInfo, inspect_fastmcp from fastmcp.utilities.logging import get_logger @@ -57,46 +58,8 @@ def _parse_env_var(env_var: str) -> tuple[str, str]: return key.strip(), value.strip() -def _build_uv_command( - server_spec: str, - with_editable: Path | None = None, - with_packages: list[str] | None = None, - no_banner: bool = False, - python_version: str | None = None, - with_requirements: Path | None = None, - project: Path | None = None, -) -> list[str]: - """Build the uv run command that runs a MCP server through mcp run.""" - cmd = ["uv", "run"] - - # Add Python version if specified - if python_version: - cmd.extend(["--python", python_version]) - - # Add project if specified - if project: - cmd.extend(["--project", str(project)]) - - cmd.extend(["--with", "fastmcp"]) - - if with_editable: - cmd.extend(["--with-editable", str(with_editable)]) - - if with_packages: - for pkg in with_packages: - if pkg: - cmd.extend(["--with", pkg]) - - if with_requirements: - cmd.extend(["--with-requirements", str(with_requirements)]) - - # Add mcp run command - cmd.extend(["fastmcp", "run", server_spec]) - - if no_banner: - cmd.append("--no-banner") - - return cmd +# The _build_uv_command function has been moved to cli/utils.py +# and is now imported as build_uv_command @app.command @@ -302,16 +265,18 @@ async def dev( if inspector_version: inspector_cmd += f"@{inspector_version}" - uv_cmd = _build_uv_command( + uv_cmd = build_uv_command( server_spec, - with_editable, - with_packages, - no_banner=True, + with_editable=with_editable, + with_packages=with_packages, python_version=python, with_requirements=with_requirements, project=project, ) + # Add --no-banner flag for dev command + uv_cmd.append("--no-banner") + # Run the MCP Inspector command with shell=True on Windows shell = sys.platform == "win32" process = subprocess.run( diff --git a/src/fastmcp/cli/install/claude_code.py b/src/fastmcp/cli/install/claude_code.py index b12b2405e..a4cf72d39 100644 --- a/src/fastmcp/cli/install/claude_code.py +++ b/src/fastmcp/cli/install/claude_code.py @@ -9,6 +9,7 @@ from typing import Annotated import cyclopts from rich import print +from fastmcp.utilities.cli import build_uv_run_args from fastmcp.utilities.logging import get_logger from .shared import process_common_args @@ -106,31 +107,14 @@ def install_claude_code( ) return False - # Build uv run command - args = ["run"] - - # Add Python version if specified - if python_version: - args.extend(["--python", python_version]) - - # Add project if specified - if project: - args.extend(["--project", str(project)]) - - # Collect all packages in a set to deduplicate - packages = {"fastmcp"} - if with_packages: - packages.update(pkg for pkg in with_packages if pkg) - - # Add all packages with --with - for pkg in sorted(packages): - args.extend(["--with", pkg]) - - if with_editable: - args.extend(["--with-editable", str(with_editable)]) - - if with_requirements: - args.extend(["--with-requirements", str(with_requirements)]) + # Build uv run command using centralized function + args = build_uv_run_args( + with_editable=with_editable, + with_packages=with_packages, + python_version=python_version, + with_requirements=with_requirements, + project=project, + ) # Build server spec from parsed components if server_object: diff --git a/src/fastmcp/cli/install/claude_desktop.py b/src/fastmcp/cli/install/claude_desktop.py index 475a0c611..60d17a833 100644 --- a/src/fastmcp/cli/install/claude_desktop.py +++ b/src/fastmcp/cli/install/claude_desktop.py @@ -9,6 +9,7 @@ import cyclopts from rich import print from fastmcp.mcp_config import StdioMCPServer, update_config_file +from fastmcp.utilities.cli import build_uv_run_args from fastmcp.utilities.logging import get_logger from .shared import process_common_args @@ -72,31 +73,14 @@ def install_claude_desktop( config_file = config_dir / "claude_desktop_config.json" - # Build uv run command - args = ["run"] - - # Add Python version if specified - if python_version: - args.extend(["--python", python_version]) - - # Add project if specified - if project: - args.extend(["--project", str(project)]) - - # Collect all packages in a set to deduplicate - packages = {"fastmcp"} - if with_packages: - packages.update(pkg for pkg in with_packages if pkg) - - # Add all packages with --with - for pkg in sorted(packages): - args.extend(["--with", pkg]) - - if with_editable: - args.extend(["--with-editable", str(with_editable)]) - - if with_requirements: - args.extend(["--with-requirements", str(with_requirements)]) + # Build uv run command using centralized function + args = build_uv_run_args( + with_editable=with_editable, + with_packages=with_packages, + python_version=python_version, + with_requirements=with_requirements, + project=project, + ) # Build server spec from parsed components if server_object: diff --git a/src/fastmcp/cli/install/cursor.py b/src/fastmcp/cli/install/cursor.py index 4363cefe8..47a854ecd 100644 --- a/src/fastmcp/cli/install/cursor.py +++ b/src/fastmcp/cli/install/cursor.py @@ -10,6 +10,7 @@ import cyclopts from rich import print from fastmcp.mcp_config import StdioMCPServer, update_config_file +from fastmcp.utilities.cli import build_uv_run_args from fastmcp.utilities.logging import get_logger from .shared import process_common_args @@ -106,31 +107,14 @@ def install_cursor_workspace( config_file = cursor_dir / "mcp.json" - # Build uv run command - args = ["run"] - - # Add Python version if specified - if python_version: - args.extend(["--python", python_version]) - - # Add project if specified - if project: - args.extend(["--project", str(project)]) - - # Collect all packages in a set to deduplicate - packages = {"fastmcp"} - if with_packages: - packages.update(pkg for pkg in with_packages if pkg) - - # Add all packages with --with - for pkg in sorted(packages): - args.extend(["--with", pkg]) - - if with_editable: - args.extend(["--with-editable", str(with_editable)]) - - if with_requirements: - args.extend(["--with-requirements", str(with_requirements)]) + # Build uv run command using centralized function + args = build_uv_run_args( + with_editable=with_editable, + with_packages=with_packages, + python_version=python_version, + with_requirements=with_requirements, + project=project, + ) # Build server spec from parsed components if server_object: @@ -194,31 +178,14 @@ def install_cursor( Returns: True if installation was successful, False otherwise """ - # Build uv run command - args = ["run"] - - # Add Python version if specified - if python_version: - args.extend(["--python", python_version]) - - # Add project if specified - if project: - args.extend(["--project", str(project)]) - - # Collect all packages in a set to deduplicate - packages = {"fastmcp"} - if with_packages: - packages.update(pkg for pkg in with_packages if pkg) - - # Add all packages with --with - for pkg in sorted(packages): - args.extend(["--with", pkg]) - - if with_editable: - args.extend(["--with-editable", str(with_editable)]) - - if with_requirements: - args.extend(["--with-requirements", str(with_requirements)]) + # Build uv run command using centralized function + args = build_uv_run_args( + with_editable=with_editable, + with_packages=with_packages, + python_version=python_version, + with_requirements=with_requirements, + project=project, + ) # Build server spec from parsed components if server_object: diff --git a/src/fastmcp/cli/install/mcp_json.py b/src/fastmcp/cli/install/mcp_json.py index bc845bf2d..2a8ed1dc0 100644 --- a/src/fastmcp/cli/install/mcp_json.py +++ b/src/fastmcp/cli/install/mcp_json.py @@ -9,6 +9,7 @@ import cyclopts import pyperclip from rich import print +from fastmcp.utilities.cli import build_uv_run_args from fastmcp.utilities.logging import get_logger from .shared import process_common_args @@ -47,31 +48,14 @@ def install_mcp_json( True if generation was successful, False otherwise """ try: - # Build uv run command - args = ["run"] - - # Add Python version if specified - if python_version: - args.extend(["--python", python_version]) - - # Add project if specified - if project: - args.extend(["--project", str(project)]) - - # Collect all packages in a set to deduplicate - packages = {"fastmcp"} - if with_packages: - packages.update(pkg for pkg in with_packages if pkg) - - # Add all packages with --with - for pkg in sorted(packages): - args.extend(["--with", pkg]) - - if with_editable: - args.extend(["--with-editable", str(with_editable)]) - - if with_requirements: - args.extend(["--with-requirements", str(with_requirements)]) + # Build uv run command using centralized function + args = build_uv_run_args( + with_editable=with_editable, + with_packages=with_packages, + python_version=python_version, + with_requirements=with_requirements, + project=project, + ) # Build server spec from parsed components if server_object: diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py index 8e363c64d..0f39a4819 100644 --- a/src/fastmcp/cli/run.py +++ b/src/fastmcp/cli/run.py @@ -13,6 +13,7 @@ from typing import Any, Literal from mcp.server.fastmcp import FastMCP as FastMCP1x from fastmcp.server.server import FastMCP +from fastmcp.utilities.cli import build_uv_command from fastmcp.utilities.fastmcp_config import ( DeploymentConfig, EntrypointConfig, @@ -255,31 +256,14 @@ def run_with_uv( port = merged_deploy["port"] path = merged_deploy["path"] log_level = merged_deploy["log_level"] - cmd = ["uv", "run"] - - # Add Python version if specified - if python_version: - cmd.extend(["--python", python_version]) - - # Add project if specified - if project: - cmd.extend(["--project", str(project)]) - - # Add fastmcp package - cmd.extend(["--with", "fastmcp"]) - - # Add additional packages - if with_packages: - for pkg in with_packages: - if pkg: - cmd.extend(["--with", pkg]) - - # Add requirements file - if with_requirements: - cmd.extend(["--with-requirements", str(with_requirements)]) - - # Add fastmcp run command - cmd.extend(["fastmcp", "run", server_spec]) + # Build uv command using centralized function + cmd = build_uv_command( + server_spec, + with_packages=with_packages, + python_version=python_version, + with_requirements=with_requirements, + project=project, + ) # Add transport options if transport: diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 1ecda5a08..91a0bb33b 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -146,6 +146,7 @@ class Settings(BaseSettings): test_mode: bool = False + log_enabled: bool = True log_level: LOG_LEVEL = "INFO" @field_validator("log_level", mode="before") @@ -314,7 +315,7 @@ class Settings(BaseSettings): Whether to include FastMCP meta in the server's MCP responses. If True, a `_fastmcp` key will be added to the `meta` field of all MCP component responses. This key will contain a dict of - various FastMCP-specific metadata, such as tags. + various FastMCP-specific metadata, such as tags. """ ), ), diff --git a/src/fastmcp/utilities/cli.py b/src/fastmcp/utilities/cli.py index 1c208da4d..e0cabddb4 100644 --- a/src/fastmcp/utilities/cli.py +++ b/src/fastmcp/utilities/cli.py @@ -1,6 +1,7 @@ from __future__ import annotations from importlib.metadata import version +from pathlib import Path from typing import TYPE_CHECKING, Any, Literal from rich.align import Align @@ -109,3 +110,119 @@ def log_server_banner( console = Console(stderr=True) console.print(Group("\n", panel, "\n")) + + +def build_uv_command( + server_spec: str, + *, + with_editable: Path | None = None, + with_packages: list[str] | None = None, + python_version: str | None = None, + with_requirements: Path | None = None, + project: Path | None = None, +) -> list[str]: + """Build a uv run command for running a FastMCP server. + + This centralized function ensures consistent path resolution and command building + across all CLI commands. + + Args: + server_spec: Server specification (file path, optionally with :object) + with_editable: Directory to install in editable mode + with_packages: Additional packages to install + python_version: Python version to use (e.g., "3.10", "3.11") + with_requirements: Requirements file to install from + project: Project directory to run within + + Returns: + List of command arguments for subprocess execution + """ + cmd = ["uv", "run"] + + # Add Python version if specified + if python_version: + cmd.extend(["--python", python_version]) + + # Add project if specified - resolve to absolute path + if project: + cmd.extend(["--project", str(project.expanduser().resolve())]) + + # Always include fastmcp + cmd.extend(["--with", "fastmcp"]) + + # Add additional packages + if with_packages: + # Deduplicate and sort packages for consistency + packages = set(pkg for pkg in with_packages if pkg) + for pkg in sorted(packages): + cmd.extend(["--with", pkg]) + + # Add editable directory - resolve to absolute path + if with_editable: + cmd.extend(["--with-editable", str(with_editable.expanduser().resolve())]) + + # Add requirements file - resolve to absolute path + if with_requirements: + cmd.extend( + ["--with-requirements", str(with_requirements.expanduser().resolve())] + ) + + # Add fastmcp run command + cmd.extend(["fastmcp", "run", server_spec]) + + return cmd + + +def build_uv_run_args( + *, + with_editable: Path | None = None, + with_packages: list[str] | None = None, + python_version: str | None = None, + with_requirements: Path | None = None, + project: Path | None = None, +) -> list[str]: + """Build just the uv run arguments without the server spec. + + This is useful for install commands that need to build the args array + without the full command structure. + + Args: + with_editable: Directory to install in editable mode + with_packages: Additional packages to install (fastmcp will be added automatically) + python_version: Python version to use + with_requirements: Requirements file to install from + project: Project directory to run within + + Returns: + List of arguments starting with "run" + """ + args = ["run"] + + # Add Python version if specified + if python_version: + args.extend(["--python", python_version]) + + # Add project if specified - resolve to absolute path + if project: + args.extend(["--project", str(project.expanduser().resolve())]) + + # Collect all packages in a set to deduplicate + packages = {"fastmcp"} + if with_packages: + packages.update(pkg for pkg in with_packages if pkg) + + # Add all packages with --with + for pkg in sorted(packages): + args.extend(["--with", pkg]) + + # Add editable directory - resolve to absolute path + if with_editable: + args.extend(["--with-editable", str(with_editable.expanduser().resolve())]) + + # Add requirements file - resolve to absolute path + if with_requirements: + args.extend( + ["--with-requirements", str(with_requirements.expanduser().resolve())] + ) + + return args diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 4653c189c..d24203733 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -4,7 +4,7 @@ from unittest.mock import Mock, patch import pytest -from fastmcp.cli.cli import _build_uv_command, _parse_env_var, app +from fastmcp.cli.cli import _parse_env_var, app class TestMainCLI: @@ -34,150 +34,6 @@ class TestMainCLI: _parse_env_var("INVALID_FORMAT") assert exc_info.value.code == 1 - def test_build_uv_command_basic(self): - """Test building basic uv command.""" - cmd = _build_uv_command("server.py") - expected = ["uv", "run", "--with", "fastmcp", "fastmcp", "run", "server.py"] - assert cmd == expected - - def test_build_uv_command_with_editable(self): - """Test building uv command with editable package.""" - editable_path = Path("/path/to/package") - cmd = _build_uv_command("server.py", with_editable=editable_path) - expected = [ - "uv", - "run", - "--with", - "fastmcp", - "--with-editable", - str(editable_path), - "fastmcp", - "run", - "server.py", - ] - assert cmd == expected - - def test_build_uv_command_with_packages(self): - """Test building uv command with additional packages.""" - cmd = _build_uv_command("server.py", with_packages=["pkg1", "pkg2"]) - expected = [ - "uv", - "run", - "--with", - "fastmcp", - "--with", - "pkg1", - "--with", - "pkg2", - "fastmcp", - "run", - "server.py", - ] - assert cmd == expected - - def test_build_uv_command_no_banner(self): - """Test building uv command with no banner flag.""" - cmd = _build_uv_command("server.py", no_banner=True) - expected = [ - "uv", - "run", - "--with", - "fastmcp", - "fastmcp", - "run", - "server.py", - "--no-banner", - ] - assert cmd == expected - - def test_build_uv_command_with_python_version(self): - """Test building uv command with Python version.""" - cmd = _build_uv_command("server.py", python_version="3.11") - expected = [ - "uv", - "run", - "--python", - "3.11", - "--with", - "fastmcp", - "fastmcp", - "run", - "server.py", - ] - assert cmd == expected - - def test_build_uv_command_with_project(self): - """Test building uv command with project directory.""" - project_path = Path("/path/to/project") - cmd = _build_uv_command("server.py", project=project_path) - expected = [ - "uv", - "run", - "--project", - str(project_path), - "--with", - "fastmcp", - "fastmcp", - "run", - "server.py", - ] - assert cmd == expected - - def test_build_uv_command_with_requirements(self): - """Test building uv command with requirements file.""" - req_path = Path("requirements.txt") - cmd = _build_uv_command("server.py", with_requirements=req_path) - expected = [ - "uv", - "run", - "--with", - "fastmcp", - "--with-requirements", - "requirements.txt", - "fastmcp", - "run", - "server.py", - ] - assert cmd == expected - - def test_build_uv_command_with_all_options(self): - """Test building uv command with all options.""" - project_path = Path("/my/project") - editable_path = Path("/local/pkg") - requirements_path = Path("reqs.txt") - cmd = _build_uv_command( - "server.py", - python_version="3.10", - project=project_path, - with_packages=["pandas", "numpy"], - with_requirements=requirements_path, - with_editable=editable_path, - no_banner=True, - ) - expected = [ - "uv", - "run", - "--python", - "3.10", - "--project", - str(project_path), - "--with", - "fastmcp", - "--with-editable", - str(editable_path), - "--with", - "pandas", - "--with", - "numpy", - "--with-requirements", - str(requirements_path), - "fastmcp", - "run", - "server.py", - "--no-banner", - ] - assert cmd == expected - class TestVersionCommand: """Test the version command.""" diff --git a/tests/cli/test_cursor.py b/tests/cli/test_cursor.py index 87145eac8..647ae6541 100644 --- a/tests/cli/test_cursor.py +++ b/tests/cli/test_cursor.py @@ -255,9 +255,10 @@ class TestInstallCursor: config_data = json.loads(decoded) assert "--with-editable" in config_data["args"] - # Check for the editable path in a platform-agnostic way - editable_path_str = str(Path("/local/package")) - assert editable_path_str in config_data["args"] + # Check that the path was resolved (should be absolute) + editable_idx = config_data["args"].index("--with-editable") + 1 + resolved_path = config_data["args"][editable_idx] + assert Path(resolved_path).is_absolute() assert "server.py:custom_app" in " ".join(config_data["args"]) @patch("fastmcp.cli.install.cursor.open_deeplink") diff --git a/tests/cli/test_run_with_uv.py b/tests/cli/test_run_with_uv.py index 3aac81fa7..4ede616b6 100644 --- a/tests/cli/test_run_with_uv.py +++ b/tests/cli/test_run_with_uv.py @@ -65,18 +65,12 @@ class TestRunWithUv: assert exc_info.value.code == 0 cmd = mock_run.call_args[0][0] - expected = [ - "uv", - "run", - "--project", - str(Path("/my/project")), - "--with", - "fastmcp", - "fastmcp", - "run", - "server.py", - ] - assert cmd == expected + # Check the basic structure + assert cmd[:3] == ["uv", "run", "--project"] + # Check that the project path is absolute + assert Path(cmd[3]).is_absolute() + # Check the rest of the command + assert cmd[4:] == ["--with", "fastmcp", "fastmcp", "run", "server.py"] @patch("subprocess.run") def test_run_with_uv_with_packages(self, mock_run): @@ -95,9 +89,9 @@ class TestRunWithUv: "--with", "fastmcp", "--with", - "pandas", + "numpy", # sorted alphabetically "--with", - "numpy", + "pandas", # sorted alphabetically "fastmcp", "run", "server.py", @@ -122,7 +116,7 @@ class TestRunWithUv: "--with", "fastmcp", "--with-requirements", - "requirements.txt", + str(req_path.expanduser().resolve()), # resolved to absolute path "fastmcp", "run", "server.py", @@ -190,19 +184,16 @@ class TestRunWithUv: assert exc_info.value.code == 0 cmd = mock_run.call_args[0][0] - expected = [ - "uv", - "run", - "--python", - "3.10", - "--project", - str(Path("/workspace")), - "--with", - "fastmcp", - "--with", - "pandas", - "--with-requirements", - "reqs.txt", + + # Check the structure piece by piece to be platform-agnostic + assert cmd[:5] == ["uv", "run", "--python", "3.10", "--project"] + # Check project path is absolute + assert Path(cmd[5]).is_absolute() + assert cmd[6:10] == ["--with", "fastmcp", "--with", "pandas"] + assert cmd[10] == "--with-requirements" + # Check requirements path is absolute + assert Path(cmd[11]).is_absolute() + assert cmd[12:] == [ "fastmcp", "run", "server.py", @@ -212,7 +203,6 @@ class TestRunWithUv: "9000", "--no-banner", ] - assert cmd == expected @patch("subprocess.run") def test_run_with_uv_error_handling(self, mock_run): diff --git a/tests/utilities/test_cli.py b/tests/utilities/test_cli.py new file mode 100644 index 000000000..905eadc2a --- /dev/null +++ b/tests/utilities/test_cli.py @@ -0,0 +1,173 @@ +from pathlib import Path + +from fastmcp.utilities.cli import build_uv_command + + +class TestBuildUVCommand: + """Test the build_uv_command function.""" + + def test_build_uv_command_basic(self): + """Test building basic uv command.""" + cmd = build_uv_command("server.py") + expected = ["uv", "run", "--with", "fastmcp", "fastmcp", "run", "server.py"] + assert cmd == expected + + def test_build_uv_command_with_editable(self): + """Test building uv command with editable package.""" + editable_path = Path("/path/to/package") + cmd = build_uv_command("server.py", with_editable=editable_path) + expected = [ + "uv", + "run", + "--with", + "fastmcp", + "--with-editable", + str(editable_path.expanduser().resolve()), + "fastmcp", + "run", + "server.py", + ] + assert cmd == expected + + def test_build_uv_command_with_packages(self): + """Test building uv command with additional packages.""" + cmd = build_uv_command("server.py", with_packages=["pkg1", "pkg2"]) + expected = [ + "uv", + "run", + "--with", + "fastmcp", + "--with", + "pkg1", + "--with", + "pkg2", + "fastmcp", + "run", + "server.py", + ] + assert cmd == expected + + def test_build_uv_command_with_python_version(self): + """Test building uv command with Python version.""" + cmd = build_uv_command("server.py", python_version="3.11") + expected = [ + "uv", + "run", + "--python", + "3.11", + "--with", + "fastmcp", + "fastmcp", + "run", + "server.py", + ] + assert cmd == expected + + def test_build_uv_command_with_project(self): + """Test building uv command with project directory.""" + project_path = Path("/path/to/project") + cmd = build_uv_command("server.py", project=project_path) + expected = [ + "uv", + "run", + "--project", + str(project_path.expanduser().resolve()), + "--with", + "fastmcp", + "fastmcp", + "run", + "server.py", + ] + assert cmd == expected + + def test_build_uv_command_with_requirements(self): + """Test building uv command with requirements file.""" + req_path = Path("requirements.txt") + cmd = build_uv_command("server.py", with_requirements=req_path) + expected = [ + "uv", + "run", + "--with", + "fastmcp", + "--with-requirements", + str(req_path.expanduser().resolve()), + "fastmcp", + "run", + "server.py", + ] + assert cmd == expected + + def test_build_uv_command_with_all_options(self): + """Test building uv command with all options.""" + project_path = Path("/my/project") + editable_path = Path("/local/pkg") + requirements_path = Path("reqs.txt") + cmd = build_uv_command( + "server.py", + python_version="3.10", + project=project_path, + with_packages=["pandas", "numpy"], + with_requirements=requirements_path, + with_editable=editable_path, + ) + expected = [ + "uv", + "run", + "--python", + "3.10", + "--project", + str(project_path.expanduser().resolve()), + "--with", + "fastmcp", + "--with", + "numpy", + "--with", + "pandas", + "--with-editable", + str(editable_path.expanduser().resolve()), + "--with-requirements", + str(requirements_path.expanduser().resolve()), + "fastmcp", + "run", + "server.py", + ] + assert cmd == expected + + def test_with_editable_resolves_dot(self): + """Test that '.' in with_editable becomes absolute.""" + cmd = build_uv_command("server.py", with_editable=Path(".")) + idx = cmd.index("--with-editable") + 1 + assert Path(cmd[idx]).is_absolute() + + def test_with_editable_resolves_tilde(self): + """Test that '~' in with_editable is expanded.""" + cmd = build_uv_command("server.py", with_editable=Path("~/project")) + idx = cmd.index("--with-editable") + 1 + assert Path(cmd[idx]).is_absolute() + assert "~" not in cmd[idx] + + def test_with_requirements_resolves_dot(self): + """Test that '.' in with_requirements becomes absolute.""" + cmd = build_uv_command("server.py", with_requirements=Path("./reqs.txt")) + idx = cmd.index("--with-requirements") + 1 + assert Path(cmd[idx]).is_absolute() + + def test_with_requirements_resolves_tilde(self): + """Test that '~' in with_requirements is expanded.""" + cmd = build_uv_command("server.py", with_requirements=Path("~/reqs.txt")) + idx = cmd.index("--with-requirements") + 1 + assert Path(cmd[idx]).is_absolute() + assert "~" not in cmd[idx] + + def test_project_resolves_relative(self): + """Test that relative path in project becomes absolute.""" + cmd = build_uv_command("server.py", project=Path("../project")) + idx = cmd.index("--project") + 1 + assert Path(cmd[idx]).is_absolute() + + def test_project_resolves_tilde(self): + """Test that '~' in project is expanded.""" + cmd = build_uv_command("server.py", project=Path("~/work")) + idx = cmd.index("--project") + 1 + assert Path(cmd[idx]).is_absolute() + assert "~" not in cmd[idx]