From 74ce25726a8522e57f55fb2d149f8822cdd9740a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 23 Aug 2025 09:52:18 -0400 Subject: [PATCH 1/3] move schemas to fastmcp.json (#1597) --- .../latest.json | 0 .../{fastmcp-json => fastmcp.json}/v1.json | 0 .../public/schemas/fastmcp_config/latest.json | 361 ------------------ docs/public/schemas/fastmcp_config/v1.json | 361 ------------------ 4 files changed, 722 deletions(-) rename docs/public/schemas/{fastmcp-json => fastmcp.json}/latest.json (100%) rename docs/public/schemas/{fastmcp-json => fastmcp.json}/v1.json (100%) delete mode 100644 docs/public/schemas/fastmcp_config/latest.json delete mode 100644 docs/public/schemas/fastmcp_config/v1.json diff --git a/docs/public/schemas/fastmcp-json/latest.json b/docs/public/schemas/fastmcp.json/latest.json similarity index 100% rename from docs/public/schemas/fastmcp-json/latest.json rename to docs/public/schemas/fastmcp.json/latest.json diff --git a/docs/public/schemas/fastmcp-json/v1.json b/docs/public/schemas/fastmcp.json/v1.json similarity index 100% rename from docs/public/schemas/fastmcp-json/v1.json rename to docs/public/schemas/fastmcp.json/v1.json diff --git a/docs/public/schemas/fastmcp_config/latest.json b/docs/public/schemas/fastmcp_config/latest.json deleted file mode 100644 index 2456b9058..000000000 --- a/docs/public/schemas/fastmcp_config/latest.json +++ /dev/null @@ -1,361 +0,0 @@ -{ - "$defs": { - "DeploymentConfig": { - "description": "Configuration for server deployment and runtime settings.", - "properties": { - "transport": { - "anyOf": [ - { - "enum": [ - "stdio", - "http", - "sse" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Transport protocol to use", - "title": "Transport" - }, - "host": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Host to bind to when using HTTP transport", - "examples": [ - "127.0.0.1", - "0.0.0.0", - "localhost" - ], - "title": "Host" - }, - "port": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Port to bind to when using HTTP transport", - "examples": [ - 8000, - 3000, - 5000 - ], - "title": "Port" - }, - "path": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "URL path for the server endpoint", - "examples": [ - "/mcp/", - "/api/mcp/", - "/sse/" - ], - "title": "Path" - }, - "log_level": { - "anyOf": [ - { - "enum": [ - "DEBUG", - "INFO", - "WARNING", - "ERROR", - "CRITICAL" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Log level for the server", - "title": "Log Level" - }, - "cwd": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Working directory for the server process", - "examples": [ - ".", - "./src", - "/app" - ], - "title": "Cwd" - }, - "env": { - "anyOf": [ - { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Environment variables to set when running the server", - "examples": [ - { - "API_KEY": "secret", - "DEBUG": "true" - } - ], - "title": "Env" - }, - "args": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arguments to pass to the server (after --)", - "examples": [ - [ - "--config", - "config.json", - "--debug" - ] - ], - "title": "Args" - } - }, - "title": "DeploymentConfig", - "type": "object" - }, - "EntrypointConfig": { - "description": "Configuration for server entrypoint when using object format.", - "properties": { - "file": { - "description": "Path to Python file containing the server", - "examples": [ - "server.py", - "src/server.py", - "app/main.py" - ], - "title": "File", - "type": "string" - }, - "object": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Name of the server object in the file (defaults to searching for mcp/server/app)", - "examples": [ - "app", - "mcp", - "server" - ], - "title": "Object" - }, - "repo": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Git repository URL", - "examples": [ - "https://github.com/user/repo" - ], - "title": "Repo" - } - }, - "required": [ - "file" - ], - "title": "EntrypointConfig", - "type": "object" - }, - "EnvironmentConfig": { - "description": "Configuration for Python environment setup.", - "properties": { - "python": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Python version constraint", - "examples": [ - "3.10", - "3.11", - "3.12" - ], - "title": "Python" - }, - "dependencies": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Python packages to install with PEP 508 specifiers", - "examples": [ - [ - "fastmcp>=2.0,<3", - "httpx", - "pandas>=2.0" - ] - ], - "title": "Dependencies" - }, - "requirements": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Path to requirements.txt file", - "examples": [ - "requirements.txt", - "../requirements/prod.txt" - ], - "title": "Requirements" - }, - "project": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Path to project directory containing pyproject.toml", - "examples": [ - ".", - "../my-project" - ], - "title": "Project" - }, - "editable": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Directory to install in editable mode", - "examples": [ - ".", - "../my-package" - ], - "title": "Editable" - } - }, - "title": "EnvironmentConfig", - "type": "object" - } - }, - "description": "Configuration file for FastMCP servers", - "properties": { - "$schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", - "description": "JSON schema for IDE support and validation", - "title": "$Schema" - }, - "entrypoint": { - "$ref": "#/$defs/EntrypointConfig", - "description": "Server entrypoint as a string (file or file:object) or object with file/object/repo", - "examples": [ - "server.py", - "server.py:app", - { - "file": "src/server.py", - "object": "app" - } - ] - }, - "environment": { - "$ref": "#/$defs/EnvironmentConfig", - "description": "Python environment setup configuration" - }, - "deployment": { - "$ref": "#/$defs/DeploymentConfig", - "description": "Server deployment and runtime settings" - } - }, - "required": [ - "entrypoint" - ], - "title": "FastMCP Configuration", - "type": "object", - "$id": "https://gofastmcp.com/schemas/fastmcp_config/v1.json" -} diff --git a/docs/public/schemas/fastmcp_config/v1.json b/docs/public/schemas/fastmcp_config/v1.json deleted file mode 100644 index 2456b9058..000000000 --- a/docs/public/schemas/fastmcp_config/v1.json +++ /dev/null @@ -1,361 +0,0 @@ -{ - "$defs": { - "DeploymentConfig": { - "description": "Configuration for server deployment and runtime settings.", - "properties": { - "transport": { - "anyOf": [ - { - "enum": [ - "stdio", - "http", - "sse" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Transport protocol to use", - "title": "Transport" - }, - "host": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Host to bind to when using HTTP transport", - "examples": [ - "127.0.0.1", - "0.0.0.0", - "localhost" - ], - "title": "Host" - }, - "port": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Port to bind to when using HTTP transport", - "examples": [ - 8000, - 3000, - 5000 - ], - "title": "Port" - }, - "path": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "URL path for the server endpoint", - "examples": [ - "/mcp/", - "/api/mcp/", - "/sse/" - ], - "title": "Path" - }, - "log_level": { - "anyOf": [ - { - "enum": [ - "DEBUG", - "INFO", - "WARNING", - "ERROR", - "CRITICAL" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Log level for the server", - "title": "Log Level" - }, - "cwd": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Working directory for the server process", - "examples": [ - ".", - "./src", - "/app" - ], - "title": "Cwd" - }, - "env": { - "anyOf": [ - { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Environment variables to set when running the server", - "examples": [ - { - "API_KEY": "secret", - "DEBUG": "true" - } - ], - "title": "Env" - }, - "args": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arguments to pass to the server (after --)", - "examples": [ - [ - "--config", - "config.json", - "--debug" - ] - ], - "title": "Args" - } - }, - "title": "DeploymentConfig", - "type": "object" - }, - "EntrypointConfig": { - "description": "Configuration for server entrypoint when using object format.", - "properties": { - "file": { - "description": "Path to Python file containing the server", - "examples": [ - "server.py", - "src/server.py", - "app/main.py" - ], - "title": "File", - "type": "string" - }, - "object": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Name of the server object in the file (defaults to searching for mcp/server/app)", - "examples": [ - "app", - "mcp", - "server" - ], - "title": "Object" - }, - "repo": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Git repository URL", - "examples": [ - "https://github.com/user/repo" - ], - "title": "Repo" - } - }, - "required": [ - "file" - ], - "title": "EntrypointConfig", - "type": "object" - }, - "EnvironmentConfig": { - "description": "Configuration for Python environment setup.", - "properties": { - "python": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Python version constraint", - "examples": [ - "3.10", - "3.11", - "3.12" - ], - "title": "Python" - }, - "dependencies": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Python packages to install with PEP 508 specifiers", - "examples": [ - [ - "fastmcp>=2.0,<3", - "httpx", - "pandas>=2.0" - ] - ], - "title": "Dependencies" - }, - "requirements": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Path to requirements.txt file", - "examples": [ - "requirements.txt", - "../requirements/prod.txt" - ], - "title": "Requirements" - }, - "project": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Path to project directory containing pyproject.toml", - "examples": [ - ".", - "../my-project" - ], - "title": "Project" - }, - "editable": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Directory to install in editable mode", - "examples": [ - ".", - "../my-package" - ], - "title": "Editable" - } - }, - "title": "EnvironmentConfig", - "type": "object" - } - }, - "description": "Configuration file for FastMCP servers", - "properties": { - "$schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", - "description": "JSON schema for IDE support and validation", - "title": "$Schema" - }, - "entrypoint": { - "$ref": "#/$defs/EntrypointConfig", - "description": "Server entrypoint as a string (file or file:object) or object with file/object/repo", - "examples": [ - "server.py", - "server.py:app", - { - "file": "src/server.py", - "object": "app" - } - ] - }, - "environment": { - "$ref": "#/$defs/EnvironmentConfig", - "description": "Python environment setup configuration" - }, - "deployment": { - "$ref": "#/$defs/DeploymentConfig", - "description": "Server deployment and runtime settings" - } - }, - "required": [ - "entrypoint" - ], - "title": "FastMCP Configuration", - "type": "object", - "$id": "https://gofastmcp.com/schemas/fastmcp_config/v1.json" -} From 587e37f0599bc1bc24840ba189d706e8e2dd9e5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isra=C3=ABl=20Hall=C3=A9?= Date: Sat, 23 Aug 2025 15:33:17 -0400 Subject: [PATCH 2/3] Add setting to disable logging configuration (#1575) --- src/fastmcp/__init__.py | 10 +++++----- src/fastmcp/settings.py | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) 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/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. """ ), ), From afedabeaf000b304251d10946d5f3d90ea564742 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 23 Aug 2025 15:37:00 -0400 Subject: [PATCH 3/3] Fix and centralize CLI path resolution (#1590) --- .pre-commit-config.yaml | 3 +- src/fastmcp/cli/claude.py | 20 +-- src/fastmcp/cli/cli.py | 53 ++----- src/fastmcp/cli/install/claude_code.py | 34 ++--- src/fastmcp/cli/install/claude_desktop.py | 34 ++--- src/fastmcp/cli/install/cursor.py | 67 +++------ src/fastmcp/cli/install/mcp_json.py | 34 ++--- src/fastmcp/cli/run.py | 34 ++--- src/fastmcp/utilities/cli.py | 117 +++++++++++++++ tests/cli/test_cli.py | 146 +----------------- tests/cli/test_cursor.py | 7 +- tests/cli/test_run_with_uv.py | 48 +++--- tests/utilities/test_cli.py | 173 ++++++++++++++++++++++ 13 files changed, 384 insertions(+), 386 deletions(-) create mode 100644 tests/utilities/test_cli.py 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/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/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]