From 1f3f82d245f97632b9bd9ccb63746acad9c87f71 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 24 Aug 2025 16:32:37 -0400 Subject: [PATCH] Implement typed source system for FastMCP configuration (#1607) --- .gitignore | 6 + .../assets/schemas/fastmcp_config/latest.json | 127 ++-- docs/assets/schemas/fastmcp_config/v1.json | 127 ++-- docs/deployment/server-configuration.mdx | 638 +++++++++--------- docs/integrations/claude-code.mdx | 6 +- docs/integrations/claude-desktop.mdx | 6 +- docs/integrations/cursor.mdx | 6 +- docs/integrations/mcp-json-configuration.mdx | 6 +- docs/patterns/cli.mdx | 44 +- docs/public/schemas/fastmcp.json/latest.json | 188 ++++-- docs/public/schemas/fastmcp.json/v1.json | 188 ++++-- examples/atproto_mcp/fastmcp.json | 4 +- examples/fastmcp_config/fastmcp.json | 4 +- .../fastmcp_config/full_example.fastmcp.json | 6 +- examples/fastmcp_config_demo/fastmcp.json | 4 +- src/fastmcp/cli/claude.py | 19 +- src/fastmcp/cli/cli.py | 245 ++++--- src/fastmcp/cli/install/claude_code.py | 25 +- src/fastmcp/cli/install/claude_desktop.py | 24 +- src/fastmcp/cli/install/cursor.py | 47 +- src/fastmcp/cli/install/mcp_json.py | 24 +- src/fastmcp/cli/install/shared.py | 51 +- src/fastmcp/cli/run.py | 247 +++---- src/fastmcp/client/transports.py | 6 +- src/fastmcp/utilities/cli.py | 117 ---- .../utilities/fastmcp_config/__init__.py | 14 +- .../fastmcp_config/v1/fastmcp_config.py | 329 ++++----- .../utilities/fastmcp_config/v1/schema.json | 188 ++++-- test.fastmcp.json | 17 + tests/cli/test_config.py | 266 +++----- tests/cli/test_cursor.py | 5 +- tests/cli/test_fastmcp_config_integration.py | 127 +--- tests/cli/test_fastmcp_config_schema.py | 13 +- tests/cli/test_run_config.py | 133 ++-- tests/cli/test_run_with_uv.py | 42 +- tests/utilities/test_cli.py | 181 +++-- 36 files changed, 1767 insertions(+), 1713 deletions(-) create mode 100644 test.fastmcp.json diff --git a/.gitignore b/.gitignore index f119f200d..887877553 100644 --- a/.gitignore +++ b/.gitignore @@ -64,7 +64,13 @@ dmypy.json # Claude worktree management .claude-wt/worktrees +# Agents +/PLAN.md +/TODO.md +/STATUS.md + # Common FastMCP test files /test.py /server.py /client.py +/test.json diff --git a/docs/assets/schemas/fastmcp_config/latest.json b/docs/assets/schemas/fastmcp_config/latest.json index 2456b9058..81d0ad754 100644 --- a/docs/assets/schemas/fastmcp_config/latest.json +++ b/docs/assets/schemas/fastmcp_config/latest.json @@ -1,6 +1,6 @@ { "$defs": { - "DeploymentConfig": { + "Deployment": { "description": "Configuration for server deployment and runtime settings.", "properties": { "transport": { @@ -159,64 +159,10 @@ "title": "Args" } }, - "title": "DeploymentConfig", + "title": "Deployment", "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": { + "Environment": { "description": "Configuration for Python environment setup.", "properties": { "python": { @@ -312,7 +258,42 @@ "title": "Editable" } }, - "title": "EnvironmentConfig", + "title": "Environment", + "type": "object" + }, + "FileSystemSource": { + "description": "Source for local Python files.", + "properties": { + "type": { + "const": "filesystem", + "default": "filesystem", + "description": "Source type", + "title": "Type", + "type": "string" + }, + "path": { + "description": "Path to Python file containing the server", + "title": "Path", + "type": "string" + }, + "entrypoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of server instance or factory function (a no-arg function that returns a FastMCP server)", + "title": "Entrypoint" + } + }, + "required": [ + "path" + ], + "title": "FileSystemSource", "type": "object" } }, @@ -327,35 +308,41 @@ "type": "null" } ], - "default": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", + "default": "https://gofastmcp.com/public/schemas/fastmcp.json/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", + "source": { + "$ref": "#/$defs/FileSystemSource", + "description": "Source configuration for the server", "examples": [ - "server.py", - "server.py:app", { - "file": "src/server.py", - "object": "app" + "path": "server.py" + }, + { + "entrypoint": "app", + "path": "server.py" + }, + { + "entrypoint": "mcp", + "path": "src/server.py", + "type": "filesystem" } ] }, "environment": { - "$ref": "#/$defs/EnvironmentConfig", + "$ref": "#/$defs/Environment", "description": "Python environment setup configuration" }, "deployment": { - "$ref": "#/$defs/DeploymentConfig", + "$ref": "#/$defs/Deployment", "description": "Server deployment and runtime settings" } }, "required": [ - "entrypoint" + "source" ], "title": "FastMCP Configuration", "type": "object", - "$id": "https://gofastmcp.com/schemas/fastmcp_config/v1.json" + "$id": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json" } diff --git a/docs/assets/schemas/fastmcp_config/v1.json b/docs/assets/schemas/fastmcp_config/v1.json index 2456b9058..81d0ad754 100644 --- a/docs/assets/schemas/fastmcp_config/v1.json +++ b/docs/assets/schemas/fastmcp_config/v1.json @@ -1,6 +1,6 @@ { "$defs": { - "DeploymentConfig": { + "Deployment": { "description": "Configuration for server deployment and runtime settings.", "properties": { "transport": { @@ -159,64 +159,10 @@ "title": "Args" } }, - "title": "DeploymentConfig", + "title": "Deployment", "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": { + "Environment": { "description": "Configuration for Python environment setup.", "properties": { "python": { @@ -312,7 +258,42 @@ "title": "Editable" } }, - "title": "EnvironmentConfig", + "title": "Environment", + "type": "object" + }, + "FileSystemSource": { + "description": "Source for local Python files.", + "properties": { + "type": { + "const": "filesystem", + "default": "filesystem", + "description": "Source type", + "title": "Type", + "type": "string" + }, + "path": { + "description": "Path to Python file containing the server", + "title": "Path", + "type": "string" + }, + "entrypoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of server instance or factory function (a no-arg function that returns a FastMCP server)", + "title": "Entrypoint" + } + }, + "required": [ + "path" + ], + "title": "FileSystemSource", "type": "object" } }, @@ -327,35 +308,41 @@ "type": "null" } ], - "default": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", + "default": "https://gofastmcp.com/public/schemas/fastmcp.json/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", + "source": { + "$ref": "#/$defs/FileSystemSource", + "description": "Source configuration for the server", "examples": [ - "server.py", - "server.py:app", { - "file": "src/server.py", - "object": "app" + "path": "server.py" + }, + { + "entrypoint": "app", + "path": "server.py" + }, + { + "entrypoint": "mcp", + "path": "src/server.py", + "type": "filesystem" } ] }, "environment": { - "$ref": "#/$defs/EnvironmentConfig", + "$ref": "#/$defs/Environment", "description": "Python environment setup configuration" }, "deployment": { - "$ref": "#/$defs/DeploymentConfig", + "$ref": "#/$defs/Deployment", "description": "Server deployment and runtime settings" } }, "required": [ - "entrypoint" + "source" ], "title": "FastMCP Configuration", "type": "object", - "$id": "https://gofastmcp.com/schemas/fastmcp_config/v1.json" + "$id": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json" } diff --git a/docs/deployment/server-configuration.mdx b/docs/deployment/server-configuration.mdx index f132e9c6b..82aaa840e 100644 --- a/docs/deployment/server-configuration.mdx +++ b/docs/deployment/server-configuration.mdx @@ -1,16 +1,18 @@ --- -title: Server Configuration with fastmcp.json -sidebarTitle: Server Configuration -description: Use fastmcp.json for declarative server configuration +title: "Project Configuration" +sidebarTitle: "Project Configuration" +description: Use fastmcp.json for portable, declarative project configuration icon: file-code --- import { VersionBadge } from "/snippets/version-badge.mdx" - + FastMCP supports declarative configuration through `fastmcp.json` files. This is the canonical and preferred way to configure FastMCP projects, providing a single source of truth for server settings, dependencies, and deployment options that replaces complex command-line arguments. +The `fastmcp.json` file is designed to be a portable description of your server configuration that can be shared across environments and teams. When running from a `fastmcp.json` file, you can override any configuration values using CLI arguments. + ## Overview The `fastmcp.json` configuration file allows you to define all aspects of your FastMCP server in a structured, shareable format. Instead of remembering command-line arguments or writing shell scripts, you declare your server's configuration once and use it everywhere. @@ -27,16 +29,45 @@ fastmcp run This configuration approach ensures reproducible deployments across different environments, from local development to production servers. It works seamlessly with Claude Desktop, VS Code extensions, and any MCP-compatible client. -## JSON Schema Support +## File Structure + +The `fastmcp.json` configuration answers three fundamental questions about your server: + +- **Source** = WHERE does your server code live? +- **Environment** = WHAT environment setup does it require? +- **Deployment** = HOW should the server run? + +This conceptual model helps you understand the purpose of each configuration section and organize your settings effectively. The configuration file maps directly to these three concerns: + +```json +{ + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + // WHERE: Location of your server code + "path": "server.py", + "entrypoint": "mcp" + }, + "environment": { + // WHAT: Python environment and dependencies + }, + "deployment": { + // HOW: Runtime configuration + } +} +``` + +Only the `source` field is required. The `environment` and `deployment` sections are optional and provide additional configuration when needed. + +### JSON Schema Support FastMCP provides JSON schemas for IDE autocomplete and validation. Add the schema reference to your `fastmcp.json` for enhanced developer experience: ```json { "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" + "source": { + "path": "server.py", + "entrypoint": "mcp" } } ``` @@ -47,69 +78,61 @@ Two schema URLs are available: Modern IDEs like VS Code will automatically provide autocomplete suggestions, validation, and inline documentation when the schema is specified. -## File Structure +### Source Configuration -The `fastmcp.json` file has three main sections, each controlling a different aspect of your server: +The source configuration determines **WHERE** your server code lives. It tells FastMCP how to find and load your server, whether it's a local Python file, a remote repository, or hosted in the cloud. This section is required and forms the foundation of your configuration. -```json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" - }, - "environment": { - // Python environment and dependencies - }, - "deployment": { - // Runtime configuration - } -} -``` - -Only the `entrypoint` field is required. The `environment` and `deployment` sections are optional and provide additional configuration when needed. - -## Configuration Fields - -### Entrypoint - -The entrypoint specifies which Python file and object contains your FastMCP server. This field is required and supports multiple formats to accommodate different project structures. - - - - The server entry point. Can be specified in three formats: + + + The server source configuration that determines where your server code lives. - **Object format** (recommended): Explicit file and object specification - ```json - "entrypoint": { - "file": "src/server.py", - "object": "mcp" - } - ``` + + The source type identifier that determines which implementation to use. Currently supports `"filesystem"` for local files. Future releases will add support for `"git"` and `"cloud"` source types. + - **String with object**: File path with colon and object name - ```json - "entrypoint": "src/server.py:app" - ``` - - **String format**: Simple path to Python file (searches for common names: mcp, server, app) - ```json - "entrypoint": "server.py" - ``` - - - - File paths are resolved relative to the configuration file's location - - If your `fastmcp.json` is in a project root and references `src/server.py`, FastMCP will look for the server at `/src/server.py` - - When no object is specified, FastMCP automatically searches for common server names: `mcp`, `server`, or `app` + + When `type` is `"filesystem"` (or omitted), the source points to a local Python file containing your FastMCP server: + + + Path to the Python file containing your FastMCP server. + + + + Name of the server instance or factory function within the module: + - Can be a FastMCP server instance (e.g., `mcp = FastMCP("MyServer")`) + - Can be a function with no arguments that returns a FastMCP server + - If not specified, FastMCP searches for common names: `mcp`, `server`, or `app` + + + **Example:** + ```json + "source": { + "type": "filesystem", + "path": "src/server.py", + "entrypoint": "mcp" + } + ``` + + Note: File paths are resolved relative to the configuration file's location. -### Environment + +**Future Source Types** -The environment section configures Python dependencies and version requirements. When specified, FastMCP uses `uv` to create an isolated environment for your server, ensuring reproducible deployments across different systems. +Future releases will support additional source types: +- **Git repositories** (`type: "git"`) for loading server code directly from version control +- **FastMCP Cloud** (`type: "cloud"`) for hosted servers with automatic scaling and management + - +### Environment Configuration + +The environment configuration determines **WHAT** environment setup your server requires. It controls the build-time setup of your Python environment using `uv`'s powerful dependency management. This section ensures your server runs with the exact Python version and dependencies it requires, creating isolated, reproducible environments across different systems. + +These settings leverage standard `uv` arguments for environment creation. When any environment field is specified, FastMCP automatically creates an isolated environment before running your server. This build-time configuration happens once when the server starts, not during runtime execution. + + Optional Python environment configuration. When any field is specified, FastMCP automatically creates an isolated environment using `uv`. @@ -143,9 +166,9 @@ The environment section configures Python dependencies and version requirements. - Path to a package to install in editable/development mode. + Path to a package to install in editable/development mode. Useful for local development when you want changes to be reflected immediately. ```json - "editable": "./my-package" + "editable": "." ``` @@ -157,11 +180,15 @@ When environment configuration is provided, FastMCP: 2. Installs the specified dependencies 3. Runs your server in this clean environment -### Deployment +This build-time setup ensures your server always has the dependencies it needs, without polluting your system Python or conflicting with other projects. -The deployment section controls runtime configuration including transport protocol, networking, logging, and environment variables. +### Deployment Configuration - +The deployment configuration controls **HOW** your server runs. It defines the runtime behavior including network settings, environment variables, and execution context. These settings determine how your server operates when it executes, from transport protocols to logging levels. + +Environment variables are included in this section because they're runtime configuration that affects how your server behaves when it executes, not how its environment is built. The deployment configuration is applied every time your server starts, controlling its operational characteristics. + + Optional runtime configuration for the server. @@ -220,243 +247,7 @@ The deployment section controls runtime configuration including transport protoc -## Usage with CLI Commands - -FastMCP automatically detects and uses `fastmcp.json` files, making server execution simple and consistent: - -```bash -# Auto-detect fastmcp.json in current directory -cd my-project -fastmcp run # No arguments needed! - -# Or specify a configuration file explicitly -fastmcp run prod.fastmcp.json -``` - -The configuration file works with all FastMCP commands: -- **`run`** - Start the server in production mode -- **`dev`** - Launch with the Inspector UI for development -- **`inspect`** - View server capabilities and configuration -- **`install`** - Install to Claude Desktop, Cursor, or other MCP clients - -When no file argument is provided, FastMCP searches the current directory for `fastmcp.json`. This means you can simply navigate to your project directory and run `fastmcp run` to start your server with all its configured settings. - -### Custom Naming Patterns - -You can use different configuration files for different environments: - -- `fastmcp.json` - Default configuration -- `dev.fastmcp.json` - Development settings -- `prod.fastmcp.json` - Production settings -- `test_fastmcp.json` - Test configuration - -Any file with "fastmcp.json" in the name is recognized as a configuration file. - -## Examples - - - - -A minimal configuration for a simple server: - -```json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" - } -} -``` -This configuration explicitly specifies the server object name (`app`), making it clear which object contains your FastMCP server. Uses all defaults: STDIO transport, no special dependencies, standard logging. - - - -A configuration optimized for local development: - -```json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "entrypoint": "src/server.py:app", - "environment": { - "python": "3.12", - "dependencies": ["fastmcp[dev]"], - "editable": "." - }, - "deployment": { - "transport": "http", - "host": "127.0.0.1", - "port": 8000, - "log_level": "DEBUG", - "env": { - "DEBUG": "true", - "ENV": "development" - } - } -} -``` - - - -A production-ready configuration with full dependency management: - -```json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "entrypoint": { - "file": "app/main.py", - "object": "mcp_server" - }, - "environment": { - "python": "3.11", - "requirements": "requirements/production.txt", - "project": "." - }, - "deployment": { - "transport": "http", - "host": "0.0.0.0", - "port": 3000, - "path": "/api/mcp/", - "log_level": "INFO", - "env": { - "ENV": "production", - "API_BASE_URL": "https://api.example.com", - "DATABASE_URL": "postgresql://user:pass@db.example.com/prod" - }, - "cwd": "/app", - "args": ["--workers", "4"] - } -} -``` - - - -Configuration for a data analysis server with scientific packages: - -```json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "entrypoint": { - "file": "analysis_server.py", - "object": "mcp" - }, - "environment": { - "python": "3.11", - "dependencies": [ - "pandas>=2.0", - "numpy", - "scikit-learn", - "matplotlib", - "jupyterlab" - ] - }, - "deployment": { - "transport": "stdio", - "env": { - "MATPLOTLIB_BACKEND": "Agg", - "DATA_PATH": "./datasets" - } - } -} -``` - - - -You can maintain multiple configuration files for different environments: - -**dev.fastmcp.json**: -```json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" - }, - "deployment": { - "transport": "http", - "log_level": "DEBUG" - } -} -``` - -**prod.fastmcp.json**: -```json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" - }, - "environment": { - "requirements": "requirements/production.txt" - }, - "deployment": { - "transport": "http", - "host": "0.0.0.0", - "log_level": "WARNING" - } -} -``` - -Run different configurations: -```bash -fastmcp run dev.fastmcp.json # Development -fastmcp run prod.fastmcp.json # Production -``` - - -## CLI Override Behavior - -Command-line arguments take precedence over configuration file values, allowing ad-hoc adjustments without modifying the file: - -```bash -# Config specifies port 3000, CLI overrides to 8080 -fastmcp run fastmcp.json --port 8080 - -# Config specifies stdio, CLI overrides to HTTP -fastmcp run fastmcp.json --transport http - -# Add extra dependencies not in config -fastmcp run fastmcp.json --with requests --with httpx -``` - -This precedence order enables: -- Quick testing of different settings -- Environment-specific overrides in deployment scripts -- Debugging with increased log levels -- Temporary configuration changes - -## Best Practices - -When using `fastmcp.json` for your projects, consider these recommendations: - -**Version Control**: Always commit your `fastmcp.json` to version control. It's essential project documentation that ensures others can run your server correctly. - -**Environment Variables**: Use the `env` field for configuration values instead of hardcoding them in your Python code. For sensitive values, consider using environment variable references or separate secret management. - -**Dependency Management**: Specify exact versions for production dependencies to ensure reproducible builds: -```json -{ - "dependencies": [ - "pandas==2.1.0", - "requests==2.31.0" - ] -} -``` - -**Path Resolution**: Remember that paths in the configuration are relative to the config file location. Use relative paths for portability: -```json -{ - "entrypoint": "./src/server.py", - "environment": { - "requirements": "./requirements.txt" - } -} -``` - -**Development Workflow**: Use separate configuration files for different environments rather than constantly modifying a single file. The CLI's override behavior makes it easy to switch between configurations. - -### Environment Variable Interpolation +#### Environment Variable Interpolation The `env` field in deployment configuration supports runtime interpolation of environment variables using `${VAR_NAME}` syntax. This enables dynamic configuration based on your deployment environment: @@ -499,6 +290,241 @@ This feature is particularly useful for: - Building dynamic URLs and connection strings - Creating environment-specific prefixes or suffixes +## Usage with CLI Commands + +FastMCP automatically detects and uses a file specifically named `fastmcp.json` in the current directory, making server execution simple and consistent. Files with FastMCP configuration format but different names are not auto-detected and must be specified explicitly: + +```bash +# Auto-detect fastmcp.json in current directory +cd my-project +fastmcp run # No arguments needed! + +# Or specify a configuration file explicitly +fastmcp run prod.fastmcp.json + +# Skip environment setup when already in a uv environment +fastmcp run fastmcp.json --skip-env +``` + +### Environment Setup Control + +By default, FastMCP uses `uv` to create an isolated environment based on your configuration. The `--skip-env` flag allows you to skip this automatic environment setup: + +```bash +fastmcp run fastmcp.json --skip-env +``` + +**When to use `--skip-env`:** +- You're already in an activated virtual environment with all dependencies installed +- You're inside a Docker container with pre-installed dependencies +- You're in a uv-managed environment and want to prevent infinite recursion +- You want to test the server without environment setup for debugging purposes + +This flag is particularly useful in CI/CD pipelines, Docker containers, or when you're managing the Python environment yourself. + +The configuration file works with all FastMCP commands: +- **`run`** - Start the server in production mode +- **`dev`** - Launch with the Inspector UI for development +- **`inspect`** - View server capabilities and configuration +- **`install`** - Install to Claude Desktop, Cursor, or other MCP clients + +When no file argument is provided, FastMCP searches the current directory for `fastmcp.json`. This means you can simply navigate to your project directory and run `fastmcp run` to start your server with all its configured settings. + +### CLI Override Behavior + +Command-line arguments take precedence over configuration file values, allowing ad-hoc adjustments without modifying the file: + +```bash +# Config specifies port 3000, CLI overrides to 8080 +fastmcp run fastmcp.json --port 8080 + +# Config specifies stdio, CLI overrides to HTTP +fastmcp run fastmcp.json --transport http + +# Add extra dependencies not in config +fastmcp run fastmcp.json --with requests --with httpx +``` + +This precedence order enables: +- Quick testing of different settings +- Environment-specific overrides in deployment scripts +- Debugging with increased log levels +- Temporary configuration changes + +### Custom Naming Patterns + +You can use different configuration files for different environments: + +- `fastmcp.json` - Default configuration +- `dev.fastmcp.json` - Development settings +- `prod.fastmcp.json` - Production settings +- `test_fastmcp.json` - Test configuration + +Any file with "fastmcp.json" in the name is recognized as a configuration file. + +## Examples + + + + +A minimal configuration for a simple server: + +```json +{ + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "path": "server.py", + "entrypoint": "mcp" + } +} +``` +This configuration explicitly specifies the server entrypoint (`mcp`), making it clear which server instance or factory function to use. Uses all defaults: STDIO transport, no special dependencies, standard logging. + + + +A configuration optimized for local development: + +```json +{ + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + // WHERE does the server live? + "source": { + "path": "src/server.py", + "entrypoint": "app" + }, + // WHAT dependencies does it need? + "environment": { + "python": "3.12", + "dependencies": ["fastmcp[dev]"], + "editable": "." + }, + // HOW should it run? + "deployment": { + "transport": "http", + "host": "127.0.0.1", + "port": 8000, + "log_level": "DEBUG", + "env": { + "DEBUG": "true", + "ENV": "development" + } + } +} +``` + + + +A production-ready configuration with full dependency management: + +```json +{ + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + // WHERE does the server live? + "source": { + "path": "app/main.py", + "entrypoint": "mcp_server" + }, + // WHAT dependencies does it need? + "environment": { + "python": "3.11", + "requirements": "requirements/production.txt", + "project": "." + }, + // HOW should it run? + "deployment": { + "transport": "http", + "host": "0.0.0.0", + "port": 3000, + "path": "/api/mcp/", + "log_level": "INFO", + "env": { + "ENV": "production", + "API_BASE_URL": "https://api.example.com", + "DATABASE_URL": "postgresql://user:pass@db.example.com/prod" + }, + "cwd": "/app", + "args": ["--workers", "4"] + } +} +``` + + + +Configuration for a data analysis server with scientific packages: + +```json +{ + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "path": "analysis_server.py", + "entrypoint": "mcp" + }, + "environment": { + "python": "3.11", + "dependencies": [ + "pandas>=2.0", + "numpy", + "scikit-learn", + "matplotlib", + "jupyterlab" + ] + }, + "deployment": { + "transport": "stdio", + "env": { + "MATPLOTLIB_BACKEND": "Agg", + "DATA_PATH": "./datasets" + } + } +} +``` + + + +You can maintain multiple configuration files for different environments: + +**dev.fastmcp.json**: +```json +{ + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "path": "server.py", + "entrypoint": "mcp" + }, + "deployment": { + "transport": "http", + "log_level": "DEBUG" + } +} +``` + +**prod.fastmcp.json**: +```json +{ + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "path": "server.py", + "entrypoint": "mcp" + }, + "environment": { + "requirements": "requirements/production.txt" + }, + "deployment": { + "transport": "http", + "host": "0.0.0.0", + "log_level": "WARNING" + } +} +``` + +Run different configurations: +```bash +fastmcp run dev.fastmcp.json # Development +fastmcp run prod.fastmcp.json # Production +``` + + + ## Migrating from CLI Arguments If you're currently using command-line arguments or shell scripts, migrating to `fastmcp.json` simplifies your workflow. Here's how common CLI patterns map to configuration: @@ -516,9 +542,9 @@ uv run --with pandas --with requests \ ```json { "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" + "source": { + "path": "server.py", + "entrypoint": "mcp" }, "environment": { "dependencies": ["pandas", "requests"] diff --git a/docs/integrations/claude-code.mdx b/docs/integrations/claude-code.mdx index 2a66c8f22..2b04c10a8 100644 --- a/docs/integrations/claude-code.mdx +++ b/docs/integrations/claude-code.mdx @@ -86,9 +86,9 @@ Alternatively, you can use a `fastmcp.json` configuration file (recommended): ```json fastmcp.json { "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" + "source": { + "path": "server.py", + "entrypoint": "mcp" }, "environment": { "dependencies": ["pandas", "requests"] diff --git a/docs/integrations/claude-desktop.mdx b/docs/integrations/claude-desktop.mdx index baa7bc47e..3158a005a 100644 --- a/docs/integrations/claude-desktop.mdx +++ b/docs/integrations/claude-desktop.mdx @@ -103,9 +103,9 @@ Alternatively, you can use a `fastmcp.json` configuration file (recommended): ```json fastmcp.json { "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" + "source": { + "path": "server.py", + "entrypoint": "mcp" }, "environment": { "dependencies": ["pandas", "requests"] diff --git a/docs/integrations/cursor.mdx b/docs/integrations/cursor.mdx index 329047011..344ddc52f 100644 --- a/docs/integrations/cursor.mdx +++ b/docs/integrations/cursor.mdx @@ -104,9 +104,9 @@ Alternatively, you can use a `fastmcp.json` configuration file (recommended): ```json fastmcp.json { "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" + "source": { + "path": "server.py", + "entrypoint": "mcp" }, "environment": { "dependencies": ["pandas", "requests"] diff --git a/docs/integrations/mcp-json-configuration.mdx b/docs/integrations/mcp-json-configuration.mdx index 394687d0a..99e44fb85 100644 --- a/docs/integrations/mcp-json-configuration.mdx +++ b/docs/integrations/mcp-json-configuration.mdx @@ -179,9 +179,9 @@ You can also use a `fastmcp.json` configuration file (recommended): ```json fastmcp.json { "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" + "source": { + "path": "server.py", + "entrypoint": "mcp" }, "environment": { "dependencies": ["pandas", "matplotlib", "seaborn"] diff --git a/docs/patterns/cli.mdx b/docs/patterns/cli.mdx index 56cc91908..654f54766 100644 --- a/docs/patterns/cli.mdx +++ b/docs/patterns/cli.mdx @@ -46,6 +46,7 @@ By default, this command runs the server directly in your current Python environ | Path | `--path` | Path to bind to when using http transport (default: `/mcp/` or `/sse/` for SSE) | | Log Level | `--log-level`, `-l` | Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) | | No Banner | `--no-banner` | Disable the startup banner display | +| No Environment | `--skip-env` | Skip environment setup with uv (use when already in a uv environment) | | Python Version | `--python` | Python version to use (e.g., 3.10, 3.11) | | Additional Packages | `--with` | Additional packages to install (can be used multiple times) | | Project Directory | `--project` | Run the command within the given project directory | @@ -57,8 +58,8 @@ By default, this command runs the server directly in your current Python environ The `fastmcp run` command supports the following entrypoints: -1. **[Inferred server instance](#inferred-server-instance)**: `server.py` - imports the module and looks for a FastMCP object named `mcp`, `server`, or `app`. Errors if no such object is found. -2. **[Explicit server object](#explicit-server-object)**: `server.py:custom_name` - imports and uses the specified server object +1. **[Inferred server instance](#inferred-server-instance)**: `server.py` - imports the module and looks for a FastMCP server instance named `mcp`, `server`, or `app`. Errors if no such object is found. +2. **[Explicit server entrypoint](#explicit-server-entrypoint)**: `server.py:custom_name` - imports and uses the specified server entrypoint 3. **[Factory function](#factory-function)**: `server.py:create_server` - calls the specified function (sync or async) to create a server instance 4. **[Remote server proxy](#remote-server-proxy)**: `https://example.com/mcp-server` - connects to a remote server and creates a **local proxy server** 5. **[FastMCP configuration file](#fastmcp-configuration)**: `fastmcp.json` - runs servers using FastMCP's declarative configuration format (auto-detects files in current directory) @@ -68,7 +69,7 @@ The `fastmcp run` command supports the following entrypoints: Note: When using `fastmcp run` with a local file, it **completely ignores** the `if __name__ == "__main__"` block. This means: - Any setup code in `__main__` will NOT run - Server configuration in `__main__` is bypassed -- `fastmcp run` finds your server object/factory and runs it with its own transport settings +- `fastmcp run` finds your server entrypoint/factory and runs it with its own transport settings If you need setup code to run, use the **factory pattern** instead. @@ -91,9 +92,9 @@ You can run it with: fastmcp run server.py ``` -#### Explicit Server Object +#### Explicit Server Entrypoint -If your server is stored as a variable with a custom name, or you want to be explicit about which server to run, you can use the following syntax to load a specific server object: +If your server is stored as a variable with a custom name, or you want to be explicit about which server to run, you can use the following syntax to load a specific server entrypoint: ```bash fastmcp run server.py:custom_name @@ -122,7 +123,7 @@ fastmcp run server.py:custom_name Since `fastmcp run` ignores the `if __name__ == "__main__"` block, you can use a factory function to run setup code before your server starts. Factory functions are called without any arguments and must return a FastMCP server instance. Both sync and async factory functions are supported. -The syntax for using a factory function is the same as for an explicit server object: `fastmcp run server.py:factory_fn`. FastMCP will automatically detect that you have identified a function rather than a server Instance +The syntax for using a factory function is the same as for an explicit server entrypoint: `fastmcp run server.py:factory_fn`. FastMCP will automatically detect that you have identified a function rather than a server Instance For example, if you have a file called `server.py` with the following content: @@ -177,8 +178,19 @@ The configuration file handles dependencies, environment variables, and transpor ```bash # Override port from config file fastmcp run fastmcp.json --port 8080 + +# Skip environment setup when already in a uv environment +fastmcp run fastmcp.json --skip-env ``` + +The `--skip-env` flag is useful when: +- You're already in an activated virtual environment +- You're inside a Docker container with pre-installed dependencies +- You're in a uv-managed environment (prevents infinite recursion) +- You want to test the server without environment setup + + See [Server Configuration](/deployment/server-configuration) for detailed documentation on fastmcp.json. #### MCP Configuration @@ -244,8 +256,8 @@ This command does not support HTTP testing. To test a server over Streamable HTT The `dev` command supports local FastMCP server files and configuration: -1. **Inferred server instance**: `server.py` - imports the module and looks for a FastMCP object named `mcp`, `server`, or `app`. Errors if no such object is found. -2. **Explicit server object**: `server.py:custom_name` - imports and uses the specified server object +1. **Inferred server instance**: `server.py` - imports the module and looks for a FastMCP server instance named `mcp`, `server`, or `app`. Errors if no such object is found. +2. **Explicit server entrypoint**: `server.py:custom_name` - imports and uses the specified server entrypoint 3. **Factory function**: `server.py:create_server` - calls the specified function (sync or async) to create a server instance 4. **FastMCP configuration**: `fastmcp.json` - uses FastMCP's declarative configuration (auto-detects in current directory) @@ -323,8 +335,8 @@ Note that for security reasons, MCP clients usually run every server in a comple The `install` command supports local FastMCP server files and configuration: -1. **Inferred server instance**: `server.py` - imports the module and looks for a FastMCP object named `mcp`, `server`, or `app`. Errors if no such object is found. -2. **Explicit server object**: `server.py:custom_name` - imports and uses the specified server object +1. **Inferred server instance**: `server.py` - imports the module and looks for a FastMCP server instance named `mcp`, `server`, or `app`. Errors if no such object is found. +2. **Explicit server entrypoint**: `server.py:custom_name` - imports and uses the specified server entrypoint 3. **Factory function**: `server.py:create_server` - calls the specified function (sync or async) to create a server instance 4. **FastMCP configuration**: `fastmcp.json` - uses FastMCP's declarative configuration with dependencies and settings @@ -339,7 +351,7 @@ The `install` command **only supports local files and fastmcp.json** - no URLs, **Examples** ```bash -# Auto-detects server object (looks for 'mcp', 'server', or 'app') +# Auto-detects server entrypoint (looks for 'mcp', 'server', or 'app') fastmcp install claude-desktop server.py # Install with fastmcp.json configuration (auto-detects) @@ -348,7 +360,7 @@ fastmcp install claude-desktop # Install with explicit fastmcp.json file fastmcp install claude-desktop my-config.fastmcp.json -# Uses specific server object +# Uses specific server entrypoint fastmcp install claude-desktop server.py:my_server # With custom name and dependencies @@ -439,8 +451,8 @@ fastmcp inspect server.py The `inspect` command supports local FastMCP server files and configuration: -1. **Inferred server instance**: `server.py` - imports the module and looks for a FastMCP object named `mcp`, `server`, or `app`. Errors if no such object is found. -2. **Explicit server object**: `server.py:custom_name` - imports and uses the specified server object +1. **Inferred server instance**: `server.py` - imports the module and looks for a FastMCP server instance named `mcp`, `server`, or `app`. Errors if no such object is found. +2. **Explicit server entrypoint**: `server.py:custom_name` - imports and uses the specified server entrypoint 3. **Factory function**: `server.py:create_server` - calls the specified function (sync or async) to create a server instance 4. **FastMCP configuration**: `fastmcp.json` - inspects servers defined with FastMCP's declarative configuration @@ -451,10 +463,10 @@ The `inspect` command **only supports local files and fastmcp.json** - no URLs, **Examples** ```bash -# Auto-detect server object +# Auto-detect server entrypoint fastmcp inspect server.py -# Specify server object +# Specify server entrypoint fastmcp inspect server.py:my_server # Custom output location diff --git a/docs/public/schemas/fastmcp.json/latest.json b/docs/public/schemas/fastmcp.json/latest.json index db5b8650e..81d0ad754 100644 --- a/docs/public/schemas/fastmcp.json/latest.json +++ b/docs/public/schemas/fastmcp.json/latest.json @@ -1,12 +1,16 @@ { "$defs": { - "DeploymentConfig": { + "Deployment": { "description": "Configuration for server deployment and runtime settings.", "properties": { "transport": { "anyOf": [ { - "enum": ["stdio", "http", "sse"], + "enum": [ + "stdio", + "http", + "sse" + ], "type": "string" }, { @@ -28,7 +32,11 @@ ], "default": null, "description": "Host to bind to when using HTTP transport", - "examples": ["127.0.0.1", "0.0.0.0", "localhost"], + "examples": [ + "127.0.0.1", + "0.0.0.0", + "localhost" + ], "title": "Host" }, "port": { @@ -42,7 +50,11 @@ ], "default": null, "description": "Port to bind to when using HTTP transport", - "examples": [8000, 3000, 5000], + "examples": [ + 8000, + 3000, + 5000 + ], "title": "Port" }, "path": { @@ -56,13 +68,23 @@ ], "default": null, "description": "URL path for the server endpoint", - "examples": ["/mcp/", "/api/mcp/", "/sse/"], + "examples": [ + "/mcp/", + "/api/mcp/", + "/sse/" + ], "title": "Path" }, "log_level": { "anyOf": [ { - "enum": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + "enum": [ + "DEBUG", + "INFO", + "WARNING", + "ERROR", + "CRITICAL" + ], "type": "string" }, { @@ -84,7 +106,11 @@ ], "default": null, "description": "Working directory for the server process", - "examples": [".", "./src", "/app"], + "examples": [ + ".", + "./src", + "/app" + ], "title": "Cwd" }, "env": { @@ -123,56 +149,20 @@ ], "default": null, "description": "Arguments to pass to the server (after --)", - "examples": [["--config", "config.json", "--debug"]], + "examples": [ + [ + "--config", + "config.json", + "--debug" + ] + ], "title": "Args" } }, - "title": "DeploymentConfig", + "title": "Deployment", "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": { + "Environment": { "description": "Configuration for Python environment setup.", "properties": { "python": { @@ -186,7 +176,11 @@ ], "default": null, "description": "Python version constraint", - "examples": ["3.10", "3.11", "3.12"], + "examples": [ + "3.10", + "3.11", + "3.12" + ], "title": "Python" }, "dependencies": { @@ -203,7 +197,13 @@ ], "default": null, "description": "Python packages to install with PEP 508 specifiers", - "examples": [["fastmcp>=2.0,<3", "httpx", "pandas>=2.0"]], + "examples": [ + [ + "fastmcp>=2.0,<3", + "httpx", + "pandas>=2.0" + ] + ], "title": "Dependencies" }, "requirements": { @@ -217,7 +217,10 @@ ], "default": null, "description": "Path to requirements.txt file", - "examples": ["requirements.txt", "../requirements/prod.txt"], + "examples": [ + "requirements.txt", + "../requirements/prod.txt" + ], "title": "Requirements" }, "project": { @@ -231,7 +234,10 @@ ], "default": null, "description": "Path to project directory containing pyproject.toml", - "examples": [".", "../my-project"], + "examples": [ + ".", + "../my-project" + ], "title": "Project" }, "editable": { @@ -245,11 +251,49 @@ ], "default": null, "description": "Directory to install in editable mode", - "examples": [".", "../my-package"], + "examples": [ + ".", + "../my-package" + ], "title": "Editable" } }, - "title": "EnvironmentConfig", + "title": "Environment", + "type": "object" + }, + "FileSystemSource": { + "description": "Source for local Python files.", + "properties": { + "type": { + "const": "filesystem", + "default": "filesystem", + "description": "Source type", + "title": "Type", + "type": "string" + }, + "path": { + "description": "Path to Python file containing the server", + "title": "Path", + "type": "string" + }, + "entrypoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of server instance or factory function (a no-arg function that returns a FastMCP server)", + "title": "Entrypoint" + } + }, + "required": [ + "path" + ], + "title": "FileSystemSource", "type": "object" } }, @@ -268,28 +312,36 @@ "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", + "source": { + "$ref": "#/$defs/FileSystemSource", + "description": "Source configuration for the server", "examples": [ - "server.py", - "server.py:app", { - "file": "src/server.py", - "object": "app" + "path": "server.py" + }, + { + "entrypoint": "app", + "path": "server.py" + }, + { + "entrypoint": "mcp", + "path": "src/server.py", + "type": "filesystem" } ] }, "environment": { - "$ref": "#/$defs/EnvironmentConfig", + "$ref": "#/$defs/Environment", "description": "Python environment setup configuration" }, "deployment": { - "$ref": "#/$defs/DeploymentConfig", + "$ref": "#/$defs/Deployment", "description": "Server deployment and runtime settings" } }, - "required": ["entrypoint"], + "required": [ + "source" + ], "title": "FastMCP Configuration", "type": "object", "$id": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json" diff --git a/docs/public/schemas/fastmcp.json/v1.json b/docs/public/schemas/fastmcp.json/v1.json index db5b8650e..81d0ad754 100644 --- a/docs/public/schemas/fastmcp.json/v1.json +++ b/docs/public/schemas/fastmcp.json/v1.json @@ -1,12 +1,16 @@ { "$defs": { - "DeploymentConfig": { + "Deployment": { "description": "Configuration for server deployment and runtime settings.", "properties": { "transport": { "anyOf": [ { - "enum": ["stdio", "http", "sse"], + "enum": [ + "stdio", + "http", + "sse" + ], "type": "string" }, { @@ -28,7 +32,11 @@ ], "default": null, "description": "Host to bind to when using HTTP transport", - "examples": ["127.0.0.1", "0.0.0.0", "localhost"], + "examples": [ + "127.0.0.1", + "0.0.0.0", + "localhost" + ], "title": "Host" }, "port": { @@ -42,7 +50,11 @@ ], "default": null, "description": "Port to bind to when using HTTP transport", - "examples": [8000, 3000, 5000], + "examples": [ + 8000, + 3000, + 5000 + ], "title": "Port" }, "path": { @@ -56,13 +68,23 @@ ], "default": null, "description": "URL path for the server endpoint", - "examples": ["/mcp/", "/api/mcp/", "/sse/"], + "examples": [ + "/mcp/", + "/api/mcp/", + "/sse/" + ], "title": "Path" }, "log_level": { "anyOf": [ { - "enum": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + "enum": [ + "DEBUG", + "INFO", + "WARNING", + "ERROR", + "CRITICAL" + ], "type": "string" }, { @@ -84,7 +106,11 @@ ], "default": null, "description": "Working directory for the server process", - "examples": [".", "./src", "/app"], + "examples": [ + ".", + "./src", + "/app" + ], "title": "Cwd" }, "env": { @@ -123,56 +149,20 @@ ], "default": null, "description": "Arguments to pass to the server (after --)", - "examples": [["--config", "config.json", "--debug"]], + "examples": [ + [ + "--config", + "config.json", + "--debug" + ] + ], "title": "Args" } }, - "title": "DeploymentConfig", + "title": "Deployment", "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": { + "Environment": { "description": "Configuration for Python environment setup.", "properties": { "python": { @@ -186,7 +176,11 @@ ], "default": null, "description": "Python version constraint", - "examples": ["3.10", "3.11", "3.12"], + "examples": [ + "3.10", + "3.11", + "3.12" + ], "title": "Python" }, "dependencies": { @@ -203,7 +197,13 @@ ], "default": null, "description": "Python packages to install with PEP 508 specifiers", - "examples": [["fastmcp>=2.0,<3", "httpx", "pandas>=2.0"]], + "examples": [ + [ + "fastmcp>=2.0,<3", + "httpx", + "pandas>=2.0" + ] + ], "title": "Dependencies" }, "requirements": { @@ -217,7 +217,10 @@ ], "default": null, "description": "Path to requirements.txt file", - "examples": ["requirements.txt", "../requirements/prod.txt"], + "examples": [ + "requirements.txt", + "../requirements/prod.txt" + ], "title": "Requirements" }, "project": { @@ -231,7 +234,10 @@ ], "default": null, "description": "Path to project directory containing pyproject.toml", - "examples": [".", "../my-project"], + "examples": [ + ".", + "../my-project" + ], "title": "Project" }, "editable": { @@ -245,11 +251,49 @@ ], "default": null, "description": "Directory to install in editable mode", - "examples": [".", "../my-package"], + "examples": [ + ".", + "../my-package" + ], "title": "Editable" } }, - "title": "EnvironmentConfig", + "title": "Environment", + "type": "object" + }, + "FileSystemSource": { + "description": "Source for local Python files.", + "properties": { + "type": { + "const": "filesystem", + "default": "filesystem", + "description": "Source type", + "title": "Type", + "type": "string" + }, + "path": { + "description": "Path to Python file containing the server", + "title": "Path", + "type": "string" + }, + "entrypoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of server instance or factory function (a no-arg function that returns a FastMCP server)", + "title": "Entrypoint" + } + }, + "required": [ + "path" + ], + "title": "FileSystemSource", "type": "object" } }, @@ -268,28 +312,36 @@ "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", + "source": { + "$ref": "#/$defs/FileSystemSource", + "description": "Source configuration for the server", "examples": [ - "server.py", - "server.py:app", { - "file": "src/server.py", - "object": "app" + "path": "server.py" + }, + { + "entrypoint": "app", + "path": "server.py" + }, + { + "entrypoint": "mcp", + "path": "src/server.py", + "type": "filesystem" } ] }, "environment": { - "$ref": "#/$defs/EnvironmentConfig", + "$ref": "#/$defs/Environment", "description": "Python environment setup configuration" }, "deployment": { - "$ref": "#/$defs/DeploymentConfig", + "$ref": "#/$defs/Deployment", "description": "Server deployment and runtime settings" } }, - "required": ["entrypoint"], + "required": [ + "source" + ], "title": "FastMCP Configuration", "type": "object", "$id": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json" diff --git a/examples/atproto_mcp/fastmcp.json b/examples/atproto_mcp/fastmcp.json index 0cab8397d..c75f8e091 100644 --- a/examples/atproto_mcp/fastmcp.json +++ b/examples/atproto_mcp/fastmcp.json @@ -1,6 +1,8 @@ { "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "entrypoint": "src/atproto_mcp/server.py", + "source": { + "path": "src/atproto_mcp/server.py" + }, "environment": { "dependencies": [ "atproto_mcp@git+https://github.com/jlowin/fastmcp.git#subdirectory=examples/atproto_mcp" diff --git a/examples/fastmcp_config/fastmcp.json b/examples/fastmcp_config/fastmcp.json index 5630c0079..3511d1153 100644 --- a/examples/fastmcp_config/fastmcp.json +++ b/examples/fastmcp_config/fastmcp.json @@ -1,6 +1,8 @@ { "$schema": "https://gofastmcp.com/schemas/fastmcp/v1.json", - "entrypoint": "server.py", + "source": { + "path": "server.py" + }, "environment": { "python": "3.12", "dependencies": ["requests"] diff --git a/examples/fastmcp_config/full_example.fastmcp.json b/examples/fastmcp_config/full_example.fastmcp.json index 98126fcf5..e31a4f8cb 100644 --- a/examples/fastmcp_config/full_example.fastmcp.json +++ b/examples/fastmcp_config/full_example.fastmcp.json @@ -1,8 +1,8 @@ { "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "entrypoint": { - "file": "server.py", - "object": "mcp" + "source": { + "path": "server.py", + "entrypoint": "mcp" }, "environment": { "python": "3.12", diff --git a/examples/fastmcp_config_demo/fastmcp.json b/examples/fastmcp_config_demo/fastmcp.json index 79b51973e..9027c6c81 100644 --- a/examples/fastmcp_config_demo/fastmcp.json +++ b/examples/fastmcp_config_demo/fastmcp.json @@ -1,6 +1,8 @@ { "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "entrypoint": "server.py", + "source": { + "path": "server.py" + }, "environment": { "python": "3.11", "dependencies": ["pyautogui", "Pillow"] diff --git a/src/fastmcp/cli/claude.py b/src/fastmcp/cli/claude.py index b19c0a7bd..424469d77 100644 --- a/src/fastmcp/cli/claude.py +++ b/src/fastmcp/cli/claude.py @@ -6,7 +6,7 @@ import sys from pathlib import Path from typing import Any -from fastmcp.utilities.cli import build_uv_run_args +from fastmcp.utilities.fastmcp_config import Environment from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) @@ -90,11 +90,20 @@ def update_claude_config( else: env_vars = existing_env - # Build uv run command using centralized function - args = build_uv_run_args( - with_editable=with_editable, - with_packages=with_packages, + # Deduplicate packages and exclude 'fastmcp' since Environment adds it automatically + deduplicated_packages = None + if with_packages: + deduplicated = list(dict.fromkeys(with_packages)) + deduplicated_packages = [pkg for pkg in deduplicated if pkg != "fastmcp"] + if not deduplicated_packages: + deduplicated_packages = None + + # Build uv run command using Environment.build_uv_args() + env_config = Environment( + dependencies=deduplicated_packages, + editable=str(with_editable) if with_editable else None, ) + args = env_config.build_uv_args() # 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 7028dc326..05c111618 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -2,6 +2,7 @@ import importlib.metadata import importlib.util +import json import os import platform import subprocess @@ -11,7 +12,7 @@ from typing import Annotated, Literal import cyclopts import pyperclip -from pydantic import TypeAdapter +from pydantic import TypeAdapter, ValidationError from rich.console import Console from rich.table import Table @@ -19,9 +20,9 @@ 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 +from fastmcp.utilities.types import get_cached_typeadapter logger = get_logger("cli") console = Console() @@ -58,10 +59,6 @@ def _parse_env_var(env_var: str) -> tuple[str, str]: return key.strip(), value.strip() -# The _build_uv_command function has been moved to cli/utils.py -# and is now imported as build_uv_command - - @app.command def version( *, @@ -198,18 +195,27 @@ async def dev( # Merge environment settings with CLI args (CLI takes precedence) if config.environment: - merged_env = config.environment.merge_with_cli_args( - python=python, - with_packages=with_packages, - with_requirements=with_requirements, - project=project, - with_editable=with_editable, + python = python or config.environment.python + project = project or ( + Path(config.environment.project) if config.environment.project else None ) - python = merged_env["python"] - with_packages = merged_env["with_packages"] - with_requirements = merged_env["with_requirements"] - project = merged_env["project"] - with_editable = merged_env["with_editable"] + with_requirements = with_requirements or ( + Path(config.environment.requirements) + if config.environment.requirements + else None + ) + with_editable = with_editable or ( + Path(config.environment.editable) + if config.environment.editable + else None + ) + + # Merge packages from both sources + if config.environment.dependencies: + packages = list(config.environment.dependencies) + if with_packages: + packages.extend(with_packages) + with_packages = packages # Get server port from deployment config if not specified if config.deployment and config.deployment.port: @@ -265,14 +271,17 @@ async def dev( if inspector_version: inspector_cmd += f"@{inspector_version}" - uv_cmd = build_uv_command( - server_spec, - with_editable=with_editable, - with_packages=with_packages, - python_version=python, - with_requirements=with_requirements, - project=project, + # Create Environment object from CLI args + from fastmcp.utilities.fastmcp_config import Environment + + env_config = Environment( + python=python, + dependencies=with_packages if with_packages else None, + requirements=str(with_requirements) if with_requirements else None, + project=str(project) if project else None, + editable=str(with_editable) if with_editable else None, ) + uv_cmd = ["uv"] + env_config.build_uv_args(["fastmcp", "run", server_spec]) # Add --no-banner flag for dev command uv_cmd.append("--no-banner") @@ -382,6 +391,14 @@ async def run( help="Requirements file to install dependencies from", ), ] = None, + skip_env: Annotated[ + bool, + cyclopts.Parameter( + "--skip-env", + help="Skip environment setup with uv (use when already in a uv environment)", + negative="", + ), + ] = False, ) -> None: """Run an MCP server or connect to a remote one. @@ -406,6 +423,7 @@ async def run( config = None config_path = None + editable = None # Initialize editable variable # Auto-detect fastmcp.json if no server_spec provided if server_spec is None: @@ -425,41 +443,69 @@ async def run( server_spec = str(config_path) logger.info(f"Using configuration from {config_path}") - # Load config if server_spec is a fastmcp.json file - if server_spec.endswith("fastmcp.json"): + # Load config if server_spec is a .json file + if server_spec.endswith(".json"): config_path = Path(server_spec) if config_path.exists(): - config = FastMCPConfig.from_file(config_path) + # Try to load as JSON and discriminate between FastMCPConfig and MCPConfig + try: + with open(config_path) as f: + data = json.load(f) - # Merge deployment config with CLI values (CLI takes precedence) - if config.deployment: - merged_deploy = config.deployment.merge_with_cli_args( - transport=transport, - host=host, - port=port, - path=path, - log_level=log_level, - server_args=list(server_args) if server_args else None, - ) - transport = merged_deploy["transport"] - host = merged_deploy["host"] - port = merged_deploy["port"] - path = merged_deploy["path"] - log_level = merged_deploy["log_level"] - server_args = merged_deploy["server_args"] or () + # Check if it's an MCPConfig first (has canonical mcpServers key) + if "mcpServers" in data: + # It's an MCPConfig, we don't process these in the run command + # They should be handled through different code paths + config = None + else: + # Try to parse as FastMCPConfig + try: + adapter = get_cached_typeadapter(FastMCPConfig) + config = adapter.validate_python(data) - # Merge environment config with CLI values (CLI takes precedence) - if config.environment: - merged_env = config.environment.merge_with_cli_args( - python=python, - with_packages=with_packages, - with_requirements=with_requirements, - project=project, - ) - python = merged_env["python"] - with_packages = merged_env["with_packages"] - with_requirements = merged_env["with_requirements"] - project = merged_env["project"] + # Merge deployment config with CLI values (CLI takes precedence) + if config.deployment: + transport = transport or config.deployment.transport + host = host or config.deployment.host + port = port or config.deployment.port + path = path or config.deployment.path + log_level = log_level or config.deployment.log_level + server_args = ( + tuple(server_args) + if server_args + else tuple(config.deployment.args or ()) + ) + + # Merge environment config with CLI values (CLI takes precedence) + if config.environment: + python = python or config.environment.python + project = project or ( + Path(config.environment.project) + if config.environment.project + else None + ) + with_requirements = with_requirements or ( + Path(config.environment.requirements) + if config.environment.requirements + else None + ) + # Extract editable from config (no CLI override for this) + editable = config.environment.editable + + # Merge packages from both sources + if config.environment.dependencies: + packages = list(config.environment.dependencies) + if with_packages: + packages.extend(with_packages) + with_packages = packages + except ValidationError: + # Not a valid FastMCPConfig, treat as regular server spec + config = None + except (json.JSONDecodeError, FileNotFoundError): + # Not a valid JSON file, treat as regular server spec + config = None + else: + config = None logger.debug( "Running server or client", extra={ @@ -474,8 +520,11 @@ async def run( ) # Check if we need to use uv run (either from CLI args or config) - needs_uv = python or with_packages or with_requirements or project - if not needs_uv and config and config.environment: + # Skip if --skip-env flag is set (we're already in a uv environment) + needs_uv = not skip_env and ( + python or with_packages or with_requirements or project or editable + ) + if not skip_env and not needs_uv and config and config.environment: # Check if config's environment needs uv needs_uv = config.environment.needs_uv() @@ -494,6 +543,7 @@ async def run( path=path, log_level=log_level, show_banner=not no_banner, + editable=editable, ) except Exception as e: logger.error( @@ -612,31 +662,64 @@ async def inspect( server_spec = str(config_path) logger.info(f"Using configuration from {config_path}") - # Load config if server_spec is a fastmcp.json file - if server_spec.endswith("fastmcp.json"): + # Load config if server_spec is a .json file + if server_spec.endswith(".json"): config_path = Path(server_spec) if config_path.exists(): - config = FastMCPConfig.from_file(config_path) - # Get the actual entrypoint with resolved paths - entrypoint = config.get_entrypoint(config_path) + # Try to load as JSON and discriminate between FastMCPConfig and MCPConfig + try: + with open(config_path) as f: + data = json.load(f) - if entrypoint.object: - server_spec = f"{entrypoint.file}:{entrypoint.object}" - else: - server_spec = entrypoint.file + # Check which type of config it is based on required fields + try: + if "source" in data: + # It's a FastMCPConfig - validate and use it + adapter = get_cached_typeadapter(FastMCPConfig) + config = adapter.validate_python(data) + # Get the actual entrypoint with resolved paths + entrypoint = config.get_entrypoint(config_path) - # Merge environment settings from config with CLI (CLI takes precedence) - if config.environment: - merged_env = config.environment.merge_with_cli_args( - python=python, - with_packages=with_packages, - with_requirements=with_requirements, - project=project, - ) - python = merged_env["python"] - with_packages = merged_env["with_packages"] - with_requirements = merged_env["with_requirements"] - project = merged_env["project"] + if entrypoint.object: + server_spec = f"{entrypoint.file}:{entrypoint.object}" + else: + server_spec = entrypoint.file + + # Merge environment settings from config with CLI (CLI takes precedence) + if config.environment: + python = python or config.environment.python + project = project or ( + Path(config.environment.project) + if config.environment.project + else None + ) + with_requirements = with_requirements or ( + Path(config.environment.requirements) + if config.environment.requirements + else None + ) + + # Merge packages from both sources + if config.environment.dependencies: + packages = list(config.environment.dependencies) + if with_packages: + packages.extend(with_packages) + with_packages = packages + elif "mcpServers" in data: + # It's an MCPConfig, we don't process these in the run command + # They should be handled through different code paths + config = None + else: + # Not a recognized config format, treat as regular server spec + config = None + except ValidationError: + # Not a valid config, treat as regular server spec + config = None + except (json.JSONDecodeError, FileNotFoundError): + # Not a valid JSON file, treat as regular server spec + config = None + else: + config = None # Check if we need to use uv run needs_uv = python or with_packages or with_requirements or project @@ -656,12 +739,12 @@ async def inspect( ] config.environment.run_with_uv(inspect_command) else: - # Build an EnvironmentConfig from CLI args for consistency + # Build an Environment from CLI args for consistency from fastmcp.utilities.fastmcp_config import ( - EnvironmentConfig, + Environment, ) - env_config = EnvironmentConfig( + env_config = Environment( python=python, dependencies=with_packages, requirements=str(with_requirements) if with_requirements else None, diff --git a/src/fastmcp/cli/install/claude_code.py b/src/fastmcp/cli/install/claude_code.py index a4cf72d39..b51b6a8a0 100644 --- a/src/fastmcp/cli/install/claude_code.py +++ b/src/fastmcp/cli/install/claude_code.py @@ -9,7 +9,7 @@ from typing import Annotated import cyclopts from rich import print -from fastmcp.utilities.cli import build_uv_run_args +from fastmcp.utilities.fastmcp_config import Environment from fastmcp.utilities.logging import get_logger from .shared import process_common_args @@ -107,14 +107,23 @@ def install_claude_code( ) return False - # 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, + # Deduplicate packages and exclude 'fastmcp' since Environment adds it automatically + deduplicated_packages = None + if with_packages: + deduplicated = list(dict.fromkeys(with_packages)) + deduplicated_packages = [pkg for pkg in deduplicated if pkg != "fastmcp"] + if not deduplicated_packages: + deduplicated_packages = None + + # Build uv run command using Environment.build_uv_args() + env_config = Environment( + python=python_version, + dependencies=deduplicated_packages, + requirements=str(with_requirements) if with_requirements else None, + project=str(project) if project else None, + editable=str(with_editable) if with_editable else None, ) + args = env_config.build_uv_args() # 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 60d17a833..542698992 100644 --- a/src/fastmcp/cli/install/claude_desktop.py +++ b/src/fastmcp/cli/install/claude_desktop.py @@ -9,7 +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.fastmcp_config import Environment from fastmcp.utilities.logging import get_logger from .shared import process_common_args @@ -73,14 +73,22 @@ def install_claude_desktop( config_file = config_dir / "claude_desktop_config.json" - # 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, + # Deduplicate packages and exclude 'fastmcp' since Environment adds it automatically + deduplicated_packages = None + if with_packages: + deduplicated = list(dict.fromkeys(with_packages)) + deduplicated_packages = [pkg for pkg in deduplicated if pkg != "fastmcp"] + if not deduplicated_packages: + deduplicated_packages = None + + env_config = Environment( + python=python_version, + dependencies=deduplicated_packages, + requirements=str(with_requirements) if with_requirements else None, + project=str(project) if project else None, + editable=str(with_editable) if with_editable else None, ) + args = env_config.build_uv_args() # 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 47a854ecd..92d10d495 100644 --- a/src/fastmcp/cli/install/cursor.py +++ b/src/fastmcp/cli/install/cursor.py @@ -10,7 +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.fastmcp_config import Environment from fastmcp.utilities.logging import get_logger from .shared import process_common_args @@ -107,14 +107,22 @@ def install_cursor_workspace( config_file = cursor_dir / "mcp.json" - # 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, + # Deduplicate packages and exclude 'fastmcp' since Environment adds it automatically + deduplicated_packages = None + if with_packages: + deduplicated = list(dict.fromkeys(with_packages)) + deduplicated_packages = [pkg for pkg in deduplicated if pkg != "fastmcp"] + if not deduplicated_packages: + deduplicated_packages = None + + env_config = Environment( + python=python_version, + dependencies=deduplicated_packages, + requirements=str(with_requirements.resolve()) if with_requirements else None, + project=str(project.resolve()) if project else None, + editable=str(with_editable.resolve()) if with_editable else None, ) + args = env_config.build_uv_args() # Build server spec from parsed components if server_object: @@ -178,14 +186,23 @@ def install_cursor( Returns: True if installation was successful, False otherwise """ - # 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, + + # Deduplicate packages and exclude 'fastmcp' since Environment adds it automatically + deduplicated_packages = None + if with_packages: + deduplicated = list(dict.fromkeys(with_packages)) + deduplicated_packages = [pkg for pkg in deduplicated if pkg != "fastmcp"] + if not deduplicated_packages: + deduplicated_packages = None + + env_config = Environment( + python=python_version, + dependencies=deduplicated_packages, + requirements=str(with_requirements.resolve()) if with_requirements else None, + project=str(project.resolve()) if project else None, + editable=str(with_editable.resolve()) if with_editable else None, ) + args = env_config.build_uv_args() # 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 2a8ed1dc0..be9443fa1 100644 --- a/src/fastmcp/cli/install/mcp_json.py +++ b/src/fastmcp/cli/install/mcp_json.py @@ -9,7 +9,7 @@ import cyclopts import pyperclip from rich import print -from fastmcp.utilities.cli import build_uv_run_args +from fastmcp.utilities.fastmcp_config import Environment from fastmcp.utilities.logging import get_logger from .shared import process_common_args @@ -48,14 +48,22 @@ def install_mcp_json( True if generation was successful, False otherwise """ try: - # 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, + # Deduplicate packages and exclude 'fastmcp' since Environment adds it automatically + deduplicated_packages = None + if with_packages: + deduplicated = list(dict.fromkeys(with_packages)) + deduplicated_packages = [pkg for pkg in deduplicated if pkg != "fastmcp"] + if not deduplicated_packages: + deduplicated_packages = None + + env_config = Environment( + python=python_version, + dependencies=deduplicated_packages, + requirements=str(with_requirements) if with_requirements else None, + project=str(project) if project else None, + editable=str(with_editable) if with_editable else None, ) + args = env_config.build_uv_args() # Build server spec from parsed components if server_object: diff --git a/src/fastmcp/cli/install/shared.py b/src/fastmcp/cli/install/shared.py index 9745801a9..82843c8d9 100644 --- a/src/fastmcp/cli/install/shared.py +++ b/src/fastmcp/cli/install/shared.py @@ -1,13 +1,16 @@ """Shared utilities for install commands.""" +import json import sys from pathlib import Path from dotenv import dotenv_values +from pydantic import ValidationError from rich import print from fastmcp.cli.run import import_server, parse_file_path from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.types import get_cached_typeadapter logger = get_logger(__name__) @@ -34,28 +37,46 @@ async def process_common_args( Handles both fastmcp.json config files and traditional file.py:object syntax. """ - # Check if server_spec is a fastmcp.json file - if server_spec.endswith("fastmcp.json") or "fastmcp.json" in Path(server_spec).name: - from fastmcp.utilities.fastmcp_config import FastMCPConfig - + # Check if server_spec is a .json file + if server_spec.endswith(".json"): config_path = Path(server_spec).resolve() if not config_path.exists(): print(f"[red]Configuration file not found: {config_path}[/red]") sys.exit(1) - # Load config and get entrypoint - config = FastMCPConfig.from_file(config_path) - entrypoint = config.get_entrypoint(config_path) + # Try to load as JSON and discriminate between FastMCPConfig and MCPConfig + try: + with open(config_path) as f: + data = json.load(f) - # Convert to file and server_object - file = Path(entrypoint.file) - server_object = entrypoint.object + # Check if it's an MCPConfig first (has canonical mcpServers key) + from fastmcp.utilities.fastmcp_config import FastMCPConfig - # Merge packages from config if not overridden - if config.environment and config.environment.dependencies: - # Merge with CLI packages (CLI takes precedence) - config_packages = config.environment.dependencies or [] - with_packages = list(set(with_packages + config_packages)) + if "mcpServers" in data: + # It's an MCPConfig, treat as regular server spec + file, server_object = parse_file_path(server_spec) + else: + # Try to parse as FastMCPConfig + try: + adapter = get_cached_typeadapter(FastMCPConfig) + config = adapter.validate_python(data) + entrypoint = config.get_entrypoint(config_path) + + # Convert to file and server_object + file = Path(entrypoint.file) + server_object = entrypoint.object + + # Merge packages from config if not overridden + if config.environment and config.environment.dependencies: + # Merge with CLI packages (CLI takes precedence) + config_packages = config.environment.dependencies or [] + with_packages = list(set(with_packages + config_packages)) + except ValidationError: + # Not a valid FastMCPConfig, treat as regular server spec + file, server_object = parse_file_path(server_spec) + except (json.JSONDecodeError, FileNotFoundError): + # Not a valid JSON file, treat as regular server spec + file, server_object = parse_file_path(server_spec) else: # Parse traditional server spec file, server_object = parse_file_path(server_spec) diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py index 0f39a4819..eaf89ea11 100644 --- a/src/fastmcp/cli/run.py +++ b/src/fastmcp/cli/run.py @@ -11,16 +11,16 @@ from pathlib import Path from typing import Any, Literal from mcp.server.fastmcp import FastMCP as FastMCP1x +from pydantic import ValidationError from fastmcp.server.server import FastMCP -from fastmcp.utilities.cli import build_uv_command from fastmcp.utilities.fastmcp_config import ( - DeploymentConfig, - EntrypointConfig, - EnvironmentConfig, + Environment, FastMCPConfig, + FileSystemSource, ) from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.types import get_cached_typeadapter logger = get_logger("cli.run") @@ -102,7 +102,7 @@ async def import_server(file: Path, server_or_factory: str | None = None) -> Any logger.error( f"No server object found in {file}. Please either:\n" "1. Use a standard variable name (mcp, server, or app)\n" - "2. Specify the object name in fastmcp.json or use `file.py:object` syntax as your path.", + "2. Specify the entrypoint name in fastmcp.json or use `file.py:object` syntax as your path.", extra={"file": str(file)}, ) sys.exit(1) @@ -191,6 +191,7 @@ def run_with_uv( path: str | None = None, log_level: LogLevelType | None = None, show_banner: bool = True, + editable: str | None = None, ) -> None: """Run a MCP server using uv run subprocess. @@ -207,62 +208,82 @@ def run_with_uv( log_level: Log level show_banner: Whether to show the server banner """ - # Check if server_spec is a fastmcp.json file - if server_spec.endswith("fastmcp.json") or "fastmcp.json" in Path(server_spec).name: + # Check if server_spec is a .json file + if server_spec.endswith(".json"): config_path = Path(server_spec).resolve() # Get absolute path if config_path.exists(): - # Load config - config = FastMCPConfig.from_file(config_path) + # Try to load as JSON and discriminate between FastMCPConfig and MCPConfig + try: + with open(config_path) as f: + data = json.load(f) - # Get entrypoint with resolved paths - entrypoint = config.get_entrypoint(config_path) - if entrypoint.object: - server_spec = f"{entrypoint.file}:{entrypoint.object}" - else: - server_spec = entrypoint.file + # Check if it's an MCPConfig first (has canonical mcpServers key) + if "mcpServers" in data: + # It's an MCPConfig, we don't process it here - just pass through + pass + else: + # Try to parse as FastMCPConfig + try: + adapter = get_cached_typeadapter(FastMCPConfig) + config: FastMCPConfig = adapter.validate_python(data) - # Merge environment config with CLI args - # Check if environment has any non-None values - if config.environment and any( - getattr(config.environment, field, None) is not None - for field in EnvironmentConfig.model_fields - ): - merged_env = config.environment.merge_with_cli_args( - python=python_version, - with_packages=with_packages, - with_requirements=with_requirements, - project=project, - ) - python_version = merged_env["python"] - with_packages = merged_env["with_packages"] - with_requirements = merged_env["with_requirements"] - project = merged_env["project"] + # Apply deployment settings + if config.deployment: + config.deployment.apply_runtime_settings(config_path) - # Merge deployment config with CLI args - # Check if deployment has any non-None values - if config.deployment and any( - getattr(config.deployment, field, None) is not None - for field in DeploymentConfig.model_fields - ): - merged_deploy = config.deployment.merge_with_cli_args( - transport=transport, - host=host, - port=port, - path=path, - log_level=log_level, - ) - transport = merged_deploy["transport"] - host = merged_deploy["host"] - port = merged_deploy["port"] - path = merged_deploy["path"] - log_level = merged_deploy["log_level"] - # 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, + # Merge environment config with CLI args (CLI takes precedence) + if config.environment: + # Use CLI values if provided, otherwise fall back to config + python_version = python_version or config.environment.python + project = project or ( + Path(config.environment.project) + if config.environment.project + else None + ) + with_requirements = with_requirements or ( + Path(config.environment.requirements) + if config.environment.requirements + else None + ) + editable = editable or config.environment.editable + + # Merge packages from both sources + # Only merge if with_packages doesn't already contain them + # (they may have been merged already in CLI) + if config.environment.dependencies and not with_packages: + with_packages = list(config.environment.dependencies) + + # Merge deployment config with CLI args (CLI takes precedence) + if config.deployment: + transport = transport or config.deployment.transport + host = host or config.deployment.host + port = port or config.deployment.port + path = path or config.deployment.path + log_level = log_level or config.deployment.log_level + except ValidationError: + # Not a valid FastMCPConfig, just pass through + pass + except (json.JSONDecodeError, FileNotFoundError): + # Not a valid JSON file, just pass through + pass + + # Build uv command using Environment.build_uv_args() + env_config = Environment( + python=python_version, + dependencies=with_packages if with_packages else None, + requirements=str(with_requirements.resolve()) if with_requirements else None, + project=str(project.resolve()) if project else None, + editable=editable, + ) + # IMPORTANT: We add --skip-env to prevent infinite recursion. + # When this function executes `uv run ... fastmcp run server.py`, the inner + # `fastmcp run` command will be executed inside the uv environment we're creating. + # Without --skip-env, that inner command would detect it needs uv (due to the same + # CLI args) and try to spawn ANOTHER uv subprocess, creating infinite recursion. + # The --skip-env flag tells the inner fastmcp: "skip environment setup, we're already + # inside the uv environment that was just created for us." + cmd = ["uv"] + env_config.build_uv_args( + ["fastmcp", "run", server_spec, "--skip-env"] ) # Add transport options @@ -320,16 +341,14 @@ def create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None]: return server -def load_fastmcp_config( - config_path: Path, -) -> tuple[EntrypointConfig, DeploymentConfig | None, EnvironmentConfig | None]: +def load_fastmcp_config(config_path: Path) -> FastMCPConfig: """Load a FastMCP configuration from a fastmcp.json file. Args: config_path: Path to fastmcp.json file Returns: - Tuple of (entrypoint, deployment config, environment config) + FastMCPConfig object """ config = FastMCPConfig.from_file(config_path) @@ -337,29 +356,7 @@ def load_fastmcp_config( if config.deployment: config.deployment.apply_runtime_settings(config_path) - # Get entrypoint as structured object with resolved paths - entrypoint = config.get_entrypoint(config_path) - - # Return None for empty configs (backward compatibility) - deployment = ( - config.deployment - if any( - getattr(config.deployment, field, None) is not None - for field in DeploymentConfig.model_fields - ) - else None - ) - - environment = ( - config.environment - if any( - getattr(config.environment, field, None) is not None - for field in EnvironmentConfig.model_fields - ) - else None - ) - - return entrypoint, deployment, environment + return config async def import_server_with_args( @@ -416,44 +413,56 @@ async def run_command( # Handle URL case server = create_client_server(server_spec) logger.debug(f"Created client proxy server for {server_spec}") - elif ( - server_spec.endswith("fastmcp.json") or "fastmcp.json" in Path(server_spec).name - ): - # Handle fastmcp.json configuration file (matches test_fastmcp.json, my.fastmcp.json, etc) - config_path = Path(server_spec) - entrypoint, deployment, environment = load_fastmcp_config(config_path) - - # Merge deployment config with CLI arguments (CLI takes precedence) - if deployment: - merged = deployment.merge_with_cli_args( - transport=transport, - host=host, - port=port, - path=path, - log_level=log_level, - server_args=server_args, - ) - transport = merged["transport"] - host = merged["host"] - port = merged["port"] - path = merged["path"] - log_level = merged["log_level"] - server_args = merged["server_args"] - - # Import the server from the structured entrypoint - file_path = Path(entrypoint.file) - server = await import_server_with_args( - file_path, entrypoint.object, server_args - ) - logger.debug(f'Found server "{server.name}" from config {config_path}') elif server_spec.endswith(".json"): - # Handle other JSON files as MCPConfig - server = create_mcp_config_server(Path(server_spec)) + # Load JSON and check which type of config it is + config_path = Path(server_spec) + with open(config_path) as f: + data = json.load(f) + + # Check if it's an MCPConfig first (has canonical mcpServers key) + if "mcpServers" in data: + # It's an MCP config + server = create_mcp_config_server(config_path) + else: + # Try to parse as FastMCPConfig + adapter = get_cached_typeadapter(FastMCPConfig) + adapter.validate_python(data) # Validate but don't need to store + # It's a FastMCP config - load it properly with runtime settings + config = load_fastmcp_config(config_path) + + # Merge deployment config with CLI arguments (CLI takes precedence) + if config.deployment: + transport = transport or config.deployment.transport + host = host or config.deployment.host + port = port or config.deployment.port + path = path or config.deployment.path + log_level = log_level or config.deployment.log_level + server_args = ( + server_args if server_args is not None else config.deployment.args + ) + + # Load the server using the source + server = await config.source.load_server(config_path, server_args) + logger.debug(f'Found server "{server.name}" from config {config_path}') else: - # Handle file case - file, server_or_factory = parse_file_path(server_spec) - server = await import_server_with_args(file, server_or_factory, server_args) - logger.debug(f'Found server "{server.name}" in {file}') + # Handle file case - parse into FileSystemSource immediately + if ":" in server_spec: + # Check if it's a Windows path (e.g., C:\...) + has_windows_drive = len(server_spec) > 1 and server_spec[1] == ":" + + # Only split if colon is not part of Windows drive + if ":" in (server_spec[2:] if has_windows_drive else server_spec): + file_str, obj = server_spec.rsplit(":", 1) + source = FileSystemSource(path=file_str, object=obj) + else: + source = FileSystemSource(path=server_spec) + else: + source = FileSystemSource(path=server_spec) + + # Create a temporary config with just the source + config = FastMCPConfig(source=source) + server = await config.source.load_server(None, server_args) + logger.debug(f'Found server "{server.name}" in {source.path}') # Run the server diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index f01932bd6..f25892fc8 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -36,7 +36,7 @@ from fastmcp.client.auth.oauth import OAuth from fastmcp.mcp_config import MCPConfig, infer_transport_type_from_url from fastmcp.server.dependencies import get_http_headers from fastmcp.server.server import FastMCP -from fastmcp.utilities.fastmcp_config.v1.fastmcp_config import EnvironmentConfig +from fastmcp.utilities.fastmcp_config.v1.fastmcp_config import Environment from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) @@ -596,8 +596,8 @@ class UvStdioTransport(StdioTransport): f"Project directory not found: {project_directory}" ) - # Create EnvironmentConfig from provided parameters (internal use) - env_config = EnvironmentConfig( + # Create Environment from provided parameters (internal use) + env_config = Environment( python=python_version, dependencies=with_packages, requirements=with_requirements, diff --git a/src/fastmcp/utilities/cli.py b/src/fastmcp/utilities/cli.py index e0cabddb4..1c208da4d 100644 --- a/src/fastmcp/utilities/cli.py +++ b/src/fastmcp/utilities/cli.py @@ -1,7 +1,6 @@ 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 @@ -110,119 +109,3 @@ 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/src/fastmcp/utilities/fastmcp_config/__init__.py b/src/fastmcp/utilities/fastmcp_config/__init__.py index db2682c95..f9d6ee445 100644 --- a/src/fastmcp/utilities/fastmcp_config/__init__.py +++ b/src/fastmcp/utilities/fastmcp_config/__init__.py @@ -5,17 +5,19 @@ The current version is v1, which is re-exported here for convenience. """ from fastmcp.utilities.fastmcp_config.v1.fastmcp_config import ( - DeploymentConfig, - EntrypointConfig, - EnvironmentConfig, + BaseSource, + Deployment, + Environment, FastMCPConfig, + FileSystemSource, generate_schema, ) __all__ = [ + "BaseSource", + "Deployment", + "Environment", "FastMCPConfig", - "EntrypointConfig", - "EnvironmentConfig", - "DeploymentConfig", + "FileSystemSource", "generate_schema", ] diff --git a/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py b/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py index 458895a6b..6a2793b54 100644 --- a/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py +++ b/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py @@ -10,6 +10,7 @@ from __future__ import annotations import json import os import re +from abc import ABC, abstractmethod from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, overload @@ -23,28 +24,61 @@ logger = get_logger("cli.config") FASTMCP_JSON_SCHEMA = "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json" -class EntrypointConfig(BaseModel): - """Configuration for server entrypoint when using object format.""" +class BaseSource(BaseModel, ABC): + """Abstract base class for all source types.""" - file: str = Field( - description="Path to Python file containing the server", - examples=["server.py", "src/server.py", "app/main.py"], - ) + type: str = Field(description="Source type identifier") - object: str | None = Field( + async def prepare(self, config_path: Path | None = None) -> Path | None: + """Prepare the source (download, clone, install, etc). + + Returns: + Path to prepared source directory, or None if no preparation needed. + This path may contain a nested fastmcp.json for configuration chaining. + """ + # Default implementation for sources that don't need preparation + return None + + @abstractmethod + async def load_server( + self, config_path: Path | None = None, server_args: list[str] | None = None + ) -> Any: + """Load and return the FastMCP server instance. + + Must be called after prepare() if the source requires preparation. + """ + ... + + +class FileSystemSource(BaseSource): + """Source for local Python files.""" + + type: Literal["filesystem"] = Field(default="filesystem", description="Source type") + path: str = Field(description="Path to Python file containing the server") + entrypoint: str | None = Field( default=None, - description="Name of the server object in the file (defaults to searching for mcp/server/app)", - examples=["app", "mcp", "server"], + description="Name of server instance or factory function (a no-arg function that returns a FastMCP server)", ) - repo: str | None = Field( - default=None, - description="Git repository URL", - examples=["https://github.com/user/repo"], - ) + async def load_server( + self, config_path: Path | None = None, server_args: list[str] | None = None + ) -> Any: + """Load server from filesystem.""" + from fastmcp.cli.run import import_server_with_args + + # Resolve relative paths if config_path provided + file_path = Path(self.path) + if not file_path.is_absolute() and config_path: + file_path = (config_path.parent / file_path).resolve() + + return await import_server_with_args(file_path, self.entrypoint, server_args) -class EnvironmentConfig(BaseModel): +# Type alias for source union (will expand with GitSource, etc in future) +SourceType = FileSystemSource + + +class Environment(BaseModel): """Configuration for Python environment setup.""" python: str | None = Field( @@ -99,10 +133,11 @@ class EnvironmentConfig(BaseModel): # Add fastmcp as a base dependency args.extend(["--with", "fastmcp"]) - # Add additional dependencies + # Add additional dependencies (skip fastmcp if already added) if self.dependencies: for dep in self.dependencies: - args.extend(["--with", dep]) + if dep != "fastmcp": # Skip fastmcp since we already added it + args.extend(["--with", dep]) # Add requirements file if self.requirements: @@ -150,51 +185,18 @@ class EnvironmentConfig(BaseModel): Returns: True if any environment settings require uv run """ - return bool( - self.python - or self.dependencies - or self.requirements - or self.project - or self.editable + return any( + [ + self.python is not None, + self.dependencies is not None, + self.requirements is not None, + self.project is not None, + self.editable is not None, + ] ) - def merge_with_cli_args( - self, - python: str | None = None, - with_packages: list[str] | None = None, - with_requirements: Path | None = None, - project: Path | None = None, - with_editable: Path | None = None, - ) -> dict[str, Any]: - """Merge environment config with CLI arguments, with CLI taking precedence. - For packages, combines both config and CLI packages. - For other fields, CLI takes precedence if provided. - - Returns: - Dictionary with merged arguments suitable for CLI commands - """ - from pathlib import Path - - # Merge packages from both sources - packages = [] - if self.dependencies: - packages.extend(self.dependencies) - if with_packages: - packages.extend(with_packages) - - return { - "python": python or self.python, - "with_packages": packages, - "with_requirements": with_requirements - or (Path(self.requirements) if self.requirements else None), - "project": project or (Path(self.project) if self.project else None), - "with_editable": with_editable - or (Path(self.editable) if self.editable else None), - } - - -class DeploymentConfig(BaseModel): +class Deployment(BaseModel): """Configuration for server deployment and runtime settings.""" transport: Literal["stdio", "http", "sse"] | None = Field( @@ -243,29 +245,6 @@ class DeploymentConfig(BaseModel): examples=[["--config", "config.json", "--debug"]], ) - def merge_with_cli_args( - self, - transport: str | None = None, - host: str | None = None, - port: int | None = None, - path: str | None = None, - log_level: str | None = None, - server_args: list[str] | None = None, - ) -> dict[str, Any]: - """Merge deployment config with CLI arguments, with CLI taking precedence. - - Returns: - Dictionary with merged arguments suitable for CLI commands - """ - return { - "transport": transport or self.transport, - "host": host or self.host, - "port": port or self.port, - "path": path or self.path, - "log_level": log_level or self.log_level, - "server_args": server_args if server_args is not None else self.args, - } - def apply_runtime_settings(self, config_path: Path | None = None) -> None: """Apply runtime settings like environment variables and working directory. @@ -329,144 +308,94 @@ class FastMCPConfig(BaseModel): description="JSON schema for IDE support and validation", ) - # Server entrypoint - supports both string and object format - entrypoint: EntrypointConfig = Field( - description="Server entrypoint as a string (file or file:object) or object with file/object/repo", + # Server source - defines where and how to load the server + source: SourceType = Field( + description="Source configuration for the server", examples=[ - "server.py", - "server.py:app", - {"file": "src/server.py", "object": "app"}, + {"path": "server.py"}, + {"path": "server.py", "entrypoint": "app"}, + {"type": "filesystem", "path": "src/server.py", "entrypoint": "mcp"}, ], ) # Environment configuration - environment: EnvironmentConfig = Field( - default_factory=lambda: EnvironmentConfig(), + environment: Environment = Field( + default_factory=lambda: Environment(), description="Python environment setup configuration", ) # Deployment configuration - deployment: DeploymentConfig = Field( - default_factory=lambda: DeploymentConfig(), + deployment: Deployment = Field( + default_factory=lambda: Deployment(), description="Server deployment and runtime settings", ) - # purely for static type checkers to avoid issues with providng str entrypoint + # purely for static type checkers to avoid issues with providing dict source if TYPE_CHECKING: @overload - def __init__( - self, *, entrypoint: str | dict | EntrypointConfig, **data - ) -> None: ... + def __init__(self, *, source: dict | FileSystemSource, **data) -> None: ... @overload - def __init__( - self, *, environment: dict | EnvironmentConfig, **data - ) -> None: ... + def __init__(self, *, environment: dict | Environment, **data) -> None: ... @overload - def __init__(self, *, deployment: dict | DeploymentConfig, **data) -> None: ... + def __init__(self, *, deployment: dict | Deployment, **data) -> None: ... def __init__(self, **data) -> None: ... - @field_validator("entrypoint", mode="before") + @field_validator("source", mode="before") @classmethod - def validate_entrypoint(cls, v: str | EntrypointConfig) -> EntrypointConfig: - """Validate and convert entrypoint to proper format. + def validate_source(cls, v: dict | FileSystemSource) -> FileSystemSource: + """Validate and convert source to proper format. Supports: - - String format: "server.py" or "server.py:object" - - Object format: {"file": "server.py", "object": "app"} - - EntrypointConfig instance (passed through) + - Dict format: {"path": "server.py", "entrypoint": "app"} + - FileSystemSource instance (passed through) - The string format with :object syntax is automatically parsed into - the object format for consistency. + No string parsing happens here - that's only at CLI boundaries. + FastMCPConfig works only with properly typed objects. """ - if isinstance(v, EntrypointConfig): - # Already an EntrypointConfig instance, return as-is + if isinstance(v, FileSystemSource): + # Already a FileSystemSource instance, return as-is return v elif isinstance(v, dict): - return EntrypointConfig(**v) - elif isinstance(v, str): - # Parse file.py:object syntax into object format if present - if ":" in v: - # Check if it's a Windows path (e.g., C:\...) - has_windows_drive = len(v) > 1 and v[1] == ":" - - # Only split if colon is not part of Windows drive - if ":" in (v[2:] if has_windows_drive else v): - file, obj = v.rsplit(":", 1) - return EntrypointConfig(file=file, object=obj) - else: - return EntrypointConfig(file=v) - - raise ValueError("entrypoint must be a string, EntrypointConfig instance") + # Dict can have type field or not (filesystem is default) + if "type" not in v: + v["type"] = "filesystem" + return FileSystemSource(**v) + else: + raise ValueError("source must be a dict or FileSystemSource instance") @field_validator("environment", mode="before") @classmethod - def validate_environment(cls, v: dict | EnvironmentConfig) -> EnvironmentConfig: - """Validate and convert environment to EnvironmentConfig. + def validate_environment(cls, v: dict | Environment) -> Environment: + """Validate and convert environment to Environment. Accepts: - - EnvironmentConfig instance - - dict that can be converted to EnvironmentConfig + - Environment instance + - dict that can be converted to Environment """ - if isinstance(v, EnvironmentConfig): + if isinstance(v, Environment): return v elif isinstance(v, dict): - return EnvironmentConfig(**v) # type: ignore[arg-type] + return Environment(**v) # type: ignore[arg-type] else: - raise ValueError("environment must be a dict, EnvironmentConfig instance") + raise ValueError("environment must be a dict, Environment instance") @field_validator("deployment", mode="before") @classmethod - def validate_deployment(cls, v: dict | DeploymentConfig) -> DeploymentConfig: - """Validate and convert deployment to DeploymentConfig. + def validate_deployment(cls, v: dict | Deployment) -> Deployment: + """Validate and convert deployment to Deployment. Accepts: - - DeploymentConfig instance - - dict that can be converted to DeploymentConfig + - Deployment instance + - dict that can be converted to Deployment """ - if isinstance(v, DeploymentConfig): + if isinstance(v, Deployment): return v elif isinstance(v, dict): - return DeploymentConfig(**v) # type: ignore[arg-type] + return Deployment(**v) # type: ignore[arg-type] else: - raise ValueError("deployment must be a dict, DeploymentConfig instance") - - def get_entrypoint(self, config_path: Path | None = None) -> EntrypointConfig: - """Get the entrypoint as a structured object with resolved paths. - - Args: - config_path: Path to config file for resolving relative paths - - Returns: - EntrypointConfig object with file, object, and repo fields. - If config_path is provided, relative file paths are resolved - relative to the config file location. - """ - if isinstance(self.entrypoint, str): - # Parse string format into structured object - if ":" in self.entrypoint: - file, obj = self.entrypoint.rsplit(":", 1) - entrypoint = EntrypointConfig(file=file, object=obj) - else: - entrypoint = EntrypointConfig(file=self.entrypoint) - else: - # Already an EntrypointConfig - entrypoint = self.entrypoint - - # Resolve relative paths if config_path provided - if config_path: - file_path = Path(entrypoint.file) - if not file_path.is_absolute(): - resolved_path = (config_path.parent / file_path).resolve() - # Create new EntrypointConfig with resolved path - entrypoint = EntrypointConfig( - file=str(resolved_path), - object=entrypoint.object, - repo=entrypoint.repo, - ) - - return entrypoint + raise ValueError("deployment must be a dict, Deployment instance") @classmethod def from_file(cls, file_path: Path) -> FastMCPConfig: @@ -494,7 +423,7 @@ class FastMCPConfig(BaseModel): @classmethod def from_cli_args( cls, - entrypoint: str, + source: FileSystemSource, transport: Literal["stdio", "http", "sse", "streamable-http"] | None = None, host: str | None = None, port: int | None = None, @@ -516,7 +445,7 @@ class FastMCPConfig(BaseModel): goes through a config object. Args: - entrypoint: Server entrypoint (file or file:object) + source: Server source (FileSystemSource instance) transport: Transport protocol host: Host for HTTP transport port: Port for HTTP transport @@ -537,7 +466,7 @@ class FastMCPConfig(BaseModel): # Build environment config if any env args provided environment = None if any([python, dependencies, requirements, project, editable]): - environment = EnvironmentConfig( + environment = Environment( python=python, dependencies=dependencies, requirements=requirements, @@ -551,7 +480,7 @@ class FastMCPConfig(BaseModel): # Convert streamable-http to http for backward compatibility if transport == "streamable-http": transport = "http" # type: ignore[assignment] - deployment = DeploymentConfig( + deployment = Deployment( transport=transport, # type: ignore[arg-type] host=host, port=port, @@ -563,7 +492,7 @@ class FastMCPConfig(BaseModel): ) return cls( - entrypoint=entrypoint, + source=source, environment=environment, deployment=deployment, ) @@ -588,14 +517,17 @@ class FastMCPConfig(BaseModel): return None - async def load_server(self, config_path: Path | None = None) -> Any: + async def load_server( + self, config_path: Path | None = None, server_args: list[str] | None = None + ) -> Any: """Load the server from the configuration. This handles environment setup, working directory changes, - and imports the server module. + and delegates to the source's load_server method. Args: config_path: Path to the config file (for resolving relative paths) + server_args: Optional arguments to pass to the server Returns: The imported server object @@ -619,16 +551,12 @@ class FastMCPConfig(BaseModel): cwd_path = cwd_path.resolve() os.chdir(cwd_path) - # Get structured entrypoint with resolved paths - entrypoint = self.get_entrypoint(config_path) + # Use server_args from deployment if not provided + if server_args is None and self.deployment: + server_args = self.deployment.args - # Import the server - from fastmcp.cli.run import import_server_with_args - - file_path = Path(entrypoint.file) - server_args = self.deployment.args if self.deployment else None - - return await import_server_with_args(file_path, entrypoint.object, server_args) + # Delegate to the source's load_server method + return await self.source.load_server(config_path, server_args) async def run_server(self, **kwargs: Any) -> None: """Load and run the server with this configuration. @@ -659,14 +587,19 @@ class FastMCPConfig(BaseModel): await server.run_async(**run_args) -def generate_schema() -> dict[str, Any]: +def generate_schema(output_path: Path | str | None = None) -> dict[str, Any] | None: """Generate JSON schema for fastmcp.json files. This is used to create the schema file that IDEs can use for validation and auto-completion. + Args: + output_path: Optional path to write the schema to. If provided, + writes the schema and returns None. If not provided, + returns the schema as a dictionary. + Returns: - JSON schema as a dictionary + JSON schema as a dictionary if output_path is None, otherwise None """ schema = FastMCPConfig.model_json_schema() @@ -675,4 +608,14 @@ def generate_schema() -> dict[str, Any]: schema["title"] = "FastMCP Configuration" schema["description"] = "Configuration file for FastMCP servers" + if output_path: + import json + + output = Path(output_path) + output.parent.mkdir(parents=True, exist_ok=True) + with open(output, "w") as f: + json.dump(schema, f, indent=2) + f.write("\n") # Add trailing newline + return None + return schema diff --git a/src/fastmcp/utilities/fastmcp_config/v1/schema.json b/src/fastmcp/utilities/fastmcp_config/v1/schema.json index db5b8650e..81d0ad754 100644 --- a/src/fastmcp/utilities/fastmcp_config/v1/schema.json +++ b/src/fastmcp/utilities/fastmcp_config/v1/schema.json @@ -1,12 +1,16 @@ { "$defs": { - "DeploymentConfig": { + "Deployment": { "description": "Configuration for server deployment and runtime settings.", "properties": { "transport": { "anyOf": [ { - "enum": ["stdio", "http", "sse"], + "enum": [ + "stdio", + "http", + "sse" + ], "type": "string" }, { @@ -28,7 +32,11 @@ ], "default": null, "description": "Host to bind to when using HTTP transport", - "examples": ["127.0.0.1", "0.0.0.0", "localhost"], + "examples": [ + "127.0.0.1", + "0.0.0.0", + "localhost" + ], "title": "Host" }, "port": { @@ -42,7 +50,11 @@ ], "default": null, "description": "Port to bind to when using HTTP transport", - "examples": [8000, 3000, 5000], + "examples": [ + 8000, + 3000, + 5000 + ], "title": "Port" }, "path": { @@ -56,13 +68,23 @@ ], "default": null, "description": "URL path for the server endpoint", - "examples": ["/mcp/", "/api/mcp/", "/sse/"], + "examples": [ + "/mcp/", + "/api/mcp/", + "/sse/" + ], "title": "Path" }, "log_level": { "anyOf": [ { - "enum": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + "enum": [ + "DEBUG", + "INFO", + "WARNING", + "ERROR", + "CRITICAL" + ], "type": "string" }, { @@ -84,7 +106,11 @@ ], "default": null, "description": "Working directory for the server process", - "examples": [".", "./src", "/app"], + "examples": [ + ".", + "./src", + "/app" + ], "title": "Cwd" }, "env": { @@ -123,56 +149,20 @@ ], "default": null, "description": "Arguments to pass to the server (after --)", - "examples": [["--config", "config.json", "--debug"]], + "examples": [ + [ + "--config", + "config.json", + "--debug" + ] + ], "title": "Args" } }, - "title": "DeploymentConfig", + "title": "Deployment", "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": { + "Environment": { "description": "Configuration for Python environment setup.", "properties": { "python": { @@ -186,7 +176,11 @@ ], "default": null, "description": "Python version constraint", - "examples": ["3.10", "3.11", "3.12"], + "examples": [ + "3.10", + "3.11", + "3.12" + ], "title": "Python" }, "dependencies": { @@ -203,7 +197,13 @@ ], "default": null, "description": "Python packages to install with PEP 508 specifiers", - "examples": [["fastmcp>=2.0,<3", "httpx", "pandas>=2.0"]], + "examples": [ + [ + "fastmcp>=2.0,<3", + "httpx", + "pandas>=2.0" + ] + ], "title": "Dependencies" }, "requirements": { @@ -217,7 +217,10 @@ ], "default": null, "description": "Path to requirements.txt file", - "examples": ["requirements.txt", "../requirements/prod.txt"], + "examples": [ + "requirements.txt", + "../requirements/prod.txt" + ], "title": "Requirements" }, "project": { @@ -231,7 +234,10 @@ ], "default": null, "description": "Path to project directory containing pyproject.toml", - "examples": [".", "../my-project"], + "examples": [ + ".", + "../my-project" + ], "title": "Project" }, "editable": { @@ -245,11 +251,49 @@ ], "default": null, "description": "Directory to install in editable mode", - "examples": [".", "../my-package"], + "examples": [ + ".", + "../my-package" + ], "title": "Editable" } }, - "title": "EnvironmentConfig", + "title": "Environment", + "type": "object" + }, + "FileSystemSource": { + "description": "Source for local Python files.", + "properties": { + "type": { + "const": "filesystem", + "default": "filesystem", + "description": "Source type", + "title": "Type", + "type": "string" + }, + "path": { + "description": "Path to Python file containing the server", + "title": "Path", + "type": "string" + }, + "entrypoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of server instance or factory function (a no-arg function that returns a FastMCP server)", + "title": "Entrypoint" + } + }, + "required": [ + "path" + ], + "title": "FileSystemSource", "type": "object" } }, @@ -268,28 +312,36 @@ "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", + "source": { + "$ref": "#/$defs/FileSystemSource", + "description": "Source configuration for the server", "examples": [ - "server.py", - "server.py:app", { - "file": "src/server.py", - "object": "app" + "path": "server.py" + }, + { + "entrypoint": "app", + "path": "server.py" + }, + { + "entrypoint": "mcp", + "path": "src/server.py", + "type": "filesystem" } ] }, "environment": { - "$ref": "#/$defs/EnvironmentConfig", + "$ref": "#/$defs/Environment", "description": "Python environment setup configuration" }, "deployment": { - "$ref": "#/$defs/DeploymentConfig", + "$ref": "#/$defs/Deployment", "description": "Server deployment and runtime settings" } }, - "required": ["entrypoint"], + "required": [ + "source" + ], "title": "FastMCP Configuration", "type": "object", "$id": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json" diff --git a/test.fastmcp.json b/test.fastmcp.json new file mode 100644 index 000000000..c2c15e00b --- /dev/null +++ b/test.fastmcp.json @@ -0,0 +1,17 @@ +{ + "source": { + "path": "test.py", + "object": "mcp" + }, + "environment": { + "python": "3.10", + "dependencies": ["fastmcp", "httpx", "pandas", "httpx23"] + }, + "deployment": { + "transport": "http", + "host": "127.0.0.1", + "port": 1234, + "path": "/mcp", + "log_level": "INFO" + } +} diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py index d8ad92b73..a1d930f8e 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -8,77 +8,52 @@ import pytest from pydantic import ValidationError from fastmcp.utilities.fastmcp_config import ( - DeploymentConfig, - EntrypointConfig, - EnvironmentConfig, + Deployment, + Environment, FastMCPConfig, + FileSystemSource, ) -class TestEntrypointConfig: - """Test EntrypointConfig class.""" +class TestFileSystemSource: + """Test FileSystemSource class.""" - def test_string_entrypoint(self): - """Test that string entrypoint is converted to EntrypointConfig.""" - config = FastMCPConfig(entrypoint="server.py") - # With the new validator, this should be converted to EntrypointConfig - assert isinstance(config.entrypoint, EntrypointConfig) - assert config.entrypoint.file == "server.py" - assert config.entrypoint.object is None + def test_dict_source_minimal(self): + """Test that dict source is converted to FileSystemSource.""" + config = FastMCPConfig(source={"path": "server.py"}) + # Dict is converted to FileSystemSource + assert isinstance(config.source, FileSystemSource) + assert config.source.path == "server.py" + assert config.source.entrypoint is None + assert config.source.type == "filesystem" - # get_entrypoint should return the same object - entrypoint = config.get_entrypoint() - assert isinstance(entrypoint, EntrypointConfig) - assert entrypoint.file == "server.py" - assert entrypoint.object is None + def test_dict_source_with_entrypoint(self): + """Test dict source with entrypoint field.""" + config = FastMCPConfig(source={"path": "server.py", "entrypoint": "app"}) + # Dict with entrypoint is converted to FileSystemSource + assert isinstance(config.source, FileSystemSource) + assert config.source.path == "server.py" + assert config.source.entrypoint == "app" + assert config.source.type == "filesystem" - def test_string_entrypoint_with_object(self): - """Test string entrypoint with :object syntax.""" - config = FastMCPConfig(entrypoint="server.py:app") - # With the new validator, this should be converted to EntrypointConfig - assert isinstance(config.entrypoint, EntrypointConfig) - assert config.entrypoint.file == "server.py" - assert config.entrypoint.object == "app" - - # get_entrypoint should return the same object - entrypoint = config.get_entrypoint() - assert isinstance(entrypoint, EntrypointConfig) - assert entrypoint.file == "server.py" - assert entrypoint.object == "app" - - def test_object_entrypoint(self): - """Test EntrypointConfig object format.""" + def test_filesystem_source_entrypoint(self): + """Test FileSystemSource entrypoint format.""" config = FastMCPConfig( - entrypoint=EntrypointConfig(file="src/server.py", object="mcp") + source=FileSystemSource(path="src/server.py", entrypoint="mcp") ) - assert isinstance(config.entrypoint, EntrypointConfig) - assert config.entrypoint.file == "src/server.py" - assert config.entrypoint.object == "mcp" - - def test_get_entrypoint_path_resolution(self, tmp_path): - """Test that get_entrypoint resolves paths relative to config file.""" - config_dir = tmp_path / "config" - config_dir.mkdir() - server_dir = tmp_path / "src" - server_dir.mkdir() - server_file = server_dir / "server.py" - server_file.write_text("# server") - - config = FastMCPConfig(entrypoint="../src/server.py") - entrypoint = config.get_entrypoint(config_dir / "fastmcp.json") - - # Should resolve to absolute path - assert Path(entrypoint.file).is_absolute() - assert Path(entrypoint.file) == server_file.resolve() + assert isinstance(config.source, FileSystemSource) + assert config.source.path == "src/server.py" + assert config.source.entrypoint == "mcp" + assert config.source.type == "filesystem" -class TestEnvironmentConfig: - """Test EnvironmentConfig class.""" +class TestEnvironment: + """Test Environment class.""" def test_environment_config_fields(self): - """Test all EnvironmentConfig fields.""" + """Test all Environment fields.""" config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, environment={ "python": "3.12", "dependencies": ["requests", "numpy>=2.0"], @@ -98,27 +73,29 @@ class TestEnvironmentConfig: def test_needs_uv(self): """Test needs_uv() method.""" # No environment config - doesn't need UV - config = FastMCPConfig(entrypoint="server.py") + config = FastMCPConfig(source={"path": "server.py"}) assert not config.environment.needs_uv() # Empty environment - doesn't need UV - config = FastMCPConfig(entrypoint="server.py", environment={}) + config = FastMCPConfig(source={"path": "server.py"}, environment={}) assert not config.environment.needs_uv() # With dependencies - needs UV config = FastMCPConfig( - entrypoint="server.py", environment={"dependencies": ["requests"]} + source={"path": "server.py"}, environment={"dependencies": ["requests"]} ) assert config.environment.needs_uv() # With Python version - needs UV - config = FastMCPConfig(entrypoint="server.py", environment={"python": "3.12"}) + config = FastMCPConfig( + source={"path": "server.py"}, environment={"python": "3.12"} + ) assert config.environment.needs_uv() def test_build_uv_args(self): """Test build_uv_args() method.""" config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, environment={ "python": "3.12", "dependencies": ["requests", "numpy"], @@ -143,33 +120,10 @@ class TestEnvironmentConfig: assert "run" in args[-2:] assert "server.py" in args[-1:] - def test_merge_with_cli_args(self): - """Test merge_with_cli_args() method.""" - config = FastMCPConfig( - entrypoint="server.py", - environment={ - "python": "3.11", - "dependencies": ["requests"], - }, - ) - - # CLI args should take precedence - merged = config.environment.merge_with_cli_args( - python="3.12", # Override - with_packages=["numpy"], # Add to dependencies - with_requirements=None, - project=None, - ) - - assert merged["python"] == "3.12" # CLI override - assert set(merged["with_packages"]) == {"requests", "numpy"} # Merged - assert merged["with_requirements"] is None - assert merged["project"] is None - def test_run_with_uv(self): """Test run_with_uv() subprocess execution.""" config = FastMCPConfig( - entrypoint="server.py", environment={"dependencies": ["requests"]} + source={"path": "server.py"}, environment={"dependencies": ["requests"]} ) # run_with_uv calls sys.exit, so we expect SystemExit @@ -182,13 +136,13 @@ class TestEnvironmentConfig: assert exc_info.value.code == 1 -class TestDeploymentConfig: - """Test DeploymentConfig class.""" +class TestDeployment: + """Test Deployment class.""" def test_deployment_config_fields(self): - """Test all DeploymentConfig fields.""" + """Test all Deployment fields.""" config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, deployment={ "transport": "http", "host": "0.0.0.0", @@ -211,33 +165,6 @@ class TestDeploymentConfig: assert deploy.cwd == "./work" assert deploy.args == ["--debug"] - def test_merge_with_cli_args(self): - """Test DeploymentConfig merge_with_cli_args() method.""" - config = FastMCPConfig( - entrypoint="server.py", - deployment={ - "transport": "stdio", - "port": 3000, - "log_level": "INFO", - }, - ) - - # CLI args should take precedence - merged = config.deployment.merge_with_cli_args( - transport="http", # Override - host="localhost", # New value - port=None, # Keep config value - path=None, - log_level="DEBUG", # Override - server_args=["--test"], - ) - - assert merged["transport"] == "http" # CLI override - assert merged["host"] == "localhost" # CLI value - assert merged["port"] == 3000 # Config value (CLI was None) - assert merged["log_level"] == "DEBUG" # CLI override - assert merged["server_args"] == ["--test"] # CLI value - def test_apply_runtime_settings(self, tmp_path): """Test apply_runtime_settings() method.""" import os @@ -247,7 +174,7 @@ class TestDeploymentConfig: work_dir.mkdir() config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, deployment={ "env": {"TEST_VAR": "test_value"}, "cwd": "work", @@ -283,7 +210,7 @@ class TestDeploymentConfig: os.environ["ENV_NAME"] = "production" config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, deployment={ "env": { "API_URL": "https://api.${BASE_URL}/v1", @@ -328,24 +255,24 @@ class TestFastMCPConfig: def test_minimal_config(self): """Test creating a config with only required fields.""" - config = FastMCPConfig(entrypoint="server.py") - assert isinstance(config.entrypoint, EntrypointConfig) - assert config.entrypoint.file == "server.py" - assert config.entrypoint.object is None + config = FastMCPConfig(source={"path": "server.py"}) + assert isinstance(config.source, FileSystemSource) + assert config.source.path == "server.py" + assert config.source.entrypoint is None # Environment and deployment are now always present but empty - assert isinstance(config.environment, EnvironmentConfig) - assert isinstance(config.deployment, DeploymentConfig) + assert isinstance(config.environment, Environment) + assert isinstance(config.deployment, Deployment) # Check they have no values set assert not config.environment.needs_uv() assert all( getattr(config.deployment, field, None) is None - for field in DeploymentConfig.model_fields + for field in Deployment.model_fields ) def test_nested_structure(self): """Test the nested configuration structure.""" config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, environment={ "python": "3.12", "dependencies": ["fastmcp"], @@ -356,17 +283,17 @@ class TestFastMCPConfig: }, ) - assert isinstance(config.entrypoint, EntrypointConfig) - assert config.entrypoint.file == "server.py" - assert config.entrypoint.object is None - assert isinstance(config.environment, EnvironmentConfig) - assert isinstance(config.deployment, DeploymentConfig) + assert isinstance(config.source, FileSystemSource) + assert config.source.path == "server.py" + assert config.source.entrypoint is None + assert isinstance(config.environment, Environment) + assert isinstance(config.deployment, Deployment) def test_from_file(self, tmp_path): """Test loading config from JSON file with nested structure.""" config_data = { "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "entrypoint": {"file": "src/server.py", "object": "app"}, + "source": {"path": "src/server.py", "entrypoint": "app"}, "environment": {"python": "3.12", "dependencies": ["requests"]}, "deployment": {"transport": "http", "port": 8000}, } @@ -376,19 +303,19 @@ class TestFastMCPConfig: config = FastMCPConfig.from_file(config_file) - # When loaded from JSON with object format, it becomes EntrypointConfig - assert isinstance(config.entrypoint, EntrypointConfig) - assert config.entrypoint.file == "src/server.py" - assert config.entrypoint.object == "app" + # When loaded from JSON with entrypoint format, it becomes EntrypointConfig + assert isinstance(config.source, FileSystemSource) + assert config.source.path == "src/server.py" + assert config.source.entrypoint == "app" assert config.environment.python == "3.12" assert config.environment.dependencies == ["requests"] assert config.deployment.transport == "http" assert config.deployment.port == 8000 def test_from_file_with_string_entrypoint(self, tmp_path): - """Test loading config with string entrypoint.""" + """Test loading config with dict source format.""" config_data = { - "entrypoint": "server.py:mcp", + "source": {"path": "server.py", "entrypoint": "mcp"}, "environment": {"dependencies": ["fastmcp"]}, } @@ -397,19 +324,14 @@ class TestFastMCPConfig: config = FastMCPConfig.from_file(config_file) # String entrypoint with : should be converted to EntrypointConfig - assert isinstance(config.entrypoint, EntrypointConfig) - assert config.entrypoint.file == "server.py" - assert config.entrypoint.object == "mcp" + assert isinstance(config.source, FileSystemSource) + assert config.source.path == "server.py" + assert config.source.entrypoint == "mcp" - # get_entrypoint should return the same - entrypoint = config.get_entrypoint() - assert entrypoint.file == "server.py" - assert entrypoint.object == "mcp" - - def test_string_entrypoint_with_object_and_environment(self, tmp_path): - """Test that file.py:object syntax works with environment config.""" + def test_string_entrypoint_with_entrypoint_and_environment(self, tmp_path): + """Test that file.py:entrypoint syntax works with environment config.""" config_data = { - "entrypoint": "src/server.py:app", + "source": {"path": "src/server.py", "entrypoint": "app"}, "environment": {"python": "3.12", "dependencies": ["fastmcp", "requests"]}, "deployment": {"transport": "http", "port": 8000}, } @@ -420,9 +342,9 @@ class TestFastMCPConfig: config = FastMCPConfig.from_file(config_file) # Should be parsed into EntrypointConfig - assert isinstance(config.entrypoint, EntrypointConfig) - assert config.entrypoint.file == "src/server.py" - assert config.entrypoint.object == "app" + assert isinstance(config.source, FileSystemSource) + assert config.source.path == "src/server.py" + assert config.source.entrypoint == "app" # Environment config should still work assert config.environment.python == "3.12" @@ -435,7 +357,7 @@ class TestFastMCPConfig: def test_find_config_in_current_dir(self, tmp_path): """Test finding config in current directory.""" config_file = tmp_path / "fastmcp.json" - config_file.write_text(json.dumps({"entrypoint": "server.py"})) + config_file.write_text(json.dumps({"source": {"path": "server.py"}})) original_cwd = os.getcwd() try: @@ -448,7 +370,7 @@ class TestFastMCPConfig: def test_find_config_not_in_parent_dir(self, tmp_path): """Test that config is NOT found in parent directory.""" config_file = tmp_path / "fastmcp.json" - config_file.write_text(json.dumps({"entrypoint": "server.py"})) + config_file.write_text(json.dumps({"source": {"path": "server.py"}})) subdir = tmp_path / "subdir" subdir.mkdir() @@ -460,7 +382,7 @@ class TestFastMCPConfig: def test_find_config_in_specified_dir(self, tmp_path): """Test finding config in the specified directory.""" config_file = tmp_path / "fastmcp.json" - config_file.write_text(json.dumps({"entrypoint": "server.py"})) + config_file.write_text(json.dumps({"source": {"path": "server.py"}})) # Should find config when looking in the directory that contains it found = FastMCPConfig.find_config(tmp_path) @@ -474,7 +396,7 @@ class TestFastMCPConfig: def test_invalid_transport(self, tmp_path): """Test loading config with invalid transport value.""" config_data = { - "entrypoint": "server.py", + "source": {"path": "server.py"}, "deployment": {"transport": "invalid_transport"}, } @@ -485,29 +407,33 @@ class TestFastMCPConfig: FastMCPConfig.from_file(config_file) def test_optional_sections(self): - """Test that all config sections are optional except entrypoint.""" - # Only entrypoint is required - config = FastMCPConfig(entrypoint="server.py") - assert isinstance(config.entrypoint, EntrypointConfig) - assert config.entrypoint.file == "server.py" + """Test that all config sections are optional except source.""" + # Only source is required + config = FastMCPConfig(source={"path": "server.py"}) + assert isinstance(config.source, FileSystemSource) + assert config.source.path == "server.py" # Environment and deployment are now always present but may be empty - assert isinstance(config.environment, EnvironmentConfig) - assert isinstance(config.deployment, DeploymentConfig) + assert isinstance(config.environment, Environment) + assert isinstance(config.deployment, Deployment) # Only environment with values - config = FastMCPConfig(entrypoint="server.py", environment={"python": "3.12"}) + config = FastMCPConfig( + source={"path": "server.py"}, environment={"python": "3.12"} + ) assert config.environment.python == "3.12" - assert isinstance(config.deployment, DeploymentConfig) + assert isinstance(config.deployment, Deployment) assert all( getattr(config.deployment, field, None) is None - for field in DeploymentConfig.model_fields + for field in Deployment.model_fields ) # Only deployment with values - config = FastMCPConfig(entrypoint="server.py", deployment={"transport": "http"}) - assert isinstance(config.environment, EnvironmentConfig) + config = FastMCPConfig( + source={"path": "server.py"}, deployment={"transport": "http"} + ) + assert isinstance(config.environment, Environment) assert all( getattr(config.environment, field, None) is None - for field in EnvironmentConfig.model_fields + for field in Environment.model_fields ) assert config.deployment.transport == "http" diff --git a/tests/cli/test_cursor.py b/tests/cli/test_cursor.py index 647ae6541..37352fd10 100644 --- a/tests/cli/test_cursor.py +++ b/tests/cli/test_cursor.py @@ -239,11 +239,14 @@ class TestInstallCursor: """Test cursor installation with editable package.""" mock_open_deeplink.return_value = True + # Use an absolute path that works on all platforms + editable_path = Path.cwd() / "local" / "package" + result = install_cursor( file=Path("/path/to/server.py"), server_object="custom_app", name="test-server", - with_editable=Path("/local/package"), + with_editable=editable_path, ) assert result is True diff --git a/tests/cli/test_fastmcp_config_integration.py b/tests/cli/test_fastmcp_config_integration.py index 758661ab2..98070385f 100644 --- a/tests/cli/test_fastmcp_config_integration.py +++ b/tests/cli/test_fastmcp_config_integration.py @@ -38,7 +38,7 @@ if __name__ == "__main__": # Create config file config_data = { "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "entrypoint": "server.py", + "source": {"path": "server.py"}, "environment": { "python": sys.version.split()[0], # Use current Python version "dependencies": ["fastmcp"], @@ -58,7 +58,7 @@ class TestConfigFileDetection: def test_detect_standard_fastmcp_json(self, tmp_path): """Test detection of standard fastmcp.json file.""" config_file = tmp_path / "fastmcp.json" - config_file.write_text(json.dumps({"entrypoint": "server.py"})) + config_file.write_text(json.dumps({"source": {"path": "server.py"}})) # Should be detected as fastmcp config assert "fastmcp.json" in config_file.name @@ -67,7 +67,7 @@ class TestConfigFileDetection: def test_detect_prefixed_fastmcp_json(self, tmp_path): """Test detection of prefixed fastmcp.json files.""" config_file = tmp_path / "my.fastmcp.json" - config_file.write_text(json.dumps({"entrypoint": "server.py"})) + config_file.write_text(json.dumps({"source": {"path": "server.py"}})) # Should be detected as fastmcp config assert "fastmcp.json" in config_file.name @@ -75,7 +75,7 @@ class TestConfigFileDetection: def test_detect_test_fastmcp_json(self, tmp_path): """Test detection of test_fastmcp.json file.""" config_file = tmp_path / "test_fastmcp.json" - config_file.write_text(json.dumps({"entrypoint": "server.py"})) + config_file.write_text(json.dumps({"source": {"path": "server.py"}})) # Should be detected as fastmcp config assert "fastmcp.json" in config_file.name @@ -91,14 +91,18 @@ class TestConfigWithClient: config_file = server_with_config / "fastmcp.json" config = FastMCPConfig.from_file(config_file) - # Import the server using the entrypoint + # Import the server using the source import importlib.util import sys - entrypoint = config.get_entrypoint(config_file) - spec = importlib.util.spec_from_file_location("test_server", entrypoint.file) + # Resolve the path from the source + source_path = Path(config.source.path) + if not source_path.is_absolute(): + source_path = (config_file.parent / source_path).resolve() + + spec = importlib.util.spec_from_file_location("test_server", str(source_path)) if spec is None or spec.loader is None: - raise RuntimeError(f"Could not load module from {entrypoint.file}") + raise RuntimeError(f"Could not load module from {source_path}") module = importlib.util.module_from_spec(spec) sys.modules["test_server"] = module spec.loader.exec_module(module) @@ -129,7 +133,7 @@ class TestEnvironmentExecution: def test_needs_uv_with_dependencies(self): """Test that environment with dependencies needs UV.""" config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, environment={"dependencies": ["requests", "numpy"]}, # type: ignore[arg-type] ) @@ -139,7 +143,7 @@ class TestEnvironmentExecution: def test_needs_uv_with_python_version(self): """Test that environment with Python version needs UV.""" config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, environment={"python": "3.12"}, # type: ignore[arg-type] ) @@ -148,7 +152,7 @@ class TestEnvironmentExecution: def test_no_uv_needed_without_environment(self): """Test that no UV is needed without environment config.""" - config = FastMCPConfig(entrypoint="server.py") + config = FastMCPConfig(source={"path": "server.py"}) # Environment is now always present but may be empty assert config.environment is not None @@ -157,7 +161,7 @@ class TestEnvironmentExecution: def test_no_uv_needed_with_empty_environment(self): """Test that no UV is needed with empty environment config.""" config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, environment={}, # type: ignore[arg-type] ) @@ -165,77 +169,11 @@ class TestEnvironmentExecution: assert not config.environment.needs_uv() -class TestCLIArgumentMerging: - """Test CLI argument merging with config values.""" - - def test_cli_overrides_environment(self): - """Test that CLI args override environment config.""" - config = FastMCPConfig( - entrypoint="server.py", - environment={"python": "3.11", "dependencies": ["requests"]}, # type: ignore[arg-type] - ) - - assert config.environment is not None - merged = config.environment.merge_with_cli_args( - python="3.12", # Override Python version - with_packages=["numpy"], # Add package - with_requirements=None, - project=None, - ) - - assert merged["python"] == "3.12" # CLI wins - assert "requests" in merged["with_packages"] # From config - assert "numpy" in merged["with_packages"] # From CLI - - def test_cli_overrides_deployment(self): - """Test that CLI args override deployment config.""" - config = FastMCPConfig( - entrypoint="server.py", - deployment={"transport": "stdio", "port": 3000, "log_level": "INFO"}, # type: ignore[arg-type] - ) - - assert config.deployment is not None - merged = config.deployment.merge_with_cli_args( - transport="http", # Override transport - host="localhost", # New value - port=8080, # Override port - path=None, - log_level="DEBUG", # Override log level - server_args=None, - ) - - assert merged["transport"] == "http" # CLI wins - assert merged["host"] == "localhost" # CLI value - assert merged["port"] == 8080 # CLI wins - assert merged["log_level"] == "DEBUG" # CLI wins - - def test_config_values_when_cli_is_none(self): - """Test that config values are used when CLI args are None.""" - config = FastMCPConfig( - entrypoint="server.py", - deployment={"transport": "http", "port": 3000}, # type: ignore[arg-type] - ) - - assert config.deployment is not None - merged = config.deployment.merge_with_cli_args( - transport=None, # Use config - host=None, # No value - port=None, # Use config - path=None, - log_level=None, - server_args=None, - ) - - assert merged["transport"] == "http" # From config - assert merged["port"] == 3000 # From config - assert merged["host"] is None # No value provided - - class TestPathResolution: """Test path resolution in configurations.""" - def test_entrypoint_path_resolution(self, tmp_path): - """Test that entrypoint paths are resolved relative to config.""" + def test_source_path_resolution(self, tmp_path): + """Test that source paths are resolved relative to config.""" # Create nested directory structure config_dir = tmp_path / "config" config_dir.mkdir() @@ -246,14 +184,11 @@ class TestPathResolution: server_file = src_dir / "server.py" server_file.write_text("# Server") - config = FastMCPConfig(entrypoint="../src/server.py") + config = FastMCPConfig(source={"path": "../src/server.py"}) - # Get entrypoint resolved relative to config location - config_file = config_dir / "fastmcp.json" - entrypoint = config.get_entrypoint(config_file) - - # Should resolve to absolute path of server file - assert Path(entrypoint.file) == server_file.resolve() + # The source path is resolved during load_server + # For now, just check that the source is created correctly + assert config.source.path == "../src/server.py" def test_cwd_path_resolution(self, tmp_path): """Test that working directory is resolved relative to config.""" @@ -264,7 +199,7 @@ class TestPathResolution: work_dir.mkdir() config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, deployment={"cwd": "work"}, # type: ignore[arg-type] ) @@ -288,7 +223,7 @@ class TestPathResolution: reqs_file.write_text("fastmcp>=2.0") config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, environment={"requirements": "requirements.txt"}, # type: ignore[arg-type] ) @@ -309,7 +244,7 @@ class TestConfigValidation: """Test that invalid transport values are rejected.""" with pytest.raises(ValueError): FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, deployment={"transport": "invalid_transport"}, # type: ignore[arg-type] ) @@ -317,7 +252,7 @@ class TestConfigValidation: """Test that streamable-http transport is rejected in fastmcp.json config.""" with pytest.raises(ValueError): FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, deployment={"transport": "streamable-http"}, # type: ignore[arg-type] ) @@ -325,12 +260,12 @@ class TestConfigValidation: """Test that invalid log level values are rejected.""" with pytest.raises(ValueError): FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, deployment={"log_level": "INVALID"}, # type: ignore[arg-type] ) - def test_missing_entrypoint_rejected(self): - """Test that config without entrypoint is rejected.""" + def test_missing_source_rejected(self): + """Test that config without source is rejected.""" with pytest.raises(ValueError): FastMCPConfig() # type: ignore[call-arg] @@ -338,7 +273,7 @@ class TestConfigValidation: """Test that all valid transport values are accepted.""" for transport in ["stdio", "http", "sse"]: config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, deployment={"transport": transport}, # type: ignore[arg-type] ) assert config.deployment is not None @@ -348,7 +283,7 @@ class TestConfigValidation: """Test that all valid log levels are accepted.""" for level in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]: config = FastMCPConfig( - entrypoint="server.py", + source={"path": "server.py"}, deployment={"log_level": level}, # type: ignore[arg-type] ) assert config.deployment is not None diff --git a/tests/cli/test_fastmcp_config_schema.py b/tests/cli/test_fastmcp_config_schema.py index 133a52eaa..5c05cd65f 100644 --- a/tests/cli/test_fastmcp_config_schema.py +++ b/tests/cli/test_fastmcp_config_schema.py @@ -40,6 +40,7 @@ def test_schema_has_correct_id(): """Test that the schema has the correct $id field.""" generated_schema = generate_schema() + assert generated_schema is not None assert "$id" in generated_schema assert ( generated_schema["$id"] @@ -51,19 +52,21 @@ def test_schema_has_required_fields(): """Test that the schema specifies the required fields correctly.""" generated_schema = generate_schema() - # Check that entrypoint is required + assert generated_schema is not None + # Check that source is required assert "required" in generated_schema - assert "entrypoint" in generated_schema["required"] + assert "source" in generated_schema["required"] - # Check that entrypoint is in properties + # Check that source is in properties assert "properties" in generated_schema - assert "entrypoint" in generated_schema["properties"] + assert "source" in generated_schema["properties"] def test_schema_nested_structure(): """Test that the schema has the correct nested structure.""" generated_schema = generate_schema() + assert generated_schema is not None properties = generated_schema["properties"] # Check environment section @@ -95,6 +98,7 @@ def test_schema_transport_enum(): """Test that transport field has correct enum values.""" generated_schema = generate_schema() + assert generated_schema is not None # Navigate to transport field deploy_schema = generated_schema["properties"]["deployment"] @@ -129,6 +133,7 @@ def test_schema_log_level_enum(): """Test that log_level field has correct enum values.""" generated_schema = generate_schema() + assert generated_schema is not None # Navigate to log_level field deploy_schema = generated_schema["properties"]["deployment"] diff --git a/tests/cli/test_run_config.py b/tests/cli/test_run_config.py index 50bed5136..f4ce3f547 100644 --- a/tests/cli/test_run_config.py +++ b/tests/cli/test_run_config.py @@ -8,9 +8,10 @@ import pytest from fastmcp.cli.run import load_fastmcp_config from fastmcp.utilities.fastmcp_config import ( - DeploymentConfig, - EntrypointConfig, - EnvironmentConfig, + Deployment, + Environment, + FastMCPConfig, + FileSystemSource, ) @@ -19,7 +20,7 @@ def sample_config(tmp_path): """Create a sample fastmcp.json configuration file with nested structure.""" config_data = { "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "entrypoint": "server.py", + "source": {"path": "server.py"}, "environment": {"python": "3.11", "dependencies": ["requests"]}, "deployment": {"transport": "stdio", "env": {"TEST_VAR": "test_value"}}, } @@ -49,25 +50,25 @@ def test_load_fastmcp_config(sample_config, monkeypatch): original_env = dict(os.environ) try: - entrypoint, deployment, environment = load_fastmcp_config(sample_config) + config = load_fastmcp_config(sample_config) # Check that we got the right types - assert isinstance(entrypoint, EntrypointConfig) - assert isinstance(deployment, DeploymentConfig) - assert isinstance(environment, EnvironmentConfig) + assert isinstance(config, FastMCPConfig) + assert isinstance(config.source, FileSystemSource) + assert isinstance(config.deployment, Deployment) + assert isinstance(config.environment, Environment) - # Check entrypoint - assert entrypoint.file.endswith("server.py") - assert Path(entrypoint.file).is_absolute() - assert entrypoint.object is None + # Check source - path is not resolved yet, only during load_server + assert config.source.path == "server.py" + assert config.source.entrypoint is None # Check environment config - assert environment.python == "3.11" - assert environment.dependencies == ["requests"] + assert config.environment.python == "3.11" + assert config.environment.dependencies == ["requests"] # Check deployment config - assert deployment.transport == "stdio" - assert deployment.env == {"TEST_VAR": "test_value"} + assert config.deployment.transport == "stdio" + assert config.deployment.env == {"TEST_VAR": "test_value"} # Check that environment variables were applied assert os.environ.get("TEST_VAR") == "test_value" @@ -78,10 +79,10 @@ def test_load_fastmcp_config(sample_config, monkeypatch): os.environ.update(original_env) -def test_load_config_with_object_entrypoint(tmp_path): - """Test loading config with object-format entrypoint.""" +def test_load_config_with_entrypoint_source(tmp_path): + """Test loading config with entrypoint-format source.""" config_data = { - "entrypoint": {"file": "src/server.py", "object": "app"}, + "source": {"path": "src/server.py", "entrypoint": "app"}, "deployment": {"transport": "http", "port": 8000}, } @@ -94,29 +95,25 @@ def test_load_config_with_object_entrypoint(tmp_path): server_file = src_dir / "server.py" server_file.write_text("# Server") - entrypoint, deployment, environment = load_fastmcp_config(config_file) + config = load_fastmcp_config(config_file) - # Check entrypoint resolution - assert entrypoint.file == str(server_file.resolve()) - assert entrypoint.object == "app" + # Check source - path is not resolved yet, only during load_server + assert config.source.path == "src/server.py" + assert config.source.entrypoint == "app" # Check deployment - assert deployment is not None - assert deployment.transport == "http" - assert deployment.port == 8000 - - # No environment config - assert environment is None + assert config.deployment.transport == "http" + assert config.deployment.port == 8000 def test_load_config_with_cwd(tmp_path): - """Test that DeploymentConfig applies working directory change.""" + """Test that Deployment applies working directory change.""" # Create a subdirectory subdir = tmp_path / "subdir" subdir.mkdir() - config_data = {"entrypoint": "server.py", "deployment": {"cwd": "subdir"}} + config_data = {"source": {"path": "server.py"}, "deployment": {"cwd": "subdir"}} config_file = tmp_path / "fastmcp.json" config_file.write_text(json.dumps(config_data)) @@ -128,7 +125,7 @@ def test_load_config_with_cwd(tmp_path): original_cwd = os.getcwd() try: - entrypoint, deployment, environment = load_fastmcp_config(config_file) + config = load_fastmcp_config(config_file) # noqa: F841 # Check that working directory was changed assert Path.cwd() == subdir.resolve() @@ -147,7 +144,7 @@ def test_load_config_with_relative_cwd(tmp_path): subdir2.mkdir(parents=True) config_data = { - "entrypoint": "server.py", + "source": {"path": "server.py"}, "deployment": { "cwd": "../" # Relative to config file location }, @@ -163,7 +160,7 @@ def test_load_config_with_relative_cwd(tmp_path): original_cwd = os.getcwd() try: - entrypoint, deployment, environment = load_fastmcp_config(config_file) + config = load_fastmcp_config(config_file) # noqa: F841 # Should change to parent directory of config file assert Path.cwd() == subdir1.resolve() @@ -173,8 +170,8 @@ def test_load_config_with_relative_cwd(tmp_path): def test_load_minimal_config(tmp_path): - """Test loading minimal configuration with only entrypoint.""" - config_data = {"entrypoint": "server.py"} + """Test loading minimal configuration with only source.""" + config_data = {"source": {"path": "server.py"}} config_file = tmp_path / "fastmcp.json" config_file.write_text(json.dumps(config_data)) @@ -183,21 +180,17 @@ def test_load_minimal_config(tmp_path): server_file = tmp_path / "server.py" server_file.write_text("# Server") - entrypoint, deployment, environment = load_fastmcp_config(config_file) + config = load_fastmcp_config(config_file) - # Check we got entrypoint - assert isinstance(entrypoint, EntrypointConfig) - assert entrypoint.file == str(server_file.resolve()) - - # No deployment or environment - assert deployment is None - assert environment is None + # Check we got source - path is not resolved yet, only during load_server + assert isinstance(config.source, FileSystemSource) + assert config.source.path == "server.py" def test_load_config_with_server_args(tmp_path): """Test configuration with server arguments.""" config_data = { - "entrypoint": "server.py", + "source": {"path": "server.py"}, "deployment": {"args": ["--debug", "--config", "custom.json"]}, } @@ -208,16 +201,15 @@ def test_load_config_with_server_args(tmp_path): server_file = tmp_path / "server.py" server_file.write_text("# Server") - entrypoint, deployment, environment = load_fastmcp_config(config_file) + config = load_fastmcp_config(config_file) - assert deployment is not None - assert deployment.args == ["--debug", "--config", "custom.json"] + assert config.deployment.args == ["--debug", "--config", "custom.json"] def test_config_subset_independence(tmp_path): """Test that config subsets can be used independently.""" config_data = { - "entrypoint": "server.py", + "source": {"path": "server.py"}, "environment": {"python": "3.12", "dependencies": ["pandas"]}, "deployment": {"transport": "http", "host": "0.0.0.0", "port": 3000}, } @@ -229,36 +221,20 @@ def test_config_subset_independence(tmp_path): server_file = tmp_path / "server.py" server_file.write_text("# Server") - entrypoint, deployment, environment = load_fastmcp_config(config_file) + config = load_fastmcp_config(config_file) # Each subset should be independently usable - assert entrypoint.file == str(server_file.resolve()) - assert entrypoint.object is None + # Path is not resolved yet, only during load_server + assert config.source.path == "server.py" + assert config.source.entrypoint is None - assert environment is not None - assert environment.python == "3.12" - assert environment.dependencies == ["pandas"] - assert environment.needs_uv() # Has dependencies + assert config.environment.python == "3.12" + assert config.environment.dependencies == ["pandas"] + assert config.environment.needs_uv() # Has dependencies - assert deployment is not None - assert deployment.transport == "http" - assert deployment.host == "0.0.0.0" - assert deployment.port == 3000 - - # Can merge deployment config with CLI args - merged = deployment.merge_with_cli_args( - transport=None, # Keep config value - host="localhost", # Override - port=8080, # Override - path="/api", # New value - log_level=None, - server_args=None, - ) - - assert merged["transport"] == "http" # Kept from config - assert merged["host"] == "localhost" # CLI override - assert merged["port"] == 8080 # CLI override - assert merged["path"] == "/api" # CLI value + assert config.deployment.transport == "http" + assert config.deployment.host == "0.0.0.0" + assert config.deployment.port == 3000 def test_environment_config_path_resolution(tmp_path): @@ -268,7 +244,7 @@ def test_environment_config_path_resolution(tmp_path): reqs_file.write_text("fastmcp>=2.0") config_data = { - "entrypoint": "server.py", + "source": {"path": "server.py"}, "environment": { "requirements": "requirements.txt", "project": ".", @@ -283,11 +259,10 @@ def test_environment_config_path_resolution(tmp_path): server_file = tmp_path / "server.py" server_file.write_text("# Server") - entrypoint, deployment, environment = load_fastmcp_config(config_file) + config = load_fastmcp_config(config_file) # Check that UV args are built with resolved paths - assert environment is not None - uv_args = environment.build_uv_args(["fastmcp", "run", "server.py"]) + uv_args = config.environment.build_uv_args(["fastmcp", "run", "server.py"]) assert "--with-requirements" in uv_args assert "--project" in uv_args diff --git a/tests/cli/test_run_with_uv.py b/tests/cli/test_run_with_uv.py index 4ede616b6..9ab903856 100644 --- a/tests/cli/test_run_with_uv.py +++ b/tests/cli/test_run_with_uv.py @@ -26,7 +26,16 @@ class TestRunWithUv: mock_run.assert_called_once() cmd = mock_run.call_args[0][0] - expected = ["uv", "run", "--with", "fastmcp", "fastmcp", "run", "server.py"] + expected = [ + "uv", + "run", + "--with", + "fastmcp", + "fastmcp", + "run", + "server.py", + "--skip-env", + ] assert cmd == expected @patch("subprocess.run") @@ -50,6 +59,7 @@ class TestRunWithUv: "fastmcp", "run", "server.py", + "--skip-env", ] assert cmd == expected @@ -57,7 +67,8 @@ class TestRunWithUv: def test_run_with_uv_project(self, mock_run): """Test run_with_uv with project directory.""" mock_run.return_value = Mock(returncode=0) - project_path = Path("/my/project") + # Use an absolute path that works on all platforms + project_path = Path.cwd() / "my" / "project" with pytest.raises(SystemExit) as exc_info: run_with_uv("server.py", project=project_path) @@ -70,7 +81,14 @@ class TestRunWithUv: # 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"] + assert cmd[4:] == [ + "--with", + "fastmcp", + "fastmcp", + "run", + "server.py", + "--skip-env", + ] @patch("subprocess.run") def test_run_with_uv_with_packages(self, mock_run): @@ -89,12 +107,13 @@ class TestRunWithUv: "--with", "fastmcp", "--with", - "numpy", # sorted alphabetically + "pandas", # original order preserved "--with", - "pandas", # sorted alphabetically + "numpy", # original order preserved "fastmcp", "run", "server.py", + "--skip-env", ] assert cmd == expected @@ -116,10 +135,11 @@ class TestRunWithUv: "--with", "fastmcp", "--with-requirements", - str(req_path.expanduser().resolve()), # resolved to absolute path + str(req_path.resolve()), # auto-resolved to absolute path "fastmcp", "run", "server.py", + "--skip-env", ] assert cmd == expected @@ -150,6 +170,7 @@ class TestRunWithUv: "fastmcp", "run", "server.py", + "--skip-env", "--transport", "http", "--host", @@ -169,11 +190,14 @@ class TestRunWithUv: """Test run_with_uv with all options combined.""" mock_run.return_value = Mock(returncode=0) + # Use an absolute path that works on all platforms + project_path = Path.cwd() / "workspace" + with pytest.raises(SystemExit) as exc_info: run_with_uv( "server.py", python_version="3.10", - project=Path("/workspace"), + project=project_path, with_packages=["pandas"], with_requirements=Path("reqs.txt"), transport="http", @@ -191,12 +215,14 @@ class TestRunWithUv: assert Path(cmd[5]).is_absolute() assert cmd[6:10] == ["--with", "fastmcp", "--with", "pandas"] assert cmd[10] == "--with-requirements" - # Check requirements path is absolute + # Check requirements path is now auto-resolved to absolute assert Path(cmd[11]).is_absolute() + assert Path(cmd[11]).name == "reqs.txt" assert cmd[12:] == [ "fastmcp", "run", "server.py", + "--skip-env", "--transport", "http", "--port", diff --git a/tests/utilities/test_cli.py b/tests/utilities/test_cli.py index 905eadc2a..3d0fdc435 100644 --- a/tests/utilities/test_cli.py +++ b/tests/utilities/test_cli.py @@ -1,39 +1,38 @@ -from pathlib import Path - -from fastmcp.utilities.cli import build_uv_command +from fastmcp.utilities.fastmcp_config.v1.fastmcp_config import Environment -class TestBuildUVCommand: - """Test the build_uv_command function.""" +class TestEnvironmentBuildUVArgs: + """Test the Environment.build_uv_args() method.""" - 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_args_basic(self): + """Test building basic uv args.""" + env = Environment() + args = env.build_uv_args(["fastmcp", "run", "server.py"]) + expected = ["run", "--with", "fastmcp", "fastmcp", "run", "server.py"] + assert args == 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) + def test_build_uv_args_with_editable(self): + """Test building uv args with editable package.""" + editable_path = "/path/to/package" + env = Environment(editable=editable_path) + args = env.build_uv_args(["fastmcp", "run", "server.py"]) expected = [ - "uv", "run", "--with", "fastmcp", "--with-editable", - str(editable_path.expanduser().resolve()), + editable_path, "fastmcp", "run", "server.py", ] - assert cmd == expected + assert args == 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"]) + def test_build_uv_args_with_packages(self): + """Test building uv args with additional packages.""" + env = Environment(dependencies=["pkg1", "pkg2"]) + args = env.build_uv_args(["fastmcp", "run", "server.py"]) expected = [ - "uv", "run", "--with", "fastmcp", @@ -45,13 +44,13 @@ class TestBuildUVCommand: "run", "server.py", ] - assert cmd == expected + assert args == 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") + def test_build_uv_args_with_python_version(self): + """Test building uv args with Python version.""" + env = Environment(python="3.11") + args = env.build_uv_args(["fastmcp", "run", "server.py"]) expected = [ - "uv", "run", "--python", "3.11", @@ -61,113 +60,109 @@ class TestBuildUVCommand: "run", "server.py", ] - assert cmd == expected + assert args == 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) + def test_build_uv_args_with_project(self): + """Test building uv args with project directory.""" + project_path = "/path/to/project" + env = Environment(project=project_path) + args = env.build_uv_args(["fastmcp", "run", "server.py"]) expected = [ - "uv", "run", "--project", - str(project_path.expanduser().resolve()), + project_path, "--with", "fastmcp", "fastmcp", "run", "server.py", ] - assert cmd == expected + assert args == 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) + def test_build_uv_args_with_requirements(self): + """Test building uv args with requirements file.""" + req_path = "requirements.txt" + env = Environment(requirements=req_path) + args = env.build_uv_args(["fastmcp", "run", "server.py"]) expected = [ - "uv", "run", "--with", "fastmcp", "--with-requirements", - str(req_path.expanduser().resolve()), + req_path, "fastmcp", "run", "server.py", ] - assert cmd == expected + assert args == 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", + def test_build_uv_args_with_all_options(self): + """Test building uv args with all options.""" + project_path = "/my/project" + editable_path = "/local/pkg" + requirements_path = "reqs.txt" + env = Environment( + python="3.10", project=project_path, - with_packages=["pandas", "numpy"], - with_requirements=requirements_path, - with_editable=editable_path, + dependencies=["pandas", "numpy"], + requirements=requirements_path, + editable=editable_path, ) + args = env.build_uv_args(["fastmcp", "run", "server.py"]) expected = [ - "uv", "run", "--python", "3.10", "--project", - str(project_path.expanduser().resolve()), + project_path, "--with", "fastmcp", "--with", - "numpy", - "--with", "pandas", - "--with-editable", - str(editable_path.expanduser().resolve()), + "--with", + "numpy", "--with-requirements", - str(requirements_path.expanduser().resolve()), + requirements_path, + "--with-editable", + editable_path, "fastmcp", "run", "server.py", ] - assert cmd == expected + assert args == 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_build_uv_args_no_command(self): + """Test building uv args with no command.""" + env = Environment(python="3.11") + args = env.build_uv_args() + expected = ["run", "--python", "3.11", "--with", "fastmcp"] + assert args == expected - 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_build_uv_args_string_command(self): + """Test building uv args with string command.""" + env = Environment() + args = env.build_uv_args("python") + expected = ["run", "--with", "fastmcp", "python"] + assert args == expected - 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_needs_uv_true(self): + """Test that needs_uv returns True when environment settings are present.""" + env = Environment(python="3.11") + assert env.needs_uv() is True - 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] + env = Environment(dependencies=["pkg"]) + assert env.needs_uv() is True - 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() + env = Environment(requirements="reqs.txt") + assert env.needs_uv() is True - 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] + env = Environment(project="/project") + assert env.needs_uv() is True + + env = Environment(editable="/pkg") + assert env.needs_uv() is True + + def test_needs_uv_false(self): + """Test that needs_uv returns False when no environment settings are present.""" + env = Environment() + assert env.needs_uv() is False